diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 764d222a4a..11adcdbdb0 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1,4 +1,5 @@ import {claimExecutionReference, claimExecutionProof} from "./claim_execution.ts"; +import {executeCoordinationTodoClaim} from "./todo_claim.ts"; import {HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {planUserCompletion, requireCompletionDecisionOutcome} from "../todos/user_completion.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; @@ -1391,11 +1392,42 @@ export async function executeCoordinationTodoTerminalLifecycle( ); } if (authority.outcome === "rejected" || authority.outcome === "conflict") { + // Missing execution is ordinary task preparation. Reuse the real claim + // preview instead of guessing from lease expiry or teaching callers to + // copy internal key/version fields. Explicit stale proof remains rejected. + let recovery: JsonObject | undefined; + if (input.command === "complete" && handoffMode === "hard_lease" && authorityTodo.role === "agent" && + input.actor_agent_id !== null && normalized.claim_operation_id === undefined && + input.lease_idempotency_key === null && input.lease_expected_version === null && + ["handoff_mode_requires_lease", "lease_fence_required"].includes(authority.code)) { + const preview = await executeCoordinationTodoClaim(store, { + goal_id: input.goal_id, todo_id: input.todo_id, actor_agent_id: input.actor_agent_id, + claimed_by: input.actor_agent_id, expected_role: input.expected_role, + registered_agents: input.registered_agents, manage_execution: true, dry_run: true, + operation_id: `terminal-claim-preview-${canonicalAuthoritySha256({operation_id: input.operation_id, + goal_id: input.goal_id, todo_id: input.todo_id, actor_agent_id: input.actor_agent_id}).slice(0, 32)}`, + now: input.now, + }, authoritySourcesCurrent).catch(() => null); + // Optional guidance cannot replace the already established rejection + // with an unrelated provider/read failure. + if (preview?.dry_run === true && ["planned", "no_change"].includes(String(preview.status))) { + recovery = {action: "claim_task", execution_authority_granted: false, + reason: "Prepare this task with ordinary todo claim, then retry this original operation using the returned claim_operation_id. The claim rechecks current ownership and eligibility; this preview grants no execution authority.", + claim: {command: "loopx todo claim", cli_args: ["todo", "claim", "--goal-id", input.goal_id, + "--todo-id", input.todo_id, "--agent-id", input.actor_agent_id, "--claimed-by", input.actor_agent_id]}, + retry: {command: `loopx todo ${input.command}`, requires_flags: ["--claim-operation-id"], + reference_source: "claim_operation_id_from_claim"}}; + } else if (preview !== null && typeof preview.reason_code === "string") { + recovery = {action: "resolve_acquire_rejection", execution_authority_granted: false, + reason_code: preview.reason_code, reason: typeof preview.reason === "string" + ? preview.reason : "Task preparation is not currently available."}; + } + } return terminalFailure( authority.code, authority.code === "handoff_mode_retired" ? HANDOFF_MODE_RETIRED_REASON : `canonical Todo terminal lifecycle rejected the request: ${authority.code}`, - {terminal_decision: authority}, + {terminal_decision: authority, ...(recovery === undefined ? {} : {recovery, handoff_mode: handoffMode})}, "decision_rejection", ); } diff --git a/loopx/control_plane/todos/markdown.py b/loopx/control_plane/todos/markdown.py index ca238e638a..7e572e80d7 100644 --- a/loopx/control_plane/todos/markdown.py +++ b/loopx/control_plane/todos/markdown.py @@ -13,7 +13,12 @@ def _render_lease_recovery(payload: dict[str, Any]) -> list[str]: recovery = payload.get("recovery") if not isinstance(recovery, dict) or not recovery.get("reason") or not payload.get("handoff_mode"): return [] - lines = [f"- handoff_mode: `{payload['handoff_mode']}`", f"- recovery: {recovery['reason']}"] + retry = recovery.get("retry") + complete_claim_recovery = (recovery.get("action") == "claim_task" + and isinstance(retry, dict) and retry.get("command") == "loopx todo complete") + lines = ([] if complete_claim_recovery + else [f"- handoff_mode: `{payload['handoff_mode']}`"]) + lines.append(f"- recovery: {recovery['reason']}") for step in ("claim", "inspect", "acquire", "retry", "release"): instruction = recovery.get(step) if isinstance(instruction, dict) and instruction.get("command"): diff --git a/tests/control_plane/test_goal_handoff_mode_cli.py b/tests/control_plane/test_goal_handoff_mode_cli.py index 0d78eceb5d..cf3609557a 100644 --- a/tests/control_plane/test_goal_handoff_mode_cli.py +++ b/tests/control_plane/test_goal_handoff_mode_cli.py @@ -415,6 +415,7 @@ def test_todo_supersede_cli_requires_key_in_hard_lease_and_accepts_it( assert exit_code == 1 assert payload["ok"] is False assert payload["error_code"] == "lease_fence_required" + assert "recovery" not in payload exit_code, payload = _run_cli( capsys, diff --git a/tests/control_plane/test_todo_claim_argument_recovery.py b/tests/control_plane/test_todo_claim_argument_recovery.py index 338b384f54..ff75d14d74 100644 --- a/tests/control_plane/test_todo_claim_argument_recovery.py +++ b/tests/control_plane/test_todo_claim_argument_recovery.py @@ -212,6 +212,31 @@ def test_ordinary_claim_progress_completion_need_no_lease_flags(claim_cli): assert snapshot() == before +def test_terminal_refusal_recovers_through_ordinary_claim(claim_cli): + _, run, snapshot, prefix, _, _, _, _ = claim_cli + complete = [*prefix, "todo", "complete", "--goal-id", GOAL, "--todo-id", TODO, + "--agent-id", "peer-a", "--evidence", "evidence:validated-output"] + before = snapshot() + rejected = run(complete, expected_exit=1) + assert snapshot() == before + assert rejected["error_code"] == "handoff_mode_requires_lease" + recovery = rejected["recovery"] + assert recovery["action"] == "claim_task" + assert recovery["execution_authority_granted"] is False + from loopx.control_plane.todos.markdown import render_todo_markdown + rendered = render_todo_markdown(rejected) + assert "loopx todo claim" in rendered and "--claim-operation-id" in rendered + assert "task-lease" not in rendered and "handoff_mode:" not in rendered + prepared = run([*prefix, *recovery["claim"]["cli_args"], + "--claim-operation-id", "after-terminal-refusal"]) + assert prepared["ok"] and prepared["execution_managed"] + retry = [*complete, "--claim-operation-id", prepared["claim_operation_id"]] + assert run(retry)["ok"] + completed = snapshot() + assert run(retry)["ok"] + assert snapshot() == completed + + @pytest.mark.parametrize("command", ["update", "complete"]) def test_empty_managed_reference_is_rejected_without_mutation(claim_cli, command): _, run, snapshot, prefix, claim, _, _, _ = claim_cli diff --git a/tests/control_plane_ts/managed_claim_execution_conformance.ts b/tests/control_plane_ts/managed_claim_execution_conformance.ts index 3ed305f20e..c94e1e8486 100644 --- a/tests/control_plane_ts/managed_claim_execution_conformance.ts +++ b/tests/control_plane_ts/managed_claim_execution_conformance.ts @@ -34,6 +34,97 @@ export function registerManagedClaimExecutionConformance(provider: string, facto claim_operation_id: request.operation_id}; return {store, request, progress}; } + function completion(request: Awaited>["request"]) { + return {goal_id: request.goal_id, todo_id: request.todo_id, actor_agent_id: "agent-a", + expected_role: "agent" as const, registered_agents: request.registered_agents, command: "complete" as const, + operation_identity: {kind: "completion_turn" as const}, claim_operation_id: request.operation_id, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, + lease_idempotency_key: null, lease_expected_version: null, allow_user_gate_auto_acquire: false, + requested_no_followup: false, requested_completion_turn_key: "original-turn", + requested_completion_identity_source: "turn_settlement" as const, linked_successor_todo_ids: [], + successor_intents: [], note: null, evidence: "evidence:validated-output", reason: null, + clear_claim: false, validation_declaration: null, validation_receipt: null, + completion_policy_request: null, dry_run: false, now: request.now}; + } + test(`${provider} managed execution: terminal recovery uses ordinary claim admission without effects`, async t => { + for (const active of [false, true]) { + const {store, request} = await setup(t); + if (active) assert.equal((await claim(store, request)).status, "applied"); + const original = {...completion(request), claim_operation_id: undefined}; + const before = await loaded(store); + const rejected = await complete(store, original); + assert.equal(rejected.status, "failed"); + assert.equal(rejected.reason_code, active ? "lease_fence_required" : "handoff_mode_requires_lease"); + const recovery = rejected.recovery as JsonObject; + assert.equal(recovery.action, "claim_task"); + assert.equal(recovery.execution_authority_granted, false); + assert.deepEqual((recovery.claim as JsonObject).cli_args, ["todo", "claim", "--goal-id", request.goal_id, + "--todo-id", request.todo_id, "--agent-id", "agent-a", "--claimed-by", "agent-a"]); + assert.deepEqual((recovery.retry as JsonObject).requires_flags, ["--claim-operation-id"]); + assert.equal(recovery.inspect, undefined); + assert.equal(recovery.acquire, undefined); + assert.deepEqual(await loaded(store), before, "the completion refusal and its claim preview never write"); + const prepared = await claim(store, {...request, operation_id: "prepared-after-refusal"}); + assert.ok(["applied", "no_change"].includes(String(prepared.status)), JSON.stringify(prepared)); + const retry = {...original, claim_operation_id: String(prepared.claim_operation_id)}; + assert.equal((await complete(store, retry)).status, "applied"); + const done = await loaded(store); + assert.equal((done.head.leases as JsonObject[])[0]!.status, "released"); + assert.equal((await complete(store, retry)).status, "replayed"); + assert.deepEqual(await loaded(store), done, "recovery keeps the original terminal identity and releases once"); + } + }); + test(`${provider} managed execution: terminal advice does not replace explicit proof or bypass task blockers`, async t => { + for (const scenario of ["explicit-proof", "foreign-holder", "excluded", "blocked", "overlapping-work"]) { + const {store, request} = await setup(t); + let original = {...completion(request), claim_operation_id: undefined} as Parameters[1]; + if (scenario === "explicit-proof") original = {...original, lease_idempotency_key: "unproven-key"}; + if (scenario === "foreign-holder") { + assert.equal((await claim(store, {...request, actor_agent_id: "agent-b", claimed_by: "agent-b"})).status, "applied"); + } + if (["excluded", "blocked", "overlapping-work"].includes(scenario)) { + const head = await loaded(store); const todo = (head.head.todos as JsonObject[])[0]!; + const change = scenario === "excluded" ? {...todo, excluded_agents: ["agent-a"]} + : scenario === "blocked" ? {...todo, status: "blocked"} + : {...todo, todo_id: "todo_other", text: "Independent work occupying the same scope"}; + assert.equal((await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: request.goal_id, + operation_id: "prepare-blocker", expected_provider_revision: head.provider_revision, projection: head.head, + mutations: [{kind: "todo_upsert", todo: change}]}))).status, "applied"); + if (scenario === "overlapping-work") assert.equal((await claim(store, {...request, todo_id: "todo_other", + actor_agent_id: "agent-b", claimed_by: "agent-b", operation_id: "occupy-scope"})).status, "applied"); + } + const before = await loaded(store); const result = await complete(store, original); + assert.equal(result.status, "failed", scenario); + assert.notEqual((result.recovery as JsonObject | undefined)?.action, "claim_task", scenario); + if (scenario === "explicit-proof") assert.equal(result.recovery, undefined); + assert.deepEqual(await loaded(store), before, scenario); + } + }); + test(`${provider} managed execution: unavailable recovery observation retains the original terminal refusal`, async t => { + const {store, request} = await setup(t); const before = await loaded(store); + const unavailable = new Proxy(store, {get(target, key) { + if (key === "readReceipt") return async (id: string) => { + if (id.startsWith("terminal-claim-preview-")) throw new Error("synthetic observation unavailable"); + return target.readReceipt(id); + }; + const value = Reflect.get(target, key, target); + return typeof value === "function" ? value.bind(target) : value; + }}); + const result = await complete(unavailable, {...completion(request), claim_operation_id: undefined}); + assert.equal(result.reason_code, "handoff_mode_requires_lease"); + assert.equal(result.recovery, undefined); + assert.deepEqual(await loaded(store), before); + }); + test(`${provider} managed execution: supersede never recommends an unsupported claim-reference retry`, async t => { + const {store, request} = await setup(t); + const before = await loaded(store); + const rejected = await complete(store, {...completion(request), command: "supersede" as const, + claim_operation_id: undefined, operation_identity: {kind: "explicit" as const, operation_id: "terminal-supersede"}, + requested_completion_turn_key: null, reason: "Continue through declared successor work"}); + assert.equal(rejected.status, "failed"); + assert.equal(rejected.recovery, undefined); + assert.deepEqual(await loaded(store), before); + }); test(`${provider} managed execution: ordinary claim/progress carry one execution, renew only near expiry, recover without writes`, async t => { const {store, request, progress} = await setup(t); const before = await loaded(store); @@ -213,16 +304,7 @@ export function registerManagedClaimExecutionConformance(provider: string, facto test(`${provider} managed execution: completion releases once and the original Turn closes without new execution`, async t => { const {store, request} = await setup(t); assert.equal((await claim(store, request)).status, "applied"); - const terminal = {goal_id: request.goal_id, todo_id: request.todo_id, actor_agent_id: "agent-a", - expected_role: "agent" as const, registered_agents: request.registered_agents, command: "complete" as const, - operation_identity: {kind: "completion_turn" as const}, claim_operation_id: request.operation_id, - lifecycle_grants: [], authority_reason: null, decision_outcome: null, - lease_idempotency_key: null, lease_expected_version: null, allow_user_gate_auto_acquire: false, - requested_no_followup: false, requested_completion_turn_key: "original-turn", - requested_completion_identity_source: "turn_settlement" as const, linked_successor_todo_ids: [], - successor_intents: [], note: null, evidence: "evidence:validated-output", reason: null, - clear_claim: false, validation_declaration: null, validation_receipt: null, - completion_policy_request: null, dry_run: false, now: request.now}; + const terminal = completion(request); const done = await complete(store, terminal); assert.equal(done.status, "applied", JSON.stringify(done)); const completed = await loaded(store);