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
34 changes: 33 additions & 1 deletion loopx/control_plane/coordination/todo_terminal_lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"],
Comment thread
loopx-agent marked this conversation as resolved.
Resolved
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",
);
}
Expand Down
7 changes: 6 additions & 1 deletion loopx/control_plane/todos/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
1 change: 1 addition & 0 deletions tests/control_plane/test_goal_handoff_mode_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions tests/control_plane/test_todo_claim_argument_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 92 additions & 10 deletions tests/control_plane_ts/managed_claim_execution_conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,97 @@ export function registerManagedClaimExecutionConformance(provider: string, facto
claim_operation_id: request.operation_id};
return {store, request, progress};
}
function completion(request: Awaited<ReturnType<typeof setup>>["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<typeof complete>[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);
Expand Down Expand Up @@ -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);
Expand Down
Loading