From 0b6527bd0b09033361c8364362f387ffb561c0b2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:16:05 +0800 Subject: [PATCH] fix(leases): explain execution key mismatches during recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/task_lease.py | 6 ++++- .../coordination/task_lease_lifecycle.ts | 2 ++ .../test_task_lease_argument_recovery.py | 26 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/loopx/cli_commands/task_lease.py b/loopx/cli_commands/task_lease.py index 643f6fdb56..493e72d459 100644 --- a/loopx/cli_commands/task_lease.py +++ b/loopx/cli_commands/task_lease.py @@ -114,7 +114,11 @@ def register_task_lease_command( parser.add_argument("--goal-id", required=True, help="Goal id that owns the todo.") parser.add_argument("--todo-id", required=True, help="Structured todo id such as todo_ab12cd34ef56.") parser.add_argument("--owner", help="Registered public-safe agent id that owns the lease.") - parser.add_argument("--idempotency-key", help="Public-safe token used for idempotent retries and CAS.") + parser.add_argument("--idempotency-key", help=( + "Public-safe execution key chosen for acquire. Renew/transfer/release reuse the original key " + "of this lease generation, not a new per-operation token. " + "Transfer's receiver key uses --new-idempotency-key." + )) parser.add_argument("--new-owner", help="For transfer, target registered public-safe agent id.") parser.add_argument("--new-idempotency-key", help="For transfer, target idempotency key.") parser.add_argument("--transfer-claim", action="store_true", diff --git a/loopx/control_plane/coordination/task_lease_lifecycle.ts b/loopx/control_plane/coordination/task_lease_lifecycle.ts index 0719fd9b13..fc96363e7b 100644 --- a/loopx/control_plane/coordination/task_lease_lifecycle.ts +++ b/loopx/control_plane/coordination/task_lease_lifecycle.ts @@ -156,6 +156,8 @@ export async function executeCanonicalTaskLeaseLifecycle(store: AuthorityStore, if (decision.outcome === "rejected" || decision.outcome === "conflict") { return {...failed(decision.code, decision.code === "handoff_mode_retired" ? "Legacy ownership cannot renew or transfer execution; release retained leases and review handoff-mode plan-migration/migrate." + : decision.code === "lease_cas_mismatch" + ? "lease owner or idempotency key mismatch; retain the original execution owner and key for this lease generation" : `canonical task lease ${input.operation} rejected: ${decision.code}`), handoff_mode: mode, expected_version: input.expected_version, actual_version: leaseVersion(lease), ...(todo ? {todo_status: todo.status, claimed_by: todo.claimed_by ?? null, excluded_agents: excluded} : {})}; diff --git a/tests/control_plane/test_task_lease_argument_recovery.py b/tests/control_plane/test_task_lease_argument_recovery.py index 991e978b1e..791abd5156 100644 --- a/tests/control_plane/test_task_lease_argument_recovery.py +++ b/tests/control_plane/test_task_lease_argument_recovery.py @@ -168,6 +168,32 @@ def test_missing_inputs_are_combined_and_wrong_owner_is_not_repaired(lease_cli): assert run(action("inspect"))["lease"] == before +@pytest.mark.parametrize("operation", ["renew", "transfer", "release"]) +def test_wrong_execution_key_explains_matching_version_and_keeps_original_recovery(lease_cli, operation): + run, action, _, _ = lease_cli + before = run(action("inspect")) + version = str(before["lease"]["version"]) + extra = ["--new-owner", "peer-a", "--new-idempotency-key", "receiver"] if operation == "transfer" else [] + denied = run(action(operation, "--owner", "peer-a", "--idempotency-key", "new-operation", + "--expected-version", version, *extra), code=1) + assert denied["error_code"] == "lease_cas_mismatch" + assert denied["expected_version"] == denied["actual_version"] == int(version) + assert "owner" in denied["error"] and "idempotency key" in denied["error"] + assert denied["inspection"]["execution_authority_granted"] is False + observed = run(denied["inspection"]["cli_args"]) + assert observed["lease"] == before["lease"] + assert observed["provider_revision"] == before["provider_revision"] + + original = action(operation, "--owner", "peer-a", "--idempotency-key", "original", + "--expected-version", version, *extra) + applied = run(original) + assert applied["lease"]["version"] == int(version) + (operation != "release") + assert applied["lease"]["idempotency_key"] == ("receiver" if operation == "transfer" else "original") + replay = run(original) + assert replay["idempotent"] is True and replay["lease"] == applied["lease"] + assert run(action("inspect"))["lease"] == applied["lease"] + + def test_recovery_round_trips_leading_hyphen_execution_keys(lease_cli): run, action, _, _ = lease_cli released = run(action("release", "--owner", "peer-a", "--idempotency-key", "original",