Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/reference/handoff-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,17 @@ under a fresh operation and current revision. Other execution fences remain:
| Expired execution matches the retained claim; eligible registered replacement actor | Use ordinary `todo claim` for atomic takeover, then use its new reference. |
| Active foreign holder, divergent history, or absent claim | Reconcile ownership through its lifecycle; never borrow another holder's proof. |
| `soft_claim` with released same-owner history, ordinary owner copy edit | Omit lease proof; keep the history. No replacement lease is needed or allowed. |
| Rejected wait revision on an already deferred Todo | Review and explicitly resume the original Todo, then reapply the reviewed deferral using a separate operation and current provider revision. Neither step grants execution. |
| Non-open Todo, acceptance hold or conflicting write scopes | Resolve the reported acquisition blocker. No acquire action is offered. |
| Edit changes retained leased work requirements or status | Use the owning lifecycle transition; acquiring another lease cannot authorize the metadata edit. |

For a deferred wait revision, the returned retry uses `--status open
--clear-resume-when` with a reviewed reason, fresh update operation and current
provider CAS. Read that result before submitting the intended deferral with
another operation and fresh CAS. Do not repeat the rejected same-status request
or carry its resume condition into the resume command. An active retained lease
must be released by its holder first; explicit stale proof remains rejected.

The existing `open -> blocked -> open` lifecycle also accepts a typed
prerequisite wait: use `todo update --status blocked --resume-when
todo_done:<dependency-todo> --reason '<bounded cause>'` after the active holder
Expand Down Expand Up @@ -346,6 +354,12 @@ claim+lease 均不可执行,必须先审核迁移;合并式 claim+lease 仍
提示不包含 execution key、不授予权限,也不修改 mode 或 provider。并发造成 version
变化时重新读取,不能绕过 CAS;dry-run 和拒绝路径不产生 Todo 或租约写入。

修改已 deferred Todo 的等待条件若被拒绝,恢复命令先以 `--status open
--clear-resume-when`、审核后的 reason、新 update operation 和当前 provider CAS
显式恢复原任务;读回后,再用另一个 operation 和新的 CAS 提交审核后的延期。
不重复原同状态请求,也不把其 resume 条件带入恢复命令。两步均不授予执行权,完整
任务和历史保留;有效的历史 lease 须先由 holder 释放,显式过期 proof 仍被拒绝。

### Agent 普通 CLI 操作

CLI 由明确的 `--agent-id` 认领给自己时,会在同一 canonical 事务中准备执行;
Expand Down
55 changes: 36 additions & 19 deletions loopx/control_plane/coordination/task_lease_proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,41 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod
if (lease?.active && !sameOwner) {
return {...base, action: "reconcile_lease_owner", reason: "Another active holder owns this execution. Its owner must release or explicitly hand it over through the lease lifecycle; this request cannot borrow its proof or take over. Inspect the current holder before retrying."};
}
// Both a bundled edit and a wait revision may need the same administrative
// resume. Probe its existing owner once; this never changes admission.
const reopen = {...input, patch: {}, clear_fields: [], planning_intent: {
status: "open", reason: "Reviewed lifecycle recovery", clear_resume_when: true,
}};
const lifecycleFacts = {goal_id: input.goal_id, todo_id: input.todo_id,
actor_agent_id: input.actor_agent_id, registered_agents: input.registered_agents,
lease: lease === null ? undefined : index.leases.get(input.todo_id),
lease_idempotency_key: input.lease_idempotency_key ?? null,
lease_expected_version: input.lease_expected_version ?? null, now: input.now};
const canReopen = mode === "hard_lease" && isBlockedLifecycleTransition(reopen, todo) &&
blockedLifecycleRejection(lifecycleFacts) === null;
const canResume = (mode === "hard_lease" || lease !== null) && isDeferredReopen(reopen, todo) &&
deferredReopenRejection(lifecycleFacts) === null;
const reopenRetry = {command: "loopx todo update --status open --reason '<reviewed reason>'",
cli_args: ["todo", "update", "--goal-id", input.goal_id, "--todo-id", input.todo_id,
...(input.actor_agent_id ? ["--agent-id", input.actor_agent_id] : []), "--status", "open"],
goal_id: input.goal_id, todo_id: input.todo_id, agent_id: input.actor_agent_id,
requires_flags: ["--reason"],
remove_flags: ["--text", "--claimed-by", "--required-write-scope", "--successor-todo-id"],
proof_source: "fresh_lifecycle_admission"};
if (todo.status === "deferred" && intent.status === "deferred") {
if (!canResume) {
return {...base, action: "resolve_lifecycle_edit",
reason: "This deferred Todo cannot currently resume through its administrative lifecycle. Inspect the retained execution and wait before revising it; an active lease must be released by its holder first. Do not repeat the deferred update or acquire execution on suspended work.",
...(lease?.active && sameOwner ? {release: {command: "loopx task-lease release",
requires_flags: ["--owner", "--idempotency-key", "--expected-version"],
proof_source: "current_owner_lease_readback"}} : {})};
}
return {...base, action: "resolve_lifecycle_edit",
reason: "Review the existing wait, then explicitly resume the unchanged Todo before reapplying the reviewed deferral. Use a separate update operation and current provider revision for each step; remove the old resume condition from the resume request. No acquire or old execution proof is needed, and neither transition grants execution. Keep the original requirements, ownership and history.",
retry: {...reopenRetry, cli_args: [...reopenRetry.cli_args, "--clear-resume-when"],
requires_flags: ["--reason", "--update-operation-id", "--update-expected-provider-revision"],
remove_flags: [...reopenRetry.remove_flags, "--resume-when"]}};
}
if (intent.status === "blocked" || intent.status === "deferred") {
return {...base, action: "resolve_lifecycle_edit",
reason: "Pause with a reason and optional note/evidence in one Todo update. The current holder's lease is retired atomically; no acquire or separate release is needed. Work requirements, text, ownership and successor changes are separate operations. A foreign active holder cannot be retired by this request; explicit stale proof is never replaced.",
Expand Down Expand Up @@ -221,31 +256,13 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod
// claim/acquire a lease that the blocked status itself makes ineligible.
// This is a diagnostic probe only; the actual retry rechecks owner admission
// and the lifecycle fence in the ordinary provider transaction.
const reopen = {...input, patch: {}, clear_fields: [], planning_intent: {
status: "open", reason: "Reviewed lifecycle recovery", clear_resume_when: true,
}};
const lifecycleFacts = {goal_id: input.goal_id, todo_id: input.todo_id,
actor_agent_id: input.actor_agent_id, registered_agents: input.registered_agents,
lease: lease === null ? undefined : index.leases.get(input.todo_id),
lease_idempotency_key: input.lease_idempotency_key ?? null,
lease_expected_version: input.lease_expected_version ?? null, now: input.now};
const canReopen = mode === "hard_lease" && isBlockedLifecycleTransition(reopen, todo) &&
blockedLifecycleRejection(lifecycleFacts) === null;
const canResume = (mode === "hard_lease" || lease !== null) && isDeferredReopen(reopen, todo) &&
deferredReopenRejection(lifecycleFacts) === null;
if (intent.status === "open" && (canReopen || canResume)) {
const nextEdit = mode === "soft_claim"
? "Then retry only a permitted owner copy edit without either lease proof flag. Planning, ownership and terminal edits keep their own admission; do not acquire a lease in soft_claim."
: "Ordinary owner metadata then needs no execution lease; other work still uses its execution or lifecycle admission.";
return {...base, action: "resolve_lifecycle_edit",
reason: `Reopen this ${todo.status} Todo with status=open and a reviewed reason; optional note/evidence can commit with it. Keep text, work requirements, ownership and successors separate. Use a fresh update operation and current provider revision after reviewing the wait. An omitted wait is cleared by this explicit resume. ${nextEdit} Reopening alone grants no execution authority.`,
retry: {command: "loopx todo update --status open --reason '<reviewed reason>'",
cli_args: ["todo", "update", "--goal-id", input.goal_id, "--todo-id", input.todo_id,
...(input.actor_agent_id ? ["--agent-id", input.actor_agent_id] : []), "--status", "open"],
goal_id: input.goal_id, todo_id: input.todo_id, agent_id: input.actor_agent_id,
requires_flags: ["--reason"],
remove_flags: ["--text", "--claimed-by", "--required-write-scope", "--successor-todo-id"],
proof_source: "fresh_lifecycle_admission"}};
retry: reopenRetry};
}
const editRejection = lease === null || isOwnerDeferral(input, todo) ? null : leasedTodoEditRejection(todo, intent);
if (editRejection !== null) {
Expand Down
87 changes: 87 additions & 0 deletions tests/control_plane/test_todo_administrative_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from canonical_authority_fixture import isolate_sqlite_runtime, promoted_create_fixture
from loopx.chat_action_store import ChatActionStore
from loopx.chat_actions import ChatActionService
from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted
from loopx.control_plane.todos.mutation_api import add_goal_todo


Expand Down Expand Up @@ -78,6 +79,92 @@ def test_cli_copy_pause_resume_defer_without_lease_bookkeeping(journey):
assert cli("todo", "list")["todo"] == readback


@pytest.mark.parametrize("retained", ["absent", "released"])
def test_deferred_wait_edit_recovers_by_reviewed_resume_then_deferral(journey, retained):
cli, _, _, runtime = journey
text = "Original requirements\n" + "完整验收 🙂 " * 80 + "FINAL_ACCEPTANCE"
cli("todo", "update", "--agent-id", "agent-a", "--text", text,
"--note", "Preserve this context", "--evidence", "evidence:original", "--priority", "P2")
if retained == "released":
cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "original")
cli("todo", "update", "--agent-id", "agent-a", "--status", "deferred",
"--reason", "Wait for the original event", "--resume-when", "resume_at:2099-01-01T00:00:00Z")

def snapshot():
return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a", include_leases=True)

before = snapshot()
original = before["todos"][0]
wait_edit = ("todo", "update", "--agent-id", "agent-a", "--status", "deferred",
"--reason", "Reviewed the next observation", "--resume-when", "resume_at:2099-02-01T00:00:00Z")
# A diagnostic is not permission to rewrite a suspended task or consume its
# released execution. Its proposed recovery must reach a lawful state.
rejected = cli(*wait_edit, ok=False)
assert snapshot() == before
recovery = rejected["recovery"]
assert recovery["execution_authority_granted"] is False
retry = recovery["retry"]
args = retry["cli_args"]
assert args[args.index("--status") + 1] == "open"
assert "--clear-resume-when" in args and "--resume-when" in retry["remove_flags"]
assert {"--reason", "--update-operation-id", "--update-expected-provider-revision"} <= set(retry["requires_flags"])
assert "acquire" not in recovery

wrong_owner = cli("todo", "update", "--agent-id", "agent-b", "--status", "open",
"--clear-resume-when", "--reason", "Cannot review another owner's wait", ok=False)
assert wrong_owner["error_code"] == "update_owner_mismatch"
assert "retry" not in wrong_owner["recovery"]
if retained == "released":
old = before["leases"][0]
refused = cli(*wait_edit, "--task-lease-idempotency-key", old["idempotency_key"],
"--task-lease-expected-version", str(old["version"]), ok=False)
assert refused["recovery"]["action"] == "inspect_current_proof"
assert "retry" not in refused["recovery"]
assert snapshot() == before

def retry_cli(*extra):
child = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", *args, *extra],
capture_output=True, text=True, timeout=60)
assert child.returncode == 0, child.stdout + child.stderr
return json.loads(child.stdout)

reviewed = ("--reason", "Owner reviewed the old wait before revising it",
"--update-operation-id", "resume-for-wait-edit",
"--update-expected-provider-revision", before["provider_revision"])
assert retry_cli(*reviewed, "--dry-run")["status"] == "planned"
assert snapshot() == before
resumed = retry_cli(*reviewed)
assert resumed["deferred_resume_transition"]["execution_authority_granted"] is False
opened = snapshot()
assert opened["todos"][0]["status"] == "open"
assert opened["todos"][0].get("resume_when") is None
assert opened["leases"] == before["leases"]
assert retry_cli(*reviewed)["status"] == "replayed"
assert snapshot() == opened

# Each new intent uses the current CAS; a historical recovery receipt cannot
# authorize the subsequent wait or revive the retired execution.
stale = cli(*wait_edit, "--update-operation-id", "stale-wait-edit",
"--update-expected-provider-revision", before["provider_revision"], ok=False)
assert stale["error_code"] == "provider_revision_mismatch"
assert snapshot() == opened
rearm = (*wait_edit, "--update-operation-id", "reviewed-wait-edit",
"--update-expected-provider-revision", opened["provider_revision"])
applied = cli(*rearm)
assert applied["deferred_transition"]["execution_authority_granted"] is False
after = snapshot()
task = after["todos"][0]
assert task["status"] == "deferred" and task["resume_when"] == "resume_at:2099-02-01T00:00:00Z"
changed = {"status", "reason", "resume_when", "updated_at", "last_actor_agent_id"}
assert {k: v for k, v in original.items() if k not in changed} == {
k: v for k, v in task.items() if k not in changed}
assert after["leases"] == before["leases"]
assert cli(*rearm)["status"] == "replayed"
assert snapshot() == after
assert retry_cli(*reviewed)["status"] == "replayed"
assert snapshot() == after, "historical resume cannot replace the newer reviewed wait"


def test_chat_uses_same_atomic_owner_and_refuses_changed_review_basis(journey, tmp_path):
cli, todo_id, registry, runtime = journey
cli("todo", "update", "--agent-id", "agent-a", "--task-repository", "git:github.com/example/original")
Expand Down
19 changes: 19 additions & 0 deletions tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,25 @@ for (const provider of ["file", "sqlite"] as const) {
}
});

providerTest(`${provider}: wait-edit recovery cannot bypass an active holder`, async () => {
for (const holder of AGENTS) {
const store = await seeded(provider, {}, {...oldLease("active", "2026-09-06T00:00:00Z"), owner: holder});
const before = await read(store);
const input = {...resume("revise-suspended-wait"), planning_intent: {status: "deferred",
resume_when: "resume_at:2026-09-06T01:00:00Z", reason: "Reviewed next observation"}};
for (const dry_run of [true, false]) {
const rejected = await executeCoordinationTodoUpdate(store, {...input, dry_run});
assert.equal(rejected.status, "failed", JSON.stringify(rejected));
const recovery = rejected.recovery as JsonObject;
assert.equal(recovery.execution_authority_granted, false);
assert.equal(recovery.retry, undefined, "active execution cannot authorize administrative resume");
assert.equal(recovery.acquire, undefined);
if (holder !== OWNER) assert.equal(recovery.action, "reconcile_lease_owner");
assert.deepEqual(await read(store), before);
}
}
});

providerTest(`${provider}: an unclaimed deferred Todo stays unclaimed; excluded actors cannot reopen it`, async () => {
const unclaimed = await seeded(provider, {claimed_by: null});
const applied = await executeCoordinationTodoUpdate(unclaimed, resume("unclaimed-resume"));
Expand Down
31 changes: 31 additions & 0 deletions tests/control_plane_ts/todo_administrative_edits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,37 @@ for (const provider of ["file", "sqlite", "postgresql"] as const) {
assert.equal((resumed.blocked_lifecycle_transition as JsonObject).execution_authority_granted, false);
assert.deepEqual((await head(store)).head.leases, paused.head.leases);
});
test(`${provider}: deferred wait recovery reaches a reviewed new wait without execution`, options, async t => {
const {store, input} = await fixture(t, provider, "released");
const defer = {...input, operation_id: "original-wait", planning_intent: {status: "deferred",
resume_when: "resume_at:2026-09-26T06:00:00Z", reason: "Original observation"}};
assert.equal((await executeCoordinationTodoUpdate(store, defer)).status, "applied");
const before = await head(store);
const revised = {...defer, operation_id: "revised-wait", planning_intent: {...defer.planning_intent,
resume_when: "resume_at:2026-09-27T06:00:00Z", reason: "Reviewed next observation"}};
const refused = await executeCoordinationTodoUpdate(store, revised);
assert.equal(refused.status, "failed");
const recovery = refused.recovery as JsonObject;
assert.equal(recovery.execution_authority_granted, false);
const retry = recovery.retry as JsonObject, argv = retry.cli_args as string[];
assert.equal(argv[argv.indexOf("--status") + 1], "open");
assert.ok(argv.includes("--clear-resume-when"));
assert.deepEqual(await head(store), before);
const resume = {...input, operation_id: "reviewed-resume", expected_provider_revision: before.provider_revision,
planning_intent: {status: "open", clear_resume_when: true, reason: "Owner reviewed the old wait"}};
assert.equal((await executeCoordinationTodoUpdate(store, resume)).status, "applied");
const opened = await head(store);
const result = await executeCoordinationTodoUpdate(store, {...revised, expected_provider_revision: opened.provider_revision});
assert.equal(result.status, "applied", JSON.stringify(result));
assert.equal((result.deferred_transition as JsonObject).execution_authority_granted, false);
const after = await head(store), todo = (after.head.todos as JsonObject[])[0]!;
assert.equal(todo.status, "deferred");
assert.equal(todo.resume_when, revised.planning_intent.resume_when);
assert.deepEqual(todo.required_write_scopes, ["src/**"]);
assert.deepEqual(after.head.leases, before.head.leases);
assert.equal((await executeCoordinationTodoUpdate(store, resume)).status, "replayed");
assert.deepEqual(await head(store), after);
});
test(`${provider}: explicit stale proof, foreign holder and changed provider revision never auto-heal`, options, async t => {
const {store, input} = await fixture(t, provider);
const before = await head(store);
Expand Down
Loading