From ecd984a291b70c7c9d65a72122228025d503bcd3 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 2 Nov 2026 21:01:40 +0800 Subject: [PATCH 1/3] fix(todos): return ordinary claim recovery after execution expiry Preview normal claim admission only when the accepted execution retains its lineage and has expired. Changed execution identity requires ownership reconciliation; guidance never acquires or revives a grant. Preserve the original CLI route and qualify recovery through real providers and caller readback. Signed-off-by: song --- .../coordination/claim_execution.ts | 26 +++- .../coordination/claim_execution_recovery.ts | 50 +++++++ .../coordination/todo_terminal_lifecycle.ts | 14 +- .../control_plane/coordination/todo_update.ts | 8 +- .../todos/provider_terminal_lifecycle.py | 9 ++ loopx/control_plane/todos/provider_update.py | 2 +- .../test_canonical_claim_execution_proof.py | 126 ++++++++++++++++++ .../managed_claim_execution_conformance.ts | 95 +++++++++++++ 8 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 loopx/control_plane/coordination/claim_execution_recovery.ts diff --git a/loopx/control_plane/coordination/claim_execution.ts b/loopx/control_plane/coordination/claim_execution.ts index 27b9857869..04c2e2875e 100644 --- a/loopx/control_plane/coordination/claim_execution.ts +++ b/loopx/control_plane/coordination/claim_execution.ts @@ -26,38 +26,52 @@ type ExecutionInput = {goal_id: string; todo_id: string; actor_agent_id: string export type ClaimWorkspaceObserver = (writeScopes: readonly string[]) => Promise; +/** A diagnostic distinction, never permission to renew or adopt another grant. */ +export class ClaimExecutionUnavailable extends AuthorityStoreProtocolError { + readonly sameExecutionExpired: boolean; + constructor(message: string, sameExecutionExpired = false) { + super(message); + this.sameExecutionExpired = sameExecutionExpired; + } +} + /** Resolve only this accepted execution. No acquisition, adoption of a newer * generation, or write retry is hidden behind a progress/completion request. */ export async function claimExecutionProof(store: AuthorityStore, head: JsonObject, input: ExecutionInput, operationId: string, allowReleasedCloseout = false): Promise<{lease_idempotency_key: string; lease_expected_version: number} | null> { const receipt = await store.readReceipt(operationId); if (receipt.status !== "found" || receipt.receipts.length !== 1) { - throw new AuthorityStoreProtocolError("Original task claim unavailable; recover that claim before writing"); + throw new ClaimExecutionUnavailable("Original task claim unavailable; recover that claim before writing"); } const original = receipt.receipts[0]; const result = canonicalAuthorityObject(original.result, "original task claim"); if (original.schema_version !== "loopx_coordination_todo_claim_receipt_v0" || original.operation_id !== operationId || original.goal_id !== input.goal_id || result.todo_id !== input.todo_id || result.claimed_by !== input.actor_agent_id || - result.execution_managed !== true) throw new AuthorityStoreProtocolError( + result.execution_managed !== true) throw new ClaimExecutionUnavailable( "The claim reference does not name this actor's managed task execution"); // A soft claim has no execution lease. Its normal writer still checks policy. if (result.handoff_mode !== "hard_lease") { if (result.handoff_mode === "soft_claim" && head.handoff_mode === "soft_claim") return null; - throw new AuthorityStoreProtocolError("Task ownership policy changed; review a new task claim"); + throw new ClaimExecutionUnavailable("Task ownership policy changed; review a new task claim"); } - if (head.handoff_mode !== "hard_lease") throw new AuthorityStoreProtocolError("Task ownership policy changed; review the current task before continuing"); + if (head.handoff_mode !== "hard_lease") throw new ClaimExecutionUnavailable("Task ownership policy changed; review the current task before continuing"); const accepted = canonicalTaskLease(canonicalAuthorityObject(result.lease, "accepted execution"), input.goal_id, input.todo_id); const index = indexCoordinationProjection(head, input.goal_id); const raw = index.leases.get(input.todo_id); const current = raw && canonicalTaskLease(raw, input.goal_id, input.todo_id); const retainedCloseout = allowReleasedCloseout && current?.status === "released" && index.todos.get(input.todo_id)?.status === "done"; - if (accepted.owner !== input.actor_agent_id || !current || !(leaseIsActive(current, input.now) || retainedCloseout) || current.owner !== input.actor_agent_id || current.idempotency_key !== accepted.idempotency_key || + const active = current !== undefined && leaseIsActive(current, input.now); + if (accepted.owner !== input.actor_agent_id || !current || current.owner !== input.actor_agent_id || current.idempotency_key !== accepted.idempotency_key || leaseEpoch(current) !== leaseEpoch(accepted) || leaseVersion(current) < leaseVersion(accepted) || !sameLeaseWorkspace(current.write_workspace, accepted.write_workspace) || leaseWriteRepository(current.write_repository) !== leaseWriteRepository(accepted.write_repository)) { - throw new AuthorityStoreProtocolError("Original task execution changed; reconcile task ownership before a new claim"); + throw new ClaimExecutionUnavailable("Original task execution changed; reconcile task ownership before a new claim"); + } + if (!active && !retainedCloseout) { + throw new ClaimExecutionUnavailable("Original task execution changed; reconcile task ownership before a new claim", + current.status === "active"); } return {lease_idempotency_key: String(current.idempotency_key), lease_expected_version: leaseVersion(current)}; } diff --git a/loopx/control_plane/coordination/claim_execution_recovery.ts b/loopx/control_plane/coordination/claim_execution_recovery.ts new file mode 100644 index 0000000000..86c9a7296a --- /dev/null +++ b/loopx/control_plane/coordination/claim_execution_recovery.ts @@ -0,0 +1,50 @@ +/** Reuse ordinary claim admission for actionable, effect-free expiry guidance. */ +import type {JsonObject} from "../effect_program.ts"; +import type {AuthorityStore} from "./authority_store.ts"; +import type {AuthoritySourceCheck} from "./authority_source.ts"; +import {canonicalAuthoritySha256} from "./authority_store_codec.ts"; +import {ClaimExecutionUnavailable} from "./claim_execution.ts"; +import {executeCoordinationTodoClaim, type CoordinationTodoClaimInput} from "./todo_claim.ts"; + +type RecoveryInput = Pick & { + claim_operation_id: string; expected_provider_revision: string; + }; + +export async function claimExecutionRecovery(store: AuthorityStore, input: RecoveryInput, + command: "update" | "complete", error: unknown, + authoritySourcesCurrent: AuthoritySourceCheck): Promise { + if (!(error instanceof ClaimExecutionUnavailable)) return undefined; + if (!error.sameExecutionExpired || input.actor_agent_id === null) { + return {action: "reconcile_lease_owner", execution_authority_granted: false, + reason: "Inspect this Todo and reconcile its original claim and current ownership before preparing a new claim. Do not replace the original execution proof with another grant's key or version.", + inspect: {command: "loopx todo list", cli_args: ["todo", "list", "--goal-id", input.goal_id, + "--todo-id", input.todo_id]}}; + } + // Pin the failed observation. A newer head must not make another execution + // look like recovery of the expired one. Real claim still rechecks its CAS. + 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: `claim-recovery-preview-${canonicalAuthoritySha256({operation_id: input.operation_id, + claim_operation_id: input.claim_operation_id, command}).slice(0, 32)}`, + expected_provider_revision: input.expected_provider_revision, now: input.now, + }, authoritySourcesCurrent).catch(() => null); + // An optional observation failure must not hide the original proof refusal. + if (preview === null) return undefined; + if (preview.dry_run === true && ["planned", "no_change"].includes(String(preview.status))) { + return {action: "claim_task", lease_state: "expired", execution_authority_granted: false, + reason: "The original execution expired. Explicitly run a new ordinary Todo claim, then retry the unchanged operation with its returned claim_operation_id. Do not reuse the expired claim id. 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 ${command}`, requires_flags: ["--claim-operation-id"], + reference_source: "claim_operation_id_from_claim"}}; + } + if (typeof preview.reason_code === "string") { + return {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 undefined; +} diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 5d6e1e30a2..bde8e3da2e 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 {claimExecutionRecovery} from "./claim_execution_recovery.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"; @@ -1275,9 +1276,16 @@ export async function executeCoordinationTodoTerminalLifecycle( } head = observation.authority; } - if (input.claim_operation_id !== undefined) { - try { input = {...input, ...await claimExecutionProof(store, head.head, input, input.claim_operation_id, input.requested_no_followup), claim_operation_id: undefined}; } - catch (error) { return terminalFailure("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable"); } + const claimReference = input.claim_operation_id; + if (claimReference !== undefined) { + try { input = {...input, ...await claimExecutionProof(store, head.head, input, claimReference, input.requested_no_followup), claim_operation_id: undefined}; } + catch (error) { + const recovery = input.command === "complete" ? await claimExecutionRecovery(store, + {...input, claim_operation_id: claimReference, expected_provider_revision: head.provider_revision}, + "complete", error, authoritySourcesCurrent) : undefined; + return terminalFailure("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable", + recovery === undefined ? {} : {handoff_mode: head.head.handoff_mode, recovery}); + } } let update: NormalizedCoordinationTodoUpdateInput | undefined; if (input.user_update !== undefined) { diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 891376e718..6482b239ab 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -1,4 +1,5 @@ import {claimExecutionProof, maintainClaimExecution} from "./claim_execution.ts"; +import {claimExecutionRecovery} from "./claim_execution_recovery.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; import type { JsonObject } from "../effect_program.ts"; @@ -221,7 +222,12 @@ export async function executeCoordinationTodoUpdate( const claimReference = input.claim_operation_id; if (claimReference !== undefined) { try { input = {...input, ...await claimExecutionProof(store, head.head, input, claimReference), claim_operation_id: undefined}; } - catch (error) { return rejection("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable"); } + catch (error) { + const recovery = await claimExecutionRecovery(store, {...input, claim_operation_id: claimReference, + expected_provider_revision: head.provider_revision}, "update", error, authoritySourcesCurrent); + return {...rejection("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable"), + ...(recovery === undefined ? {} : {handoff_mode: head.head.handoff_mode, recovery})}; + } } const rejected = todoUpdateAdmissionRejection(head.head, target.todo, target.leases, input); if (rejected !== null) return {...rejection(rejected.code, rejected.reason), diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 1b8c7ff7a9..1e02721eea 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -464,6 +464,15 @@ def terminal_canonical_todo_if_promoted( if validation_failure is not None: return validation_failure payload = dict(result) + # Recovery describes native decisions. Bind its executable arguments to + # this exact caller route so an explicit registry cannot become a default. + recovery = payload.get("recovery") + if isinstance(recovery, dict): + for step in ("claim", "inspect", "retry", "acquire", "release"): + descriptor = recovery.get(step) + if isinstance(descriptor, dict) and isinstance(descriptor.get("cli_args"), list): + descriptor["cli_args"] = [f"--registry={registry_path}", + f"--runtime-root={runtime_root}", "--format", "json", *descriptor["cli_args"]] if completion_validation_executed: payload["completion_validation_executed"] = True if ( diff --git a/loopx/control_plane/todos/provider_update.py b/loopx/control_plane/todos/provider_update.py index feb7027505..d5b2de7273 100644 --- a/loopx/control_plane/todos/provider_update.py +++ b/loopx/control_plane/todos/provider_update.py @@ -333,7 +333,7 @@ def update_canonical_todo_if_promoted( # an unrelated Goal when this call used an explicit registry/runtime. recovery = result.get("recovery") if isinstance(recovery, dict): - for step in ("inspect", "retry", "acquire", "release"): + for step in ("claim", "inspect", "retry", "acquire", "release"): descriptor = recovery.get(step) if isinstance(descriptor, dict) and isinstance(descriptor.get("cli_args"), list): descriptor["cli_args"] = [f"--registry={registry_path}", diff --git a/tests/control_plane/test_canonical_claim_execution_proof.py b/tests/control_plane/test_canonical_claim_execution_proof.py index 5f9bac937a..52e4a891e6 100644 --- a/tests/control_plane/test_canonical_claim_execution_proof.py +++ b/tests/control_plane/test_canonical_claim_execution_proof.py @@ -7,6 +7,7 @@ import pytest from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.todos.markdown import render_todo_markdown REPO = Path(__file__).resolve().parents[2] @@ -77,3 +78,128 @@ def cli(command, *args, expected_exit=0): finally: subprocess.run([sys.executable, "-c", "from loopx.control_plane.effect_runtime import effect_runtime_result; effect_runtime_result('runtime.shutdown',{},retry_safe=False)"], cwd=REPO, capture_output=True, text=True, timeout=30, check=True) + + +@pytest.fixture(params=["file", "sqlite"]) +def expired_managed_claim(tmp_path, monkeypatch, request): + """A real accepted receipt with a past synthetic clock, without waiting.""" + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, state, registry = tmp_path / "runtime", tmp_path / "state.md", tmp_path / "registry.json" + goal, target = "expired-claim", "todo_expired_claim" + state.write_text("# Expired execution\n\n## Agent Todo\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": goal, "repo": str(tmp_path), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a", "agent-b"]}, + }]})) + projection = build_todo_runtime_shadow_projection(goal_id=goal, handoff_mode="hard_lease", leases=[], todos=[{ + "schema_version": "todo_item_v0", "todo_id": target, "role": "agent", "status": "open", "done": False, + "text": "Finish the original validated work", "archive_state": "active", "source_section": "Agent Todo", + "index": 1, "task_class": "advancement_task", "required_write_scopes": ["src/**"], + }]) + initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=request.param) + module = REPO / "loopx/control_plane/coordination" + script = ( + f"import {{openLocalAuthorityStore}} from {json.dumps((module / 'local_authority_provider.ts').as_uri())};" + f"import {{executeCoordinationTodoClaim}} from {json.dumps((module / 'todo_claim.ts').as_uri())};" + f"import {{prepareCoordinationProjectionCommit}} from {json.dumps((module / 'coordination_projection.ts').as_uri())};" + "let text='';for await(const chunk of process.stdin)text+=chunk;const input=JSON.parse(text);" + "const store=await openLocalAuthorityStore(input.runtime,input.goal);let result;" + "if(input.patch){const head=await store.loadAuthority();" + "result=await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id:input.goal," + "operation_id:'synthetic-identity-change',expected_provider_revision:head.provider_revision,projection:head.head," + "mutations:[{kind:'lease_upsert',lease:{...head.head.leases[0],...input.patch}}]}));}" + "else result=await executeCoordinationTodoClaim(store,{goal_id:input.goal,todo_id:input.todo," + "actor_agent_id:'agent-a',claimed_by:'agent-a',expected_role:'agent',registered_agents:['agent-a','agent-b']," + "operation_id:'expired-original-claim',manage_execution:true,dry_run:false,now:new Date('2001-01-01T00:00:00Z')});" + "process.stdout.write(JSON.stringify(result));" + ) + + def author(patch=None): + result = subprocess.run(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script], + input=json.dumps({"runtime": str(runtime), "goal": goal, "todo": target, "patch": patch}), + capture_output=True, text=True, timeout=45, check=True) + accepted = json.loads(result.stdout) + assert accepted["status"] == "applied", accepted + return accepted + + original = author() + state.unlink() # Display files cannot restore execution authority. + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + selected = ["--goal-id", goal, "--todo-id", target] + + def cli(arguments, expected_exit=0, *, use_fixture_route=True): + process = subprocess.run([sys.executable, "-m", "loopx.cli", *(prefix if use_fixture_route else []), *arguments], cwd=REPO, + capture_output=True, text=True, timeout=90, check=False) + assert process.returncode == expected_exit, process.stdout + process.stderr + return json.loads(process.stdout) + + def snapshot(): + task = cli(["todo", "list", *selected]) + return task["todo"], task["authority_read"]["provider_revision"], cli(["task-lease", "inspect", *selected]) + + yield cli, selected, snapshot, author, original + subprocess.run([sys.executable, "-c", "from loopx.control_plane.effect_runtime import effect_runtime_result; effect_runtime_result('runtime.shutdown',{},retry_safe=False)"], + cwd=REPO, capture_output=True, text=True, timeout=30, check=True) + + +@pytest.mark.parametrize("command", ["update", "complete"]) +def test_expired_claim_packet_recovers_without_help_or_list(expired_managed_claim, command, record_property): + cli, selected, snapshot, _, original = expired_managed_claim + extra = ["--update-operation-id", "original-progress", "--note", "Validated progress"] if command == "update" else [ + "--evidence", "evidence:validated-output"] + attempt = ["todo", command, *selected, "--agent-id", "agent-a", "--claim-operation-id", "expired-original-claim", *extra] + before = snapshot() + rejected = cli(attempt, expected_exit=1) + assert rejected["error_code"] == "claim_execution_unavailable" + recovery = rejected["recovery"] + assert recovery["action"] == "claim_task" and recovery["lease_state"] == "expired" + assert recovery["execution_authority_granted"] is False + assert "inspect" not in recovery and "acquire" not in recovery + rendered = render_todo_markdown(rejected) + assert "todo claim" in rendered and f"loopx todo {command}" in rendered + assert "--claim-operation-id" in rendered and "task-lease" not in rendered + assert snapshot() == before, "a rejected operation and its recovery preview have no effects" + prepared = cli(recovery["claim"]["cli_args"], use_fixture_route=False) + assert prepared["ok"] and prepared["execution_managed"] + current = snapshot() + assert current[2]["lease"]["lease_epoch"] > original["lease"]["lease_epoch"] + assert current[2]["lease"]["idempotency_key"] != original["lease"]["idempotency_key"] + stale = cli(attempt, expected_exit=1) + assert stale["error_code"] == "claim_execution_unavailable" + assert stale["recovery"]["action"] == "reconcile_lease_owner" and "claim" not in stale["recovery"] + assert snapshot() == current + retry = [prepared["claim_operation_id"] if value == "expired-original-claim" else value for value in attempt] + assert cli(retry)["ok"] + completed = snapshot() + if command == "update": + assert completed[0]["note"] == "Validated progress" + else: + assert completed[0]["status"] == "done" + assert cli(retry)["ok"] and snapshot() == completed, "lost replies replay the accepted operation" + record_property("recovery_interaction", json.dumps({ + "commands": [f"todo {command} (refused)", "todo claim (explicit)", f"todo {command} (retry)"], + "discovery_help_or_list_calls": 0, "verification_reads_excluded": True, + })) + + +@pytest.mark.parametrize("change", [ + {"owner": "agent-b"}, {"lease_epoch": 2, "version": 2}, + {"write_repository": "git:github.com/example/other"}, + {"write_workspace": {"host": "1" * 64, "common_directory": "2" * 64, "worktree": "3" * 64, + "repository": "git:github.com/example/project"}}, +]) +def test_changed_execution_packet_never_recommends_claim_adoption(expired_managed_claim, change): + cli, selected, snapshot, author, _ = expired_managed_claim + author(change) + before = snapshot() + for command in ("update", "complete"): + extra = ["--note", "Reviewed progress"] if command == "update" else ["--evidence", "evidence:output"] + rejected = cli(["todo", command, *selected, "--agent-id", "agent-a", "--claim-operation-id", + "expired-original-claim", *extra], expected_exit=1) + assert rejected["error_code"] == "claim_execution_unavailable" + recovery = rejected["recovery"] + assert recovery["action"] == "reconcile_lease_owner" and recovery["execution_authority_granted"] is False + assert "claim" not in recovery and "retry" not in recovery + assert "todo list" in render_todo_markdown(rejected) + assert cli(recovery["inspect"]["cli_args"], use_fixture_route=False)["todo"] == before[0] + assert snapshot() == before diff --git a/tests/control_plane_ts/managed_claim_execution_conformance.ts b/tests/control_plane_ts/managed_claim_execution_conformance.ts index ca52ec2857..a73afda9d9 100644 --- a/tests/control_plane_ts/managed_claim_execution_conformance.ts +++ b/tests/control_plane_ts/managed_claim_execution_conformance.ts @@ -218,6 +218,101 @@ export function registerManagedClaimExecutionConformance(provider: string, facto assert.deepEqual(await loaded(store), replaced); assert.equal((await claim(store, request)).status, "failed", "old claim never revives retired execution"); }); + test(`${provider} managed execution: expiry returns an explicit ordinary-claim recovery for update and completion`, async t => { + for (const command of ["update", "complete"] as const) { + const {store, request, progress} = await setup(t); + const accepted = await claim(store, request); + const now = new Date("2026-09-13T10:45:00Z"); + const original = {...(command === "update" ? progress : completion(request)), now}; + const execute = (input: typeof original) => command === "update" + ? update(store, input as Parameters[1]) + : complete(store, input as Parameters[1]); + const before = await loaded(store); + const rejected = await execute(original); + assert.equal(rejected.reason_code, "claim_execution_unavailable"); + const recovery = rejected.recovery as JsonObject; + assert.equal(recovery.action, "claim_task"); + assert.equal(recovery.lease_state, "expired"); + 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.equal((recovery.retry as JsonObject).command, `loopx todo ${command}`); + assert.deepEqual((recovery.retry as JsonObject).requires_flags, ["--claim-operation-id"]); + assert.equal(recovery.inspect, undefined, "expiry recovery needs no extra list or proof-copy step"); + assert.deepEqual(await loaded(store), before, "the refusal and claim preview cannot acquire or renew"); + const prepared = await claim(store, {...request, operation_id: "explicit-expiry-recovery", now}); + assert.equal(prepared.status, "applied"); + assert.notEqual((prepared.lease as JsonObject).idempotency_key, (accepted.lease as JsonObject).idempotency_key); + assert.ok(Number((prepared.lease as JsonObject).lease_epoch) > Number((accepted.lease as JsonObject).lease_epoch)); + assert.equal((await execute(original)).reason_code, "claim_execution_unavailable", + "a fresh grant never makes the expired reference valid"); + assert.equal((await execute({...original, claim_operation_id: String(prepared.claim_operation_id)})).status, "applied"); + } + }); + test(`${provider} managed execution: changed identity requires reconciliation instead of claim adoption`, async t => { + for (const change of [{owner: "agent-b"}, {lease_epoch: 2, version: 2}, + {idempotency_key: "different-execution"}, {write_repository: "git:github.com/example/other"}, + {write_workspace: {host: "1".repeat(64), common_directory: "2".repeat(64), worktree: "3".repeat(64), + repository: "git:github.com/example/project"}}, {status: "released"}]) { + const {store, request, progress} = await setup(t); + const accepted = await claim(store, request); + const head = await loaded(store); + assert.equal((await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: request.goal_id, + operation_id: "change-execution", expected_provider_revision: head.provider_revision, projection: head.head, + mutations: [{kind: "lease_upsert", lease: {...accepted.lease as JsonObject, ...change}}]}))).status, "applied"); + const before = await loaded(store); + for (const result of [await update(store, {...progress, now: new Date("2026-09-13T10:45:00Z")}), + await complete(store, {...completion(request), now: new Date("2026-09-13T10:45:00Z")})]) { + assert.equal(result.reason_code, "claim_execution_unavailable"); + const recovery = result.recovery as JsonObject; + assert.equal(recovery.action, "reconcile_lease_owner", JSON.stringify(change)); + assert.equal(recovery.execution_authority_granted, false); + assert.equal(recovery.claim, undefined); + assert.equal(recovery.retry, undefined); + } + assert.deepEqual(await loaded(store), before, "diagnostics preserve the changed authority"); + } + }); + test(`${provider} managed execution: expiry recovery rechecks admission, current head and source without effects`, async t => { + for (const scenario of ["excluded", "blocked", "head-race", "source-changed", "unavailable"]) { + const {store, request, progress} = await setup(t); + assert.equal((await claim(store, request)).status, "applied"); + const head = await loaded(store); + if (scenario === "excluded" || scenario === "blocked") { + const todo = (head.head.todos as JsonObject[])[0]!; + assert.equal((await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: request.goal_id, + operation_id: "restrict-task", expected_provider_revision: head.provider_revision, projection: head.head, + mutations: [{kind: "todo_upsert", todo: scenario === "excluded" + ? {...todo, excluded_agents: ["agent-a"]} : {...todo, status: "blocked"}}]}))).status, "applied"); + } + let sourceReads = 0, previewReads = 0; + const guarded = new Proxy(store, {get(target, key) { + if (key === "readReceipt") return async (id: string) => { + if (id.startsWith("claim-recovery-preview-")) { + if (scenario === "unavailable") throw new Error("synthetic preview unavailable"); + if (scenario === "head-race" && ++previewReads === 1) { + const current = await loaded(store); + assert.equal((await store.commitAuthority({...current, operation_id: "concurrent-owner-change", + expected_provider_revision: current.provider_revision, next_projection: {...current.head, revision_note: "changed"}, + events: [], receipts: []})).status, "applied"); + } + } + return target.readReceipt(id); + }; + const value = Reflect.get(target, key, target); + return typeof value === "function" ? value.bind(target) : value; + }}); + const before = await loaded(store); + const result = await update(guarded, {...progress, now: new Date("2026-09-13T10:45:00Z")}, + async () => scenario !== "source-changed" || ++sourceReads === 1); + assert.equal(result.reason_code, "claim_execution_unavailable"); + assert.notEqual((result.recovery as JsonObject | undefined)?.action, "claim_task", scenario); + if (scenario === "unavailable") assert.equal(result.recovery, undefined); + else assert.equal((result.recovery as JsonObject).action, "resolve_acquire_rejection", scenario); + if (scenario !== "head-race") assert.deepEqual(await loaded(store), before, scenario); + assert.equal((await store.readReceipt(progress.operation_id)).status, "missing"); + } + }); test(`${provider} managed execution: claim conflict, exclusion, policy loss and provider CAS preserve the original task`, async t => { const {store, request, progress} = await setup(t); assert.equal((await claim(store, request)).status, "applied"); From d41a6b45448c389b18da42b22237444e569c4214 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 3 Nov 2026 00:25:12 +0800 Subject: [PATCH 2/3] fix(todos): preserve worktree admission in claim recovery Pass the original caller worktree through the existing update and completion transports into ordinary claim previews. Reuse the canonical scope-aware workspace observer so independent checkouts receive recovery guidance while physical checkout conflicts remain rejected. Qualify both paths through real File and SQLite CLI transactions. Signed-off-by: song --- .../coordination/claim_execution_recovery.ts | 7 +- .../coordination/local_authority.py | 10 +- .../coordination/local_authority_runtime.ts | 38 +++++--- .../coordination/todo_terminal_lifecycle.ts | 7 +- .../control_plane/coordination/todo_update.ts | 7 +- .../todos/provider_terminal_lifecycle.py | 3 + loopx/control_plane/todos/provider_update.py | 3 + tests/test_task_lease_worktree.py | 94 +++++++++++++++++++ 8 files changed, 146 insertions(+), 23 deletions(-) diff --git a/loopx/control_plane/coordination/claim_execution_recovery.ts b/loopx/control_plane/coordination/claim_execution_recovery.ts index 86c9a7296a..cac6bf365e 100644 --- a/loopx/control_plane/coordination/claim_execution_recovery.ts +++ b/loopx/control_plane/coordination/claim_execution_recovery.ts @@ -3,7 +3,7 @@ import type {JsonObject} from "../effect_program.ts"; import type {AuthorityStore} from "./authority_store.ts"; import type {AuthoritySourceCheck} from "./authority_source.ts"; import {canonicalAuthoritySha256} from "./authority_store_codec.ts"; -import {ClaimExecutionUnavailable} from "./claim_execution.ts"; +import {ClaimExecutionUnavailable, type ClaimWorkspaceObserver} from "./claim_execution.ts"; import {executeCoordinationTodoClaim, type CoordinationTodoClaimInput} from "./todo_claim.ts"; type RecoveryInput = Pick { + authoritySourcesCurrent: AuthoritySourceCheck, + observeWorkspace?: ClaimWorkspaceObserver): Promise { if (!(error instanceof ClaimExecutionUnavailable)) return undefined; if (!error.sameExecutionExpired || input.actor_agent_id === null) { return {action: "reconcile_lease_owner", execution_authority_granted: false, @@ -30,7 +31,7 @@ export async function claimExecutionRecovery(store: AuthorityStore, input: Recov operation_id: `claim-recovery-preview-${canonicalAuthoritySha256({operation_id: input.operation_id, claim_operation_id: input.claim_operation_id, command}).slice(0, 32)}`, expected_provider_revision: input.expected_provider_revision, now: input.now, - }, authoritySourcesCurrent).catch(() => null); + }, authoritySourcesCurrent, observeWorkspace).catch(() => null); // An optional observation failure must not hide the original proof refusal. if (preview === null) return undefined; if (preview.dry_run === true && ["planned", "no_change"].includes(String(preview.status))) { diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index 0cf337488b..d3ee0137a1 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -142,6 +142,12 @@ def local_authority_is_promoted(*, runtime_root: Path, goal_id: str) -> bool: return True +def claim_worktree_candidate() -> Path | None: + """Locate the caller; the TS lease owner still verifies physical isolation.""" + worktree = _git_worktree_root(Path.cwd()) + return worktree if worktree is not None and (worktree / ".git").is_file() else None + + def claim_canonical_todo_if_promoted( *, registry_path: Path, @@ -164,9 +170,7 @@ def claim_canonical_todo_if_promoted( registered = registered_agent_ids_from_registry(registry_path, goal_id) # This is only a candidate location. The lease owner observes physical # isolation and the current canonical scopes before granting execution. - worktree = _git_worktree_root(Path.cwd()) if manage_execution else None - if worktree is not None and not (worktree / ".git").is_file(): - worktree = None + worktree = claim_worktree_candidate() if manage_execution else None result = effect_runtime_result( LOCAL_COORDINATION_TODO_CLAIM_METHOD, { diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 185cae934f..4d4de11614 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -5,6 +5,7 @@ import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOpera import {registryAuthoritySourceCheck} from "./authority_source.ts"; import {ResearchTerminalEvidenceHost} from "../capabilities/explore_research_terminal.ts"; import {decodeTaskLeaseProof} from "./task_lease_proof.ts"; +import type {ClaimWorkspaceObserver} from "./claim_execution.ts"; import {LeaseWorktreeObservationError, observeLeaseWorktree} from "../work_items/task_lease_workspace.ts"; import {evaluateTaskLeaseWriteScopesOverlap} from "../work_items/task_lease_acquire_decision.ts"; import {COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA} from "./todo_archive.ts"; @@ -529,6 +530,23 @@ function claimObservedAt(value: unknown): Date { return observedAt; } +/** The same scope-aware observation serves ordinary claims and recovery previews. */ +function claimWorkspaceObserver(worktree: unknown): ClaimWorkspaceObserver | undefined { + if (worktree === undefined) return undefined; + if (typeof worktree !== "string" || !isAbsolute(worktree)) { + throw new TypeError("Worktree observation requires an absolute caller path"); + } + return async writeScopes => { + try { + return await observeLeaseWorktree(worktree, path => + evaluateTaskLeaseWriteScopesOverlap({left: [...writeScopes], right: [path]}).overlap === true); + } catch (error) { + throw new Error(error instanceof LeaseWorktreeObservationError ? error.message + : "Cannot verify independent Git worktree and origin for task claim"); + } + }; +} + function authorityDirectory(root: string): string { return join(root, "authority", "file-v0"); } @@ -1092,15 +1110,7 @@ export async function claimLocalCoordinationTodo( ...(input.manage_execution === undefined ? {} : {manage_execution: input.manage_execution as boolean}), dry_run: input.dry_run === true, now: claimObservedAt(input.observed_at), - }, authoritySourcesCurrent, worktree === undefined ? undefined : async writeScopes => { - try { - return await observeLeaseWorktree(worktree as string, path => - evaluateTaskLeaseWriteScopesOverlap({left: [...writeScopes], right: [path]}).overlap === true); - } catch (error) { - throw new Error(error instanceof LeaseWorktreeObservationError ? error.message - : "Cannot verify independent Git worktree and origin for task claim"); - } - }); + }, authoritySourcesCurrent, claimWorkspaceObserver(worktree)); return { ...result, source_authority: sourceAuthority, @@ -1214,6 +1224,9 @@ export async function updateLocalCoordinationTodo( } if (Object.hasOwn(input, "claim_operation_id") && input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_UPDATE_REQUEST_SCHEMA) throw new TypeError("Claim execution reference requires update request v6"); + if (Object.hasOwn(input, "write_worktree") && (input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_UPDATE_REQUEST_SCHEMA || + input.claim_operation_id == null)) throw new TypeError("Worktree recovery observation requires an original claim execution reference"); + const observeWorkspace = claimWorkspaceObserver(input.write_worktree); const planningIntent = input.planning_intent == null ? undefined : requireJsonObject(input.planning_intent, "Todo planning intent"); if (planningIntent && Object.keys(planningIntent).length && @@ -1293,7 +1306,7 @@ export async function updateLocalCoordinationTodo( clear_fields: input.clear_fields.map((field) => claimAgentValue(field, "clear field")), dry_run: input.dry_run as boolean, now: claimObservedAt(input.observed_at), - }, authoritySourcesCurrent), ...providerEvidence}; + }, authoritySourcesCurrent, observeWorkspace), ...providerEvidence}; }, {businessWrite: "todo_update"}); } catch (error) { return {schema_version: COORDINATION_TODO_UPDATE_RESULT_SCHEMA, status: "failed", @@ -1322,6 +1335,9 @@ export async function terminalLifecycleLocalCoordinationTodo( } if (Object.hasOwn(input, "claim_operation_id") && input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_TERMINAL_REQUEST_SCHEMA) throw new TypeError("Claim execution reference requires terminal request v4"); + if (Object.hasOwn(input, "write_worktree") && (input.schema_version !== LOCAL_COORDINATION_TODO_EXECUTION_TERMINAL_REQUEST_SCHEMA || + input.claim_operation_id == null)) throw new TypeError("Worktree recovery observation requires an original claim execution reference"); + const observeWorkspace = claimWorkspaceObserver(input.write_worktree); const operationIntent = decodeTerminalOperationIntent(input); const reviewBasis = input.review_basis == null ? undefined : requireJsonObject(input.review_basis, "terminal review basis"); const authoritySourcesCurrent = registryAuthoritySourceCheck(input, true, reviewBasis?.registry_sha256); @@ -1419,7 +1435,7 @@ export async function terminalLifecycleLocalCoordinationTodo( dry_run: input.dry_run as boolean, now: claimObservedAt(input.observed_at), }, async () => await authoritySourcesCurrent() && researchEvidence.current(), - researchEvidence.qualify, request => verifyReentrySettlement(root, request)), ...providerEvidence}; + researchEvidence.qualify, request => verifyReentrySettlement(root, request), observeWorkspace), ...providerEvidence}; } finally {await researchEvidence.close();} }, {businessWrite: "todo_terminal"}); } catch (error) { diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index bde8e3da2e..c7c9fb11da 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1,4 +1,4 @@ -import {claimExecutionReference, claimExecutionProof} from "./claim_execution.ts"; +import {claimExecutionReference, claimExecutionProof, type ClaimWorkspaceObserver} from "./claim_execution.ts"; import {claimExecutionRecovery} from "./claim_execution_recovery.ts"; import {executeCoordinationTodoClaim} from "./todo_claim.ts"; import {HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; @@ -1208,6 +1208,7 @@ export async function executeCoordinationTodoTerminalLifecycle( authoritySourcesCurrent: AuthoritySourceCheck = uncheckedAuthoritySource, terminalEvidenceGuard: TodoTerminalEvidenceGuard | null = null, verifyLifecycleSettlement?: (input: CoordinationTodoTerminalLifecycleInput) => Promise, + observeWorkspace?: ClaimWorkspaceObserver, ): Promise { let normalized: CoordinationTodoTerminalLifecycleInput; try { @@ -1282,7 +1283,7 @@ export async function executeCoordinationTodoTerminalLifecycle( catch (error) { const recovery = input.command === "complete" ? await claimExecutionRecovery(store, {...input, claim_operation_id: claimReference, expected_provider_revision: head.provider_revision}, - "complete", error, authoritySourcesCurrent) : undefined; + "complete", error, authoritySourcesCurrent, observeWorkspace) : undefined; return terminalFailure("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable", recovery === undefined ? {} : {handoff_mode: head.head.handoff_mode, recovery}); } @@ -1435,7 +1436,7 @@ export async function executeCoordinationTodoTerminalLifecycle( 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); + }, authoritySourcesCurrent, observeWorkspace).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))) { diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 6482b239ab..53ca56cc7f 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -1,4 +1,4 @@ -import {claimExecutionProof, maintainClaimExecution} from "./claim_execution.ts"; +import {claimExecutionProof, maintainClaimExecution, type ClaimWorkspaceObserver} from "./claim_execution.ts"; import {claimExecutionRecovery} from "./claim_execution_recovery.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; @@ -150,6 +150,7 @@ function loadUpdateTarget( export async function executeCoordinationTodoUpdate( store: AuthorityStore, rawInput: CoordinationTodoUpdateInput, authoritySourcesCurrent: AuthoritySourceCheck = uncheckedAuthoritySource, + observeWorkspace?: ClaimWorkspaceObserver, ): Promise { let input: CoordinationTodoUpdateInput; try { input = normalizeTodoUpdateEnvelope(rawInput); } catch (error) { @@ -183,7 +184,7 @@ export async function executeCoordinationTodoUpdate( ...(completion.source_provider_revision == null ? {} : { validation_source_provider_revision: requireAuthorityStoreId(completion.source_provider_revision, "completion source provider revision")}), }, - }, authoritySourcesCurrent); + }, authoritySourcesCurrent, null, undefined, observeWorkspace); return {...result, schema_version: COORDINATION_TODO_UPDATE_RESULT_SCHEMA}; } const requestSha = updateRequestSha(input); @@ -224,7 +225,7 @@ export async function executeCoordinationTodoUpdate( try { input = {...input, ...await claimExecutionProof(store, head.head, input, claimReference), claim_operation_id: undefined}; } catch (error) { const recovery = await claimExecutionRecovery(store, {...input, claim_operation_id: claimReference, - expected_provider_revision: head.provider_revision}, "update", error, authoritySourcesCurrent); + expected_provider_revision: head.provider_revision}, "update", error, authoritySourcesCurrent, observeWorkspace); return {...rejection("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable"), ...(recovery === undefined ? {} : {handoff_mode: head.head.handoff_mode, recovery})}; } diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 1e02721eea..4d30d47d93 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -17,6 +17,7 @@ LOCAL_AUTHORITY_SOURCES, LocalCoordinationAuthorityRejection, LocalCoordinationAuthorityUnavailable, + claim_worktree_candidate, read_canonical_todos_if_promoted, require_canonical_authority, raise_canonical_write_failure, @@ -363,6 +364,7 @@ def terminal_canonical_todo_if_promoted( and completion_turn_key is None and target.get("task_class") == "continuous_monitor" ) + worktree = claim_worktree_candidate() if claim_operation_id is not None else None request = { "schema_version": ("loopx_local_coordination_todo_terminal_lifecycle_request_v4" if claim_operation_id is not None else _TERMINAL_REQUEST_SCHEMA), @@ -393,6 +395,7 @@ def terminal_canonical_todo_if_promoted( )} ), **({"claim_operation_id": claim_operation_id} if claim_operation_id is not None else {}), + **({"write_worktree": str(worktree)} if worktree is not None else {}), "lease_idempotency_key": task_lease_idempotency_key, "lease_expected_version": task_lease_expected_version, "allow_user_gate_auto_acquire": command == "complete", diff --git a/loopx/control_plane/todos/provider_update.py b/loopx/control_plane/todos/provider_update.py index d5b2de7273..25e5f210c2 100644 --- a/loopx/control_plane/todos/provider_update.py +++ b/loopx/control_plane/todos/provider_update.py @@ -16,6 +16,7 @@ LOCAL_AUTHORITY_SOURCES, LocalCoordinationAuthorityRejection, LocalCoordinationAuthorityUnavailable, + claim_worktree_candidate, raise_canonical_write_failure, read_canonical_todos_if_promoted, ) @@ -285,6 +286,7 @@ def update_canonical_todo_if_promoted( registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, role=role, persist_if_resolved=not dry_run) completion = {"validation_declaration": declaration} + worktree = claim_worktree_candidate() if claim_operation_id is not None else None request = { "schema_version": ("loopx_local_coordination_todo_update_request_v6" if claim_operation_id is not None else "loopx_local_coordination_todo_update_request_v5" if validation_revision is not None @@ -302,6 +304,7 @@ def update_canonical_todo_if_promoted( (monitor_observation.monitor_effect_id if monitor_observation else None) or f"todo-update:{uuid4().hex}"), **({"claim_operation_id": claim_operation_id} if claim_operation_id is not None else {}), + **({"write_worktree": str(worktree)} if worktree is not None else {}), "lease_idempotency_key": task_lease_idempotency_key, "lease_expected_version": task_lease_expected_version, "patch": patch, "clear_fields": [], "dry_run": dry_run, diff --git a/tests/test_task_lease_worktree.py b/tests/test_task_lease_worktree.py index 28e65c4a27..1c081219c5 100644 --- a/tests/test_task_lease_worktree.py +++ b/tests/test_task_lease_worktree.py @@ -8,6 +8,7 @@ from control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.cli import main as cli_main from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted @pytest.mark.skipif(sys.platform not in {"darwin", "linux"}, reason="worktree lease fixture requires symlinks; Windows needs Developer Mode") @@ -196,3 +197,96 @@ def claim(key, operation, expected=0): "--expected-version", str(second["version"])) assert claim("b", "claim-b", expected=1)["ok"] is False assert not cli(["task-lease", "inspect"], "b")["active"] + + +@pytest.mark.skipif(sys.platform not in {"darwin", "linux"}, reason="independent worktree fixture requires POSIX Git") +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("command", ["update", "complete"]) +def test_expired_claim_recovery_reuses_current_worktree_admission(tmp_path, monkeypatch, capsys, provider, command): + """Expiry advice and ordinary claims must make the same isolation decision.""" + isolate_sqlite_runtime(tmp_path, monkeypatch) + project = tmp_path / "repo" + project.mkdir() + + def git(*args): + return subprocess.run(["git", "-C", str(project), *args], capture_output=True, text=True, check=True) + + git("init") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-m", "fixture") + git("remote", "add", "origin", "https://github.com/example/project.git") + a, b = tmp_path / "a", tmp_path / "b" + git("worktree", "add", "-b", "a", str(a)) + git("worktree", "add", "-b", "b", str(b)) + runtime, state, registry = tmp_path / "runtime", tmp_path / "state.md", tmp_path / "registry.json" + goal = "expired-worktree-claim" + state.write_text("# Expired worktree claim\n\n## Agent Todo\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": goal, "repo": str(project), "state_file": str(state), + "coordination": {"registered_agents": ["agent-a", "agent-b"]}, + }]})) + projection = build_todo_runtime_shadow_projection(goal_id=goal, handoff_mode="hard_lease", leases=[], todos=[{ + "schema_version": "todo_item_v0", "todo_id": f"todo_expiry_{key}", "role": "agent", "status": "open", "done": False, + "text": "Edit isolated source", "archive_state": "active", "source_section": "Agent Todo", "index": i, + "task_class": "advancement_task", "task_domain": "code", "task_repository": "git:github.com/example/project", + "required_write_scopes": ["src/**"], + } for i, key in enumerate(("a", "b"), 1)]) + initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=provider) + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + + def run(arguments, *, expected=0, route=True): + rc = cli_main([*(prefix if route else []), *arguments]) + output = capsys.readouterr().out + assert rc == expected, output + return json.loads(output) + + def claim(key, operation, *, preview=False, expected=0): + return run(["todo", "claim", "--goal-id", goal, "--todo-id", f"todo_expiry_{key}", + "--agent-id", f"agent-{key}", "--claimed-by", f"agent-{key}", "--claim-operation-id", operation, + *(["--dry-run"] if preview else [])], expected=expected) + + def snapshot(): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal, include_leases=True) + + monkeypatch.chdir(a) + # Only the accepted operation's clock is synthetic. Git observation, the + # managed claim, its receipt and both provider transactions remain real. + with monkeypatch.context() as clock: + clock.setattr("loopx.control_plane.coordination.local_authority.now_local", lambda: "2001-01-01T00:00:00Z") + assert claim("a", "expired-original")["execution_managed"] + monkeypatch.chdir(b) + assert claim("b", "peer-current")["execution_managed"] + original = snapshot() + peer = next(lease for lease in original["leases"] if lease["todo_id"] == "todo_expiry_b") + extra = ["--update-operation-id", "expired-writeback", "--note", "Reviewed progress"] if command == "update" else [ + "--evidence", "evidence:validated-source"] + attempt = ["todo", command, "--goal-id", goal, "--todo-id", "todo_expiry_a", "--agent-id", "agent-a", + "--claim-operation-id", "expired-original", *extra] + + # An independent marker cannot make the peer's physical checkout ours. + refused = run(attempt, expected=1) + assert refused["recovery"]["action"] == "resolve_acquire_rejection" + assert refused["recovery"]["reason_code"] == "invalid_coordination_task_lease" + assert "claim" not in refused["recovery"] + assert claim("a", "same-checkout-preview", preview=True, expected=1)["ok"] is False + assert snapshot() == original + + monkeypatch.chdir(a) + ordinary_preview = claim("a", "independent-preview", preview=True) + assert ordinary_preview["status"] == "planned" + assert ordinary_preview["integration_overlap_advisories"] == [{ + "todo_id": "todo_expiry_b", "owner": "agent-b", "write_scopes": ["src/**"], + "reason": "independent_worktree_integration_overlap", + }] + refused = run(attempt, expected=1) + assert refused["error_code"] == "claim_execution_unavailable" + assert snapshot() == original, "recovery previews cannot acquire or renew either execution" + recovery = refused["recovery"] + assert recovery["action"] == "claim_task", recovery + assert recovery["execution_authority_granted"] is False + prepared = run([*recovery["claim"]["cli_args"], "--claim-operation-id", "explicit-recovery"], route=False) + assert prepared["integration_overlap_advisories"] == ordinary_preview["integration_overlap_advisories"] + assert run(attempt, expected=1)["recovery"]["action"] == "reconcile_lease_owner" + retry = [prepared["claim_operation_id"] if arg == "expired-original" else arg for arg in attempt] + assert run(retry)["ok"] + readback = snapshot() + assert next(lease for lease in readback["leases"] if lease["todo_id"] == "todo_expiry_b") == peer From 23c5d67bf7af3114605fb3a3916c3f1db632cb5a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:23:00 +0800 Subject: [PATCH 3/3] fix(todos): preserve prefixed recovery intent Recognize the todo subcommand after provider-bound global options before adding caller selectors and dry-run intent. Keep inspection routes free of action-only arguments. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/todo_event.py | 2 +- tests/control_plane/test_canonical_claim_execution_proof.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/loopx/cli_commands/todo_event.py b/loopx/cli_commands/todo_event.py index 0181bd93a0..4299ef929f 100644 --- a/loopx/cli_commands/todo_event.py +++ b/loopx/cli_commands/todo_event.py @@ -78,7 +78,7 @@ def _route_native_recovery( value = descriptor.get(field) or getattr(args, field, None) if flag not in arguments and value is not None: arguments.extend(cli_option_args(flag, value)) - if arguments[:1] == ["todo"]: + if step in {"claim", "retry"} and arguments[start:start + 2] == command[1:3]: for flag, field in (("--agent-id", "agent_id"), ("--role", "role"), ("--project", "project"), ("--state-file", "state_file")): value = getattr(args, field, None) diff --git a/tests/control_plane/test_canonical_claim_execution_proof.py b/tests/control_plane/test_canonical_claim_execution_proof.py index 52e4a891e6..da5a3d6bfc 100644 --- a/tests/control_plane/test_canonical_claim_execution_proof.py +++ b/tests/control_plane/test_canonical_claim_execution_proof.py @@ -155,8 +155,10 @@ def test_expired_claim_packet_recovers_without_help_or_list(expired_managed_clai assert recovery["action"] == "claim_task" and recovery["lease_state"] == "expired" assert recovery["execution_authority_granted"] is False assert "inspect" not in recovery and "acquire" not in recovery + assert recovery["claim"]["command"].split()[:3] == ["loopx", "todo", "claim"] + assert recovery["retry"]["command"].split()[:3] == ["loopx", "todo", command] rendered = render_todo_markdown(rejected) - assert "todo claim" in rendered and f"loopx todo {command}" in rendered + assert "todo claim" in rendered and f"todo {command}" in rendered assert "--claim-operation-id" in rendered and "task-lease" not in rendered assert snapshot() == before, "a rejected operation and its recovery preview have no effects" prepared = cli(recovery["claim"]["cli_args"], use_fixture_route=False)