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
6 changes: 5 additions & 1 deletion loopx/cli_commands/task_lease.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/coordination/task_lease_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} : {})};
Expand Down
26 changes: 26 additions & 0 deletions tests/control_plane/test_task_lease_argument_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading