Skip to content

Commit 1ea060e

Browse files
author
mac
committed
fix(todo): preserve error_code and actionable recovery contract for hard-lease claim failures
Signed-off-by: mac <bishopapril850965@yahoo.com>
1 parent b9ea1ba commit 1ea060e

4 files changed

Lines changed: 82 additions & 5 deletions

File tree

‎loopx/cli_commands/todo_event.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from collections.abc import Callable
55
from pathlib import Path
66

7+
from ..control_plane.coordination.local_authority import (
8+
LocalCoordinationAuthorityRejection,
9+
)
710
from ..control_plane.todos.external_wait_contract import TodoExternalWaitAuthoringError
811
from ..control_plane.todos.handoff_mode import HandoffModeError
912
from ..control_plane.todos.contract import decision_scope_metadata_value
@@ -43,12 +46,25 @@ def todo_error_payload(args: argparse.Namespace, exc: Exception) -> dict[str, ob
4346
if isinstance(exc, (TaskLeaseError, HandoffModeError)):
4447
payload["error_code"] = exc.code
4548
payload.update(exc.payload)
49+
elif isinstance(exc, LocalCoordinationAuthorityRejection):
50+
payload["error_code"] = exc.code
51+
payload["code"] = exc.code
52+
for key, value in exc.payload.items():
53+
if key not in {
54+
"schema_version",
55+
"status",
56+
"failure_kind",
57+
"reason_code",
58+
"reason",
59+
}:
60+
payload[key] = value
4661
elif isinstance(exc, TodoExternalWaitAuthoringError):
4762
payload["error_code"] = exc.code
4863
if exc.authoring_contract is not None:
4964
payload["authoring_contract"] = exc.authoring_contract
5065
return payload
5166

67+
5268
def append_todo_rollout_event(
5369
payload: dict[str, object],
5470
*,

‎loopx/control_plane/coordination/todo_claim.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,10 +577,28 @@ export async function executeCoordinationTodoClaim(
577577
}
578578
} else if (handoffMode === "hard_lease" &&
579579
!activeLeaseForOwner(currentLease, authority.owner, input.now)) {
580+
const existingVersion = currentLease !== undefined
581+
? (leaseInteger(currentLease, "version") ?? 0)
582+
: null;
583+
const expectedVersionGuidance = existingVersion !== null
584+
? `; specify --task-lease-expected-version ${existingVersion} to match the existing canonical lease version`
585+
: "; provide --task-lease-expected-version if a canonical lease already exists";
580586
return failure(
581587
"handoff_mode_requires_lease",
582-
"hard_lease Todo claim requires an active canonical lease held by the claiming agent",
583-
{ todo_id: input.todo_id, actor_agent_id: authority.owner },
588+
`hard_lease Todo claim requires an active canonical lease held by the claiming agent; ` +
589+
`retry with \`loopx todo claim --task-lease-idempotency-key <key>\`${expectedVersionGuidance}`,
590+
{
591+
todo_id: input.todo_id,
592+
actor_agent_id: authority.owner,
593+
handoff_mode: handoffMode,
594+
recovery: {
595+
command: "loopx todo claim",
596+
requires_flags: ["--task-lease-idempotency-key"],
597+
optional_flags: ["--task-lease-expected-version"],
598+
expected_version: existingVersion,
599+
expected_version_required: existingVersion !== null,
600+
},
601+
},
584602
"decision_rejection",
585603
);
586604
}

‎tests/control_plane/test_local_coordination_authority.py‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,7 @@ def test_promoted_hard_lease_claim_cli_atomically_acquires_ownership(
819819
)
820820
state_file.unlink()
821821

822-
command = [
822+
base_command = [
823823
sys.executable,
824824
"-m",
825825
"loopx.cli",
@@ -839,6 +839,27 @@ def test_promoted_hard_lease_claim_cli_atomically_acquires_ownership(
839839
"agent-a",
840840
"--claim-operation-id",
841841
"atomic-cli-claim",
842+
]
843+
initial_failure = subprocess.run(
844+
base_command,
845+
capture_output=True,
846+
text=True,
847+
timeout=30,
848+
)
849+
assert initial_failure.returncode == 1
850+
failure_payload = json.loads(initial_failure.stdout)
851+
assert failure_payload["ok"] is False
852+
assert failure_payload["error_code"] == "handoff_mode_requires_lease"
853+
assert failure_payload["handoff_mode"] == "hard_lease"
854+
assert "loopx todo claim --task-lease-idempotency-key" in failure_payload["error"]
855+
assert "--task-lease-expected-version" in failure_payload["error"]
856+
recovery = failure_payload.get("recovery") or {}
857+
assert recovery.get("command") == "loopx todo claim"
858+
assert recovery.get("requires_flags") == ["--task-lease-idempotency-key"]
859+
assert "--task-lease-expected-version" in (recovery.get("optional_flags") or [])
860+
861+
command = [
862+
*base_command,
842863
"--task-lease-idempotency-key",
843864
"turn:atomic-cli-claim",
844865
"--task-lease-expected-version",
@@ -1398,8 +1419,14 @@ def test_hard_lease_eligibility_rejection_is_valueerror(
13981419
assert isinstance(exc_info.value, LocalCoordinationAuthorityRejection)
13991420
assert exc_info.value.code == "handoff_mode_requires_lease"
14001421
assert (
1401-
str(exc_info.value)
1402-
== "hard_lease Todo claim requires an active canonical lease held by the claiming agent"
1422+
"hard_lease Todo claim requires an active canonical lease held by the claiming agent"
1423+
in str(exc_info.value)
1424+
)
1425+
assert "loopx todo claim --task-lease-idempotency-key" in str(exc_info.value)
1426+
assert "--task-lease-expected-version" in str(exc_info.value)
1427+
assert (
1428+
exc_info.value.payload.get("recovery", {}).get("requires_flags")
1429+
== ["--task-lease-idempotency-key"]
14031430
)
14041431
store_after = json.loads(
14051432
(

‎tests/control_plane_ts/local_authority_runtime.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,22 @@ test("provider-first Todo claim validates authority and hard-lease ownership", a
926926
claimed_by: "agent-a",
927927
});
928928
assert.equal(missingLease.reason_code, "handoff_mode_requires_lease");
929+
assert.match(
930+
String(missingLease.reason),
931+
/loopx todo claim --task-lease-idempotency-key/,
932+
);
933+
assert.match(
934+
String(missingLease.reason),
935+
/--task-lease-expected-version/,
936+
);
937+
assert.equal(
938+
(missingLease.recovery as Record<string, unknown> | undefined)?.command,
939+
"loopx todo claim",
940+
);
941+
assert.deepEqual(
942+
(missingLease.recovery as Record<string, unknown> | undefined)?.requires_flags,
943+
["--task-lease-idempotency-key"],
944+
);
929945

930946
const dryRun = await claimLocalCoordinationTodo({
931947
...base,

0 commit comments

Comments
 (0)