diff --git a/docs/reference/handoff-mode.md b/docs/reference/handoff-mode.md index 79dd2e8a21..da1007e9bd 100644 --- a/docs/reference/handoff-mode.md +++ b/docs/reference/handoff-mode.md @@ -62,6 +62,32 @@ A read-only quota guard never claims or renews. Long-running delegated Hosts ret their existing supervision; this CLI path does not supervise an idle external process. The App's manual completion boundary described above is unchanged. +Goal-mode MCP uses the same task reference: retain `claim_operation_id` from +`claim_task` and pass it to `complete_task`, including retries. This is a breaking +tool-schema change: `complete_task` no longer accepts +`task_lease_idempotency_key` or `task_lease_expected_version`. Migrate callers +to the returned claim reference; do not synthesize one from an old lease key. +Ordinary CLI help also omits those host-only proof flags. Explicit Host supervision +and diagnostic lease commands retain their generation-fencing protocol. + +For an eligible task edit without explicit proof, recovery now offers ordinary +`todo claim`, then retry with its returned reference. It does not direct the Agent +through inspect/acquire/release. Expired or replaced references remain rejected; +recovery advice itself never grants execution or changes state. + +An expired hard-lease execution can be reclaimed with ordinary `todo claim` +by an eligible registered Agent. When the expired holder still matches the Todo +claim, the claim owner and a fresh execution generation change in one canonical +transaction. No response, release or transfer grant from the old process is +required. This intentionally changes the previous cross-agent expiry behavior. +Active executions remain protected. Missing, malformed, released or unrelated +lease history cannot authorize this takeover; `soft_claim` keeps its prior +handoff rules. Current task status, exclusions, acceptance, dependencies and +write-scope checks remain in force. Concurrent claimants cannot both win the same +provider revision. Old references cannot update, renew or release the replacement. +Expiry prevents further control-plane writes; it cannot undo external effects +already issued, and native Hosts still own process supervision. + ## Choose the policy independently of storage Use `soft_claim` for an explicitly single executor without parallel takeover. @@ -252,9 +278,11 @@ under a fresh operation and current revision. Other execution fences remain: | Observation | Recovery | | --- | --- | | Historical `legacy` policy, with or without a retained lease | Release any retained execution using its exact CAS, then review and back up a migration to `soft_claim` or `hard_lease`. Historical acceptance grants no current execution. | -| Active lease held by the current claim owner; missing/stale proof | Inspect the lease and retry using its current proof. Do not acquire a competing execution. | -| Released/expired lease; current owner is eligible and acquisition passes the current mode, acceptance and scope checks | Inspect the version, acquire a short lease with a fresh key, update using the returned proof, then release it. | -| Active foreign holder, divergent claim, or absent claim | Reconcile ownership through its lifecycle; never borrow another holder's proof. | +| Active execution held by the current claim owner; proof omitted | Use ordinary `todo claim` and retry with its returned reference; claim reuses eligible active execution. | +| Released/expired execution; proof omitted and current owner passes acquisition checks | Use ordinary `todo claim` for fresh execution, then retry with its returned reference. | +| Explicit Host proof is stale or partial | Inspect current execution and repair the Host's proof; do not silently fall back to a task reference. | +| Expired execution matches the retained claim; eligible registered replacement actor | Use ordinary `todo claim` for atomic takeover, then use its new reference. | +| Active foreign holder, divergent history, or absent claim | Reconcile ownership through its lifecycle; never borrow another holder's proof. | | `soft_claim` with released same-owner history, ordinary owner copy edit | Omit lease proof; keep the history. No replacement lease is needed or allowed. | | Non-open Todo, acceptance hold or conflicting write scopes | Resolve the reported acquisition blocker. No acquire action is offered. | | Edit changes retained leased work requirements or status | Use the owning lifecycle transition; acquiring another lease cannot authorize the metadata edit. | @@ -275,7 +303,8 @@ todo_done: --reason ''` 暂停。此显式 wait 或附带执行 proof 仍被拒绝。用 `--status open --clear-resume-when --reason ''` 恢复;两次转换都不授予执行权限,下一次执行仍需新 lease。 -The recovery descriptor uses the standalone `loopx task-lease acquire` command. +For explicit Host-proof callers, the recovery descriptor uses the standalone +`loopx task-lease acquire` command when acquisition is eligible. Combined `todo claim --task-lease-idempotency-key` is restricted to `hard_lease` and is unavailable in historical `legacy` mode. After reviewed migration, acquire uses `--owner`, a **fresh** @@ -306,9 +335,10 @@ lease 即使自然过期,也不适用。显式旧 proof 或部分 proof 仍拒 `handoff_mode_requires_lease` 不表示 Goal 已自动切成 `hard_lease`;拒绝结果会返回真实 mode 与只读 `recovery`。 -有效的本主租约应 inspect 后使用当前 proof 重试;已释放或过期的租约,只有当前 -claim owner 满足相同的 acquire 准入规则时,才提示“inspect version → 用新 key -申请短 lease → 带新 proof 更新 → release”。legacy 下独立 acquire 与合并式 +未提供显式 proof 的普通任务调用,只有当前 owner 满足执行准入时,才提示普通 +`todo claim`,再用返回的 `claim_operation_id` 重试。有效执行复用,已结束执行须 +重新通过准入;旧引用不能复活过期或换代执行。显式 Host proof 过期或不完整时, +仍须 inspect 并修复 Host 的执行证明,不静默切换协议。legacy 下独立 acquire 与合并式 claim+lease 均不可执行,必须先审核迁移;合并式 claim+lease 仍仅限 hard_lease。 异主有效 lease、claim 不一致、不允许执行的 Todo、验收阻塞或 scope @@ -330,3 +360,17 @@ CLI 由明确的 `--agent-id` 认领给自己时,会在同一 canonical 事务 不自动借用或重新取得证明;显式证明不能与引用混用。原操作回执先于维护恢复,重试不 重复写入或续租。只读 quota guard 不做认领或续租;长期委派 Host 仍走已有监督, 本 CLI 路径不监督空闲外部进程,也不改变 App 手工完成的现有边界。 + +Goal-mode MCP 同样传递任务引用:保存 `claim_task` 返回的 `claim_operation_id`, +在 `complete_task` 及其重试中原样使用。本次为 breaking change,`complete_task` +移除 `task_lease_idempotency_key` 和 `task_lease_expected_version` 参数;调用方须 +迁移到认领返回的引用,不能从旧 key 拼造。普通 CLI 帮助也不再展示这两个 Host +证明参数;显式 Host 监督和诊断命令保留换代隔离协议。恢复提示不授予执行权或写入状态。 + +过期的 hard-lease 执行可由符合当前条件的注册 Agent 用普通 `todo claim` 接管。 +只有过期持有者仍与 Todo claim 一致时,才在同一 canonical 事务中变更认领人并取得 +新一代执行,无需旧进程回复、释放或提供交接证明。本次明确改变原来的跨 Agent +过期恢复行为。有效执行不可抢占;缺失、损坏、已释放或不对应的历史记录不能授予 +此接管,`soft_claim` 保持原交接规则。仍检查任务状态、排除规则、验收、依赖和写范围 +冲突;同一 revision 的并发竞争只允许一个成功,旧引用不能写入、续租或释放新执行。 +过期会阻止后续控制面写入,但无法撤回此前已发出的外部副作用;进程监督仍由 Host 负责。 diff --git a/examples/explore-result-layer-smoke.py b/examples/explore-result-layer-smoke.py index 405e5b63fb..75bd033443 100644 --- a/examples/explore-result-layer-smoke.py +++ b/examples/explore-result-layer-smoke.py @@ -791,7 +791,7 @@ def check_todo_branch_prediction_contract() -> None: rejected = {item["todo_id"]: item for item in plan["rejected_candidates"]} assert rejected["todo_conflict"]["selection_status"] == "rejected_hazard", plan assert rejected["todo_other"]["selection_status"] == "blocked_claimed_by_other", plan - assert any("task-lease acquire" in command for command in plan["selected_branches"][0]["suggested_commands"]), plan + assert not any("task-lease" in command for command in plan["selected_branches"][0]["suggested_commands"]), plan worker_plan = build_explore_worker_branch_plan( goal_id=goal_id, diff --git a/examples/explore-worker-plan-gate-smoke.py b/examples/explore-worker-plan-gate-smoke.py index 6badf4fd58..9128c92476 100644 --- a/examples/explore-worker-plan-gate-smoke.py +++ b/examples/explore-worker-plan-gate-smoke.py @@ -165,10 +165,10 @@ def check_commands_and_width_cap() -> None: assert plan["selected_worker_branch_count"] <= 3, plan first = plan["selected_worker_branches"][0] assert any("loopx todo claim" in command for command in first["suggested_commands"]), first - assert any("task-lease acquire" in command for command in first["suggested_commands"]), first + assert not any("task-lease" in command for command in first["suggested_commands"]), first # Suggested is still only suggested: the packet remains dry-run. assert plan["dry_run"] is True, plan - assert plan["harness_compatibility"]["claim_and_lease_are_suggested_only"] is True, plan + assert plan["harness_compatibility"]["claim_is_suggested_only"] is True, plan _assert_read_only_boundary(plan) wide = _plan( diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index b90294385f..7248087071 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -555,8 +555,8 @@ packet with: enter the exploration scheduler or consume branch width; - a dry-run A/B estimate comparing baseline serial execution with the DSpark-style selected prefix (`ab_result.estimated_speedup_vs_baseline`); -- suggested `loopx todo claim` and `loopx task-lease acquire` commands for a - human operator or registered peer runner to execute explicitly; +- suggested ordinary `loopx todo claim` commands for a human operator or + registered peer runner; the claim manages execution and returns its task reference; - the safety boundary that keeps the packet advisory rather than an replacement for `quota should-run`. @@ -682,12 +682,12 @@ the existing LoopX harness, not beside it and not instead of it: 2. The opt-in planner groups todos into worker-lane candidates and uses DSpark-style confidence/prefix/load scoring to pick a worker branch prefix. 3. Execution must return to the normal LoopX path: `quota should-run`, - `todo claim`, `task-lease acquire`, worker execution, `explore node|edge|finding`, + `todo claim`, worker execution, `explore node|edge|finding`, `refresh-state`, and `quota spend-slot`. For goals that have opted in, the packet therefore contains `harness_compatibility` and `boundary` fields: `replaces_loopx_runtime=false`, -`launches_workers=false`, and `claim_and_lease_are_suggested_only=true`; the +`launches_workers=false`, and `claim_is_suggested_only=true`; the deny-by-default disabled packet carries the `boundary` block plus the opt-in `required_contract` instead. The packet can be used by a controller or human operator to decide which workers to start, but it cannot launch workers or diff --git a/loopx/capabilities/explore/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index 4d5f105cf2..0b7a8e5000 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -278,7 +278,7 @@ Todo 关联保留既有可恢复边界与 claim/lease 证明;恢复旧批次 - 选中的分支、置信度、hazards 与 reason codes; - 被排除的 `continuous_monitor` 诊断——仍可见,但绝不进入探索调度器或消耗分支宽度; - 基线串行执行与 DSpark 风格选中前缀的 dry-run A/B 估计(`ab_result.estimated_speedup_vs_baseline`); -- 供 operator 或注册 peer runner 显式执行的 `loopx todo claim` / `loopx task-lease acquire` 建议; +- 供 operator 或注册 peer runner 执行的普通 `loopx todo claim` 建议;认领管理执行并返回任务引用; - 保持 packet 咨询性、而非替代 `quota should-run` 的安全边界。 advancement todo 可通过挂一个或多个显式 Explore node id 选择 typed 结果诊断: @@ -326,9 +326,9 @@ loopx explore worker-branch-plan --goal-id --worker-width 5 \ 1. LoopX 提供 harness 输入:quota/status 上下文、open agent todo 投影、explore result 投影、所有权、能力、写作用域元数据。 2. opt-in planner 把 todos 分组为 worker-lane 候选,用 DSpark 风格 confidence/prefix/load 打分选择 worker branch 前缀。 -3. 执行必须回到正常 LoopX 路径:`quota should-run`、`todo claim`、`task-lease acquire`、worker 执行、`explore node|edge|finding`、`refresh-state`、`quota spend-slot`。 +3. 执行必须回到正常 LoopX 路径:`quota should-run`、`todo claim`、worker 执行、`explore node|edge|finding`、`refresh-state`、`quota spend-slot`。 -因此 packet 对已 opt-in 的 goal 包含 `harness_compatibility` 与 `boundary` 字段:`replaces_loopx_runtime=false`、`launches_workers=false`、`claim_and_lease_are_suggested_only=true`;deny-by-default 的 disabled packet 携带 `boundary` 块和 opt-in `required_contract`。packet 可被控制器或 operator 用来决定启动哪些 worker,但不能自行 launch worker 或变更控制面。 +因此 packet 对已 opt-in 的 goal 包含 `harness_compatibility` 与 `boundary` 字段:`replaces_loopx_runtime=false`、`launches_workers=false`、`claim_is_suggested_only=true`;deny-by-default 的 disabled packet 携带 `boundary` 块和 opt-in `required_contract`。packet 可被控制器或 operator 用来决定启动哪些 worker,但不能自行 launch worker 或变更控制面。 ### 每 Goal 独立 Opt-In 门控 diff --git a/loopx/capabilities/explore/todo_branch_plan.py b/loopx/capabilities/explore/todo_branch_plan.py index e2fbb757cc..457f33d103 100644 --- a/loopx/capabilities/explore/todo_branch_plan.py +++ b/loopx/capabilities/explore/todo_branch_plan.py @@ -239,28 +239,6 @@ def _lane_id(todo_id: str, *, index: int) -> str: return f"branch_{index}_{todo_id.removeprefix('todo_')}" -def _lease_command( - *, - goal_id: str, - todo_id: str, - agent_id: str | None, - write_scopes: Sequence[str], -) -> str | None: - owner = normalize_todo_claimed_by(agent_id) - if not owner: - return None - parts = [ - "loopx task-lease acquire", - f"--goal-id {goal_id}", - f"--todo-id {todo_id}", - f"--owner {owner}", - f"--idempotency-key branch-plan:{goal_id}:{todo_id}", - ] - for scope in write_scopes: - parts.append(f"--write-scope {scope}") - return " ".join(parts) - - def _claim_command(*, goal_id: str, todo_id: str, agent_id: str | None) -> str | None: owner = normalize_todo_claimed_by(agent_id) if not owner: @@ -523,6 +501,7 @@ def build_explore_todo_branch_plan( branch_index = len(selected) role = "primary" if branch_index == 0 else "speculative" selected_in_lane = selected_by_resource_lane.get(resource_lane, 0) + 1 + claim_command = _claim_command(goal_id=goal_id, todo_id=todo_id, agent_id=normalized_agent) selected_candidate = { **candidate, "selection_status": "selected", @@ -533,19 +512,7 @@ def build_explore_todo_branch_plan( "verify and write back before keeping this branch; discard or convert " "to successor todo if evidence does not advance the exploration" ), - "suggested_commands": [ - command - for command in ( - _claim_command(goal_id=goal_id, todo_id=todo_id, agent_id=normalized_agent), - _lease_command( - goal_id=goal_id, - todo_id=todo_id, - agent_id=normalized_agent, - write_scopes=scopes, - ), - ) - if command - ], + "suggested_commands": [claim_command] if claim_command else [], } assignment = resource_assignment( resource_portfolio, diff --git a/loopx/capabilities/explore/worker_branch_plan.py b/loopx/capabilities/explore/worker_branch_plan.py index 8b1f6248e4..42cb086ecc 100644 --- a/loopx/capabilities/explore/worker_branch_plan.py +++ b/loopx/capabilities/explore/worker_branch_plan.py @@ -41,7 +41,6 @@ _claim_command, _compact_text, _frontier_tokens, - _lease_command, _execution_exclusion, _required_capabilities, _required_write_scopes, @@ -492,19 +491,9 @@ def _branch_candidate( commands: list[str] = [] for item in todo_bundle: todo_id = str(item["todo_id"]) - commands.extend( - command - for command in ( - _claim_command(goal_id=goal_id, todo_id=todo_id, agent_id=agent_id), - _lease_command( - goal_id=goal_id, - todo_id=todo_id, - agent_id=agent_id, - write_scopes=item.get("required_write_scopes") or [], - ), - ) - if command - ) + command = _claim_command(goal_id=goal_id, todo_id=todo_id, agent_id=agent_id) + if command: + commands.append(command) aggregate_confidence = _aggregate_confidence(todo_bundle) candidate = { "branch_id": branch_id, @@ -1177,7 +1166,7 @@ def build_explore_worker_branch_plan( "replaces_loopx_runtime": False, "launches_workers": False, "mutates_quota_or_state": False, - "claim_and_lease_are_suggested_only": True, + "claim_is_suggested_only": True, "duration_guard_controlled_by_planner": False, "fixed_worker_count_controlled_by_planner": False, "forces_full_branch_fill": False, diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 79b4a87e8a..f3de10804a 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -45,7 +45,7 @@ def register_todo_command( ], default=None, help=( - "Use add to append a checkbox todo, claim to soft-claim by registered " + "Use add to append a checkbox todo, claim to prepare execution by registered " "agent id, list to read projected todos, update/complete/supersede to transition by todo_id, or " "archive-completed to move older completed todos into Completed Work Archive. " "Use plan with --text and --agent-id for the existing Goal's model planning checkpoint; the caller owns subsequent execution." @@ -285,32 +285,17 @@ def register_todo_command( todo_parser.add_argument( "--claimed-by", help=( - "For agent todo add/claim/update, assign the soft execution owner to a " + "For agent todo add/claim/update, assign the task owner to a " "registered public-safe agent id such as codex-main-control. This names " "the assignment target, not the lifecycle actor; multi-agent lifecycle " "commands still require --agent-id. User todos use --bound-agent or " "--goal-bound instead." ), ) - todo_parser.add_argument( - "--task-lease-idempotency-key", - help=( - "For todo claim on promoted hard-lease authority, atomically acquire " - "the canonical lease and claim. Optional for administrative edits and bounded owner pause/resume; " - "when supplied, this proof and the current version are strictly checked. " - "Complete, supersede and other execution-fenced updates require the active execution proof." - ), - ) - todo_parser.add_argument( - "--task-lease-expected-version", - type=int, - help=( - "For promoted todo claim, optionally compare-and-set the canonical " - "lease version. For updates with an explicit lease key, supply its current version; " - "partial or stale proof is rejected. Complete, supersede and other execution-fenced " - "updates require the active lease version when it is effective." - ), - ) + # Host orchestration uses explicit generation fencing. Ordinary task callers + # use claim_operation_id; do not advertise the host transport as task work. + todo_parser.add_argument("--task-lease-idempotency-key", help=argparse.SUPPRESS) + todo_parser.add_argument("--task-lease-expected-version", type=int, help=argparse.SUPPRESS) todo_parser.add_argument( "--bound-agent", help=( diff --git a/loopx/control_plane/capabilities/explore_result_writeback.ts b/loopx/control_plane/capabilities/explore_result_writeback.ts index 99154c8ff8..bf49b8cda2 100644 --- a/loopx/control_plane/capabilities/explore_result_writeback.ts +++ b/loopx/control_plane/capabilities/explore_result_writeback.ts @@ -17,10 +17,10 @@ export const EXPLORE_WRITEBACK_SUMMARY_LIMIT = 2000; // before authoring. Keep them in the entry even when attachment syntax is read // later. The full standalone guidance retains its original order and bytes. const EXPLORE_BEFORE_WORK = { - openLease: "For an open Todo under hard_lease, keep the active task lease through refresh-state and Explore graph/Todo-link delivery; release only after explore_result_delivery.ok=true.", - leaseRecovery: "If already released, re-enter the normal guard/claim/lease path before retrying.", - completedLease: "A completed Todo under hard_lease permits only its existing additive evidence-link exception with the owner's retained released lease key and current version; this grants no new execution authority.", - deliveryReplay: "An exact replay of already-successful delivery is readback-only and needs no new lease; unfinished delivery still requires the applicable claim/lease proof.", + openLease: "When execution fencing applies to an open Todo, keep its claimed execution through refresh-state and Explore graph/Todo-link delivery; end that execution only after explore_result_delivery.ok=true. The writeback resolves and checks execution proof internally.", + leaseRecovery: "If execution has ended or expired, re-enter the normal guard and todo claim path before retrying; a stale task reference cannot resume execution.", + completedLease: "When execution fencing applies to a completed Todo, only its existing owner-scoped additive evidence-link exception is permitted. Writeback checks retained execution history internally; this grants no new execution authority.", + deliveryReplay: "An exact replay of already-successful delivery is readback-only and needs no new claim; unfinished delivery still requires current ownership and execution checks.", optionalFinding: "Routine work needs no attachment; do not invent findings.", optionalCapture: "Capture is optional, not a settlement obligation.", negativeEvidence: "A stopped route does not imply refuted status, and a score alone does not prove refutation.", diff --git a/loopx/control_plane/coordination/claim_execution.ts b/loopx/control_plane/coordination/claim_execution.ts index 9e74eb517f..f357573d18 100644 --- a/loopx/control_plane/coordination/claim_execution.ts +++ b/loopx/control_plane/coordination/claim_execution.ts @@ -79,16 +79,14 @@ export function maintainClaimExecution(head: JsonObject, input: ExecutionInput, return materializeTaskLeaseLifecycle(lease, command, decision, input.now); } -/** Atomic claim preparation, reusing acquire/lifecycle semantics and the - * caller's provider CAS. A foreign retained execution needs explicit recovery. */ +/** Prepare execution only after the Todo claim owner accepts the actor (and any + * transfer grant). Expired execution is reclaimable without the old process; + * acquisition still validates scope, eligibility and generation under one CAS. */ export function prepareClaimExecution(head: JsonObject, input: ExecutionInput & {operation_id: string; claimed_by: string}): {lease: LeaseRecord; changed: boolean} { const index = indexCoordinationProjection(head, input.goal_id); const facts = canonicalTaskLeaseAcquireFacts(index, input.goal_id, input.todo_id, input.registered_agents, input.now); const current = facts.current; - if (current && current.owner !== input.claimed_by && current.status !== "released") { - throw new AuthorityStoreProtocolError("Another task execution is retained; its owner must release or hand it over"); - } if (current && leaseIsActive(current, input.now)) { if (current.owner !== input.claimed_by) throw new AuthorityStoreProtocolError("Task is occupied by another actor"); const renewed = maintainClaimExecution(head, input, current); @@ -100,7 +98,10 @@ export function prepareClaimExecution(head: JsonObject, expected_version: current ? leaseVersion(current) : 0, ttl_seconds: normalizeTtl(null), write_scopes: coordinationTodoWriteScopes(index.todos.get(input.todo_id)!)}; const decision = evaluateTaskLeaseAcquireDecision({handoff_mode: String(head.handoff_mode), - registered_agents: [...input.registered_agents], ...facts, command}); + registered_agents: [...input.registered_agents], ...facts, + // Claim admission has already authorized this assignment. Acquire and the + // Todo owner change commit together; the old assignment is not a new gate. + todo: facts.todo === null ? null : {...facts.todo, claimed_by: input.claimed_by}, command}); if (decision.outcome !== "apply") throw new AuthorityStoreProtocolError(`Task claim execution rejected: ${decision.code}`); return {lease: materializeTaskLeaseAcquire(input, command, decision, input.now), changed: true}; } diff --git a/loopx/control_plane/coordination/task_lease_proof.ts b/loopx/control_plane/coordination/task_lease_proof.ts index 00aca6e9ce..b0b4787bce 100644 --- a/loopx/control_plane/coordination/task_lease_proof.ts +++ b/loopx/control_plane/coordination/task_lease_proof.ts @@ -19,6 +19,7 @@ import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts"; import {leaseEpoch} from "../work_items/task_lease_acquire.ts"; import {leaseRepositoryRejection} from "../work_items/task_lease_repository.ts"; import {evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA} from "./todo_lifecycle_decision.ts"; +import {evaluateCoordinationTodoClaimDecision} from "./todo_claim.ts"; import {todoExecutionDependencyRejection} from "./todo_execution_dependency.ts"; export interface TaskLeaseProof { @@ -121,7 +122,7 @@ export function evaluateCanonicalTaskLeaseProof(input: { export type TodoUpdateLeaseRecovery = JsonObject & { action: "inspect_current_proof" | "reconcile_lease_owner" | "resolve_acquire_rejection" | - "resolve_lifecycle_edit" | "acquire_fresh_lease"; + "resolve_lifecycle_edit" | "acquire_fresh_lease" | "claim_task"; lease_state: "absent" | "active" | "released" | "expired"; owner_relation: "none" | "same_owner" | "foreign_owner"; }; @@ -251,7 +252,14 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod return {...base, action: "resolve_lifecycle_edit", reason_code: editRejection.code, reason: "This edit changes leased work requirements or status. Use the owning lifecycle transition; reacquiring a lease alone cannot authorize this metadata edit."}; } - if (todo.claimed_by !== input.actor_agent_id && !isBoundUserActionMetadataUpdate(todo, input)) { + const omittedProof = input.lease_idempotency_key == null && input.lease_expected_version == null; + const reclaimExpired = omittedProof && mode === "hard_lease" && leaseState === "expired" && + evaluateCoordinationTodoClaimDecision(todo, {goal_id: input.goal_id, todo_id: input.todo_id, + claimed_by: input.actor_agent_id ?? "", actor_agent_id: input.actor_agent_id, + expected_role: input.expected_role, registered_agents: input.registered_agents, + operation_id: "recovery-probe", manage_execution: true, dry_run: true, now: input.now}, + null, lease?.owner).status === "accepted"; + if (todo.claimed_by !== input.actor_agent_id && !reclaimExpired && !isBoundUserActionMetadataUpdate(todo, input)) { return {...base, action: "reconcile_lease_owner", reason: "A leased update requires the actor to own the Todo claim. Reconcile ownership before acquiring execution authority."}; } @@ -280,6 +288,16 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod } if (dependency !== null) return {...base, action: "resolve_acquire_rejection", reason_code: dependency.code, reason: dependency.reason}; + const managedRecovery = (): TodoUpdateLeaseRecovery => ({ + action: "claim_task", lease_state: leaseState, owner_relation: ownerRelation, + todo_status: todo.status, claimed_by: todo.claimed_by ?? null, + execution_authority_granted: false, + reason: "Claim this Todo through the ordinary task operation, then retry with its returned claim_operation_id. Claim rechecks current eligibility and ownership; update and completion manage execution internally. Keep the returned reference on retries; it cannot revive expired or replaced execution.", + 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 update", requires_flags: ["--claim-operation-id"], + reference_source: "claim_operation_id_from_claim"}, + }); const retry = {command: "loopx todo update", requires_flags: ["--task-lease-idempotency-key", "--task-lease-expected-version"], proof_source: "current_owner_lease_readback"}; @@ -289,6 +307,7 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod reason_code: repositoryRejection, reason: "Todo repository differs from its frozen execution grant. Reconcile the owning lifecycle; inspection cannot grant replacement authority."}; const eligible = sameOwner && leaseOwnerRejection(facts.todo, input.actor_agent_id, input.registered_agents) === null; + if (mode === "hard_lease" && eligible && omittedProof) return managedRecovery(); return {...base, action: eligible ? "inspect_current_proof" : "reconcile_lease_owner", reason: eligible ? "Inspect the active lease and retry with its current owner proof; do not acquire a competing execution." @@ -299,7 +318,8 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod // This hypothetical fresh identity is never published or returned as proof. const key = lease?.idempotency_key === "recovery-probe" ? "recovery-probe-next" : "recovery-probe"; const decision = decideTaskLeaseAcquire({handoff_mode: mode, registered_agents: input.registered_agents, - ...facts, command: {owner: input.actor_agent_id ?? "", idempotency_key: key, + ...facts, todo: reclaimExpired && facts.todo ? {...facts.todo, claimed_by: input.actor_agent_id} : facts.todo, + command: {owner: input.actor_agent_id ?? "", idempotency_key: key, ttl_seconds: 60, write_scopes: scopes, expected_version: lease?.version ?? 0}}); const acceptance = acceptanceWorkGuard(head, input.goal_id, input.todo_id); const blocked = decision.outcome !== "apply" ? decision.code @@ -308,6 +328,7 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTod return {...base, action: "resolve_acquire_rejection", reason_code: blocked, reason: "Current mode, Todo eligibility, acceptance or write scopes reject acquisition. Resolve that condition before retrying the edit; changing handoff mode is not a recovery shortcut."}; } + if (omittedProof) return managedRecovery(); return {...base, action: "acquire_fresh_lease", reason: "Inspect the current version, acquire a short lease with a fresh key, retry the edit with the returned proof, then release that lease. Acquisition revalidates current authority.", acquire: {command: "loopx task-lease acquire", goal_id: input.goal_id, todo_id: input.todo_id, diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index 8de1db416d..b39c3d5ed0 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -2,7 +2,7 @@ import {prepareClaimExecution} from "./claim_execution.ts"; import {retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; import {currentLeaseAcquisitionProof} from "./lease_acquisition_proof.ts"; -import {canonicalTaskLeaseAcquireFacts} from "./task_lease_state.ts"; +import {canonicalTaskLease, canonicalTaskLeaseAcquireFacts} from "./task_lease_state.ts"; import {evaluateTaskLeaseAcquireDecision, materializeTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts"; import type { JsonObject } from "../effect_program.ts"; import type { AuthorityStore, AuthorityStoreCommit } from "./authority_store.ts"; @@ -219,6 +219,7 @@ export function evaluateCoordinationTodoClaimDecision( todo: JsonObject, input: CoordinationTodoClaimInput, activeLeaseOwner?: string | null, + expiredLeaseOwner?: string | null, ): CoordinationTodoClaimDecision { const registered = input.registered_agents; const owner = input.claimed_by; @@ -236,10 +237,12 @@ export function evaluateCoordinationTodoClaimDecision( const existing = typeof todo.claimed_by === "string" && todo.claimed_by.length > 0 ? normalizeTodoAgent(todo.claimed_by, "todo.claimed_by") : null; - // Cross-agent transfer requires an explicit handoff transfer grant. + // A managed hard-lease claim can replace the matching expired holder. + // This fact is derived from canonical state, never a caller bypass flag. + // Other cross-agent transfer requires an explicit handoff transfer grant. // The grant binds source owner, target owner, todo id, revision, and // current continuation-note facts; it can only be produced by the handoff - // flow. Without a valid grant, foreign-owner claims are always rejected. + // flow. Without expiry or a valid grant, foreign-owner claims are rejected. // The final claim authority reuses the shared validateContinuationNote so // the prepared-note invariant is enforced in one place: an arbitrary JSON // note with matching hash is rejected because it lacks the typed marker, @@ -264,7 +267,7 @@ export function evaluateCoordinationTodoClaimDecision( && registered.includes(owner); } const leasedByOther = activeLeaseOwner !== undefined && activeLeaseOwner !== null && activeLeaseOwner === existing; - if (leasedByOther || !grantValid) { + if (leasedByOther || !(grantValid || expiredLeaseOwner === existing)) { return decisionFailure( "claim_owner_mismatch", "Todo is already claimed by another agent", @@ -530,13 +533,20 @@ async function executeClaimAttempt( return failure("invalid_replan_successor_claim", "A successor must be open advancement work with a concrete action/target and unchanged causal identity"); } const activeLeaseOwner = projection.leases.get(input.todo_id); - const activeLeaseActive = activeLeaseOwner !== undefined && leaseIsActive(activeLeaseOwner, input.now); - const activeLeaseHolder = activeLeaseActive && activeLeaseOwner !== undefined && typeof activeLeaseOwner.owner === "string" - ? normalizeTodoAgent(activeLeaseOwner.owner, "lease.owner") - : null; let authority: ReturnType; try { - authority = evaluateCoordinationTodoClaimDecision(todo, input, activeLeaseHolder); + const activeLeaseActive = activeLeaseOwner !== undefined && leaseIsActive(activeLeaseOwner, input.now); + const activeLeaseHolder = activeLeaseActive && activeLeaseOwner !== undefined && typeof activeLeaseOwner.owner === "string" + ? normalizeTodoAgent(activeLeaseOwner.owner, "lease.owner") + : null; + let expiredLeaseHolder: string | null = null; + if (head.head.handoff_mode === "hard_lease" && input.manage_execution === true && activeLeaseOwner) { + const retained = canonicalTaskLease(activeLeaseOwner, input.goal_id, input.todo_id); + if (retained.status === "active" && !leaseIsActive(retained, input.now)) { + expiredLeaseHolder = normalizeTodoAgent(retained.owner, "lease.owner"); + } + } + authority = evaluateCoordinationTodoClaimDecision(todo, input, activeLeaseHolder, expiredLeaseHolder); } catch (error) { return failure( "invalid_coordination_todo_claim", diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 2d136dca81..3b29244cd8 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -766,12 +766,6 @@ class CliOutputCommandClassification: surface_id="todo_list", rationale="agent work-queue read path", ), - CliOutputCommandClassification( - command_id="task-lease", - qualification="explicit_cold_path_exception", - surface_id=None, - rationale="explicit per-todo lease lifecycle command family", - ), CliOutputCommandClassification( command_id="coordination-shadow", qualification="explicit_cold_path_exception", diff --git a/loopx/control_plane/todos/markdown.py b/loopx/control_plane/todos/markdown.py index 5b547812a3..ca238e638a 100644 --- a/loopx/control_plane/todos/markdown.py +++ b/loopx/control_plane/todos/markdown.py @@ -14,7 +14,7 @@ def _render_lease_recovery(payload: dict[str, Any]) -> list[str]: 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']}"] - for step in ("inspect", "acquire", "retry", "release"): + for step in ("claim", "inspect", "acquire", "retry", "release"): instruction = recovery.get(step) if isinstance(instruction, dict) and instruction.get("command"): arguments = instruction.get("cli_args") diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index b1cda792dc..02aeb55aec 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -1,6 +1,7 @@ """Shared MCP control plane for host-specific LoopX goal adapters.""" from __future__ import annotations +import inspect import json import os import shutil @@ -9,14 +10,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Any - -try: # pydantic ships with the FastMCP extra; base installs have no pydantic. - from pydantic import Strict -except ImportError: # pragma: no cover - exercised on base installs only - class Strict: # type: ignore[no-redef] - """Inert stand-in so the shared annotation still evaluates without - pydantic; FastMCP cannot run on such an install anyway.""" +from typing import Any from .control_plane.host_adapter_settlement import ( HostTodoSettlementRequest, @@ -29,14 +23,6 @@ class Strict: # type: ignore[no-redef] ContextResolver = Callable[[], dict[str, Any] | None] -# The one boundary type for a caller-supplied lease version. FastMCP validates -# tool arguments with pydantic, and lax pydantic coerces JSON true to 1 before -# any loopx code runs, so strictness must live in the annotation itself; the -# control-plane method and the FastMCP wrapper share this alias so the two -# signatures cannot drift. -ExpectedTaskLeaseVersion = Annotated[int, Strict()] | None - - @dataclass(frozen=True) class GoalModeMCPConfig: server_name: str @@ -235,8 +221,7 @@ def complete_task( agent_id: str, evidence: str, next_agent_todo: str = "", - task_lease_idempotency_key: str = "", - task_lease_expected_version: ExpectedTaskLeaseVersion = None, + claim_operation_id: str | None = None, no_follow_up: bool = False, successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, @@ -280,13 +265,8 @@ def complete_task( args += ["--next-agent-todo", next_agent_todo] for successor in successor_todo_ids or []: args += ["--successor-todo-id", successor] - if task_lease_idempotency_key: - args += ["--task-lease-idempotency-key", task_lease_idempotency_key] - if task_lease_expected_version is not None: - args += [ - "--task-lease-expected-version", - str(task_lease_expected_version), - ] + if claim_operation_id is not None: + args += ["--claim-operation-id", claim_operation_id] if no_follow_up: args.append("--no-follow-up") request = HostTodoSettlementRequest( @@ -336,6 +316,7 @@ def create_fastmcp_server( ): try: from mcp.server.fastmcp import FastMCP + from mcp.server.fastmcp.exceptions import ToolError except Exception as exc: # pragma: no cover - exercised by real adapter startup raise SystemExit( "MCP SDK v1 is required. Install the adapter with loopx-kunluncode install.\n" @@ -343,7 +324,17 @@ def create_fastmcp_server( ) from exc control = GoalModeMCPControlPlane(config, context_resolver) - server = FastMCP(config.server_name) + completion_arguments = frozenset(inspect.signature(control.complete_task).parameters) + + class GoalModeServer(FastMCP): + async def call_tool(self, name: str, arguments: dict[str, Any]): + # FastMCP ignores unknown arguments by default. Retired execution + # proof must reject, never disappear before a completion write. + if name == "complete_task" and set(arguments) - completion_arguments: + raise ToolError("Unsupported complete_task arguments; use its current schema and the claim_operation_id returned by claim_task") + return await super().call_tool(name, arguments) + + server = GoalModeServer(config.server_name) if config.legacy_host_surface == "claude_code": @server.tool() @@ -363,7 +354,7 @@ def list_todos() -> str: @server.tool() def claim_task(todo_id: str, agent_id: str) -> str: - """Claim one todo as the bound agent.""" + """Claim one Todo; retain its claim_operation_id for updates and completion.""" return control.claim_task(todo_id, agent_id) @server.tool() @@ -392,14 +383,15 @@ def complete_task( agent_id: str, evidence: str, next_agent_todo: str = "", - task_lease_idempotency_key: str = "", - task_lease_expected_version: ExpectedTaskLeaseVersion = None, + claim_operation_id: str | None = None, no_follow_up: bool = False, successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", ) -> str: - """Complete verified work and settle once. Link existing planned successors + """Complete verified work and settle once using claim_operation_id from claim_task. + Reuse that reference on retries; ownership and expiry are checked internally. + Link existing planned successors with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. no_follow_up closes this Todo's continuation, NOT the Goal's vision. Do not duplicate existing work; only the fresh should_run contract can @@ -415,8 +407,7 @@ def complete_task( agent_id, evidence, next_agent_todo=next_agent_todo, - task_lease_idempotency_key=task_lease_idempotency_key, - task_lease_expected_version=task_lease_expected_version, + claim_operation_id=claim_operation_id, no_follow_up=no_follow_up, successor_todo_ids=successor_todo_ids, agent_vision=agent_vision, diff --git a/loopx/help_surface.py b/loopx/help_surface.py index cb177397e6..4f99654154 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -134,10 +134,6 @@ def register_command_reference(subparsers: argparse._SubParsersAction) -> None: "purpose": "Preview an explicit local-state path migration or receipt-bound rollback; stop writers before execution.", }, {"command": "loopx todo --help", "purpose": "Show todo lifecycle commands."}, - { - "command": "loopx task-lease --help", - "purpose": "Acquire, renew, transfer, release, or inspect a hard per-todo lease.", - }, { "command": "loopx coordination-shadow --help", "purpose": "Inspect, bootstrap, or revision-fenced rollback the default-off Stage 2C file shadow.", @@ -263,6 +259,10 @@ def register_command_reference(subparsers: argparse._SubParsersAction) -> None: { "title": "Maintainer and adapter commands", "commands": [ + { + "command": "loopx task-lease --help", + "purpose": "Host/adapter execution ownership protocol; ordinary task work uses todo claim and update.", + }, {"command": "loopx check", "purpose": "Run contract and public/private boundary checks."}, { "command": "loopx change-quality --help", @@ -504,7 +504,6 @@ def render_concise_help(program: str = "loopx") -> str: " loopx status Show current goals, gates, and next action.", " loopx diagnose --goal-id ID Build a compact evidence packet.", " loopx todo --help Add, claim, complete, update, or archive todos.", - " loopx task-lease --help Manage a hard per-todo lease.", " loopx coordination-shadow --help", " Inspect, bootstrap, or roll back the default-off file shadow.", " loopx quota should-run Decide whether the next agent turn should run.", diff --git a/loopx/kunluncode_goal_mode/guards.py b/loopx/kunluncode_goal_mode/guards.py index 7779fe96df..96db65dfcf 100644 --- a/loopx/kunluncode_goal_mode/guards.py +++ b/loopx/kunluncode_goal_mode/guards.py @@ -33,27 +33,7 @@ def guard_native_controller_writeback(control_plane: Any) -> None: def blocked_claim(_todo_id: str, _agent_id: str) -> str: return _native_controller_rejection("claim_task") - def blocked_complete( - _todo_id: str, - _agent_id: str, - _evidence: str, - next_agent_todo: str = "", - task_lease_idempotency_key: str = "", - task_lease_expected_version: int | None = None, - no_follow_up: bool = False, - successor_todo_ids: list[str] | None = None, - agent_vision: dict | None = None, - vision_unchanged_reason: str = "", - ) -> str: - del ( - next_agent_todo, - task_lease_idempotency_key, - task_lease_expected_version, - no_follow_up, - successor_todo_ids, - agent_vision, - vision_unchanged_reason, - ) + def blocked_complete(*_args: Any, **_kwargs: Any) -> str: return _native_controller_rejection("complete_task") control_plane.claim_task = blocked_claim diff --git a/man/loopx.1 b/man/loopx.1 index 1b2a0855fe..c263960141 100644 --- a/man/loopx.1 +++ b/man/loopx.1 @@ -105,9 +105,6 @@ Preview an explicit local\-state path migration or receipt\-bound rollback; stop \fBloopx todo \-\-help\fR Show todo lifecycle commands. .TP -\fBloopx task\-lease \-\-help\fR -Acquire, renew, transfer, release, or inspect a hard per\-todo lease. -.TP \fBloopx coordination\-shadow \-\-help\fR Inspect, bootstrap, or revision\-fenced rollback the default\-off Stage 2C file shadow. .TP @@ -198,6 +195,9 @@ Install, inspect, or remove release\-owned skills in selected project agent host Inspect, install, or remove packaged user\-level LoopX workflow skills. .SH MAINTAINER AND ADAPTER COMMANDS .TP +\fBloopx task\-lease \-\-help\fR +Host/adapter execution ownership protocol; ordinary task work uses todo claim and update. +.TP \fBloopx check\fR Run contract and public/private boundary checks. .TP diff --git a/tests/control_plane/test_cli_action_help.py b/tests/control_plane/test_cli_action_help.py index ae896b479d..5982ef727c 100644 --- a/tests/control_plane/test_cli_action_help.py +++ b/tests/control_plane/test_cli_action_help.py @@ -17,7 +17,8 @@ (["task-lease", "acquire"], ["--write-worktree", "--write-scope"], ["--new-owner"]), (["task-lease", "release"], ["--expected-version"], ["--ttl-seconds"]), (["todo", "list"], ["--todo-id", "--thin", "--limit"], ["--claimed-by", "--next-agent-todo"]), - (["todo", "claim"], ["--claimed-by", "--task-lease-expected-version"], ["--note", "--next-agent-todo"]), + (["todo", "claim"], ["--claimed-by", "--claim-operation-id"], + ["--note", "--next-agent-todo", "--task-lease-expected-version", "--task-lease-idempotency-key"]), (["todo", "receipt"], ["--operation-id"], ["--todo-id", "--claimed-by"]), (["todo", "result-read"], ["--todo-id"], ["--text", "--claimed-by"]), (["todo", "plan"], ["--agent-id", "--text"], ["--claimed-by", "--todo-id"]), diff --git a/tests/control_plane/test_explore_result_writeback.py b/tests/control_plane/test_explore_result_writeback.py index c9f383fb34..adc863152c 100644 --- a/tests/control_plane/test_explore_result_writeback.py +++ b/tests/control_plane/test_explore_result_writeback.py @@ -834,7 +834,7 @@ def run(*command): ] guard = run(*guard_command) hint = guard["interaction_contract"]["cli_channel"]["settlement_plan"]["ordered_steps"][1]["optional_attachments"][0] - assert "release only after explore_result_delivery.ok=true" in hint["guidance"] + assert "end that execution only after explore_result_delivery.ok=true" in hint["guidance"] lease_binding = ["--goal-id", GOAL_ID, "--todo-id", TODO_ID, "--owner", AGENT_ID] early = run("task-lease", "acquire", *lease_binding, "--idempotency-key", "early-release")["lease"] run("task-lease", "release", *lease_binding, "--idempotency-key", "early-release", diff --git a/tests/control_plane/test_explore_settlement_attachment.py b/tests/control_plane/test_explore_settlement_attachment.py index 03d829caff..f2ab717b6a 100644 --- a/tests/control_plane/test_explore_settlement_attachment.py +++ b/tests/control_plane/test_explore_settlement_attachment.py @@ -36,10 +36,10 @@ def test_settlement_attachment_preserves_commands_and_off_plan(tmp_path, mode): assert attachment["inline_field"] == "explore_result" assert attachment["attachment_schema"] == "explore_result_attachment_v0" assert "Routine work needs no attachment" in attachment["guidance"] - assert "open Todo under hard_lease" in attachment["guidance"] - assert "release only after explore_result_delivery.ok=true" in attachment["guidance"] - assert "readback-only and needs no new lease" in attachment["guidance"] - assert "unfinished delivery still requires the applicable claim/lease proof" in attachment["guidance"] + assert "open Todo" in attachment["guidance"] + assert "end that execution only after explore_result_delivery.ok=true" in attachment["guidance"] + assert "readback-only and needs no new claim" in attachment["guidance"] + assert "unfinished delivery still requires current ownership and execution checks" in attachment["guidance"] assert attachment["path_delta_attachment_schema"] == "explore_result_from_path_delta_v0" assert "observation" not in attachment["path_delta_attachment_template"] assert projected == before # Including command, order, receipts and gates. diff --git a/tests/control_plane/test_replan_staged_read.py b/tests/control_plane/test_replan_staged_read.py index 780a14138c..68dc690405 100644 --- a/tests/control_plane/test_replan_staged_read.py +++ b/tests/control_plane/test_replan_staged_read.py @@ -165,7 +165,7 @@ def assert_explore_entry_and_writer(source, entry, writer): assert "result_attachment_template" not in graph # These obligations apply BEFORE choosing or executing work, not at closeout. assert "Read measurement_feedback before repeating a measured route" in graph["result_writeback_guidance"] - assert "keep the active task lease through refresh-state" in graph["result_writeback_guidance"] + assert "keep its claimed execution through refresh-state" in graph["result_writeback_guidance"] assert "Keep raw logs local." in graph["result_writeback_guidance"] assert "Capture is optional, not a settlement obligation." in graph["result_writeback_guidance"] assert "never invent a shared version" in graph["result_writeback_guidance"] diff --git a/tests/control_plane/test_todo_claim_argument_recovery.py b/tests/control_plane/test_todo_claim_argument_recovery.py index fe974441f1..338b384f54 100644 --- a/tests/control_plane/test_todo_claim_argument_recovery.py +++ b/tests/control_plane/test_todo_claim_argument_recovery.py @@ -229,3 +229,11 @@ def test_empty_managed_reference_is_rejected_without_mutation(claim_cli, command else "invalid_local_coordination_todo_terminal_lifecycle_request") assert rejected["error"] == "claim_operation_id must be a non-empty trimmed string" assert snapshot() == before + + +def test_task_help_exposes_managed_reference_not_host_lease_flags(): + result = subprocess.run([sys.executable, "-m", "loopx.cli", "todo", "claim", "--help"], + cwd=REPO, capture_output=True, text=True, check=True) + assert "--claim-operation-id" in result.stdout + assert "--task-lease-idempotency-key" not in result.stdout + assert "--task-lease-expected-version" not in result.stdout diff --git a/tests/control_plane/test_todo_update_lease_recovery.py b/tests/control_plane/test_todo_update_lease_recovery.py index 5441877da0..d0fa01a1b1 100644 --- a/tests/control_plane/test_todo_update_lease_recovery.py +++ b/tests/control_plane/test_todo_update_lease_recovery.py @@ -16,7 +16,8 @@ @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_execution_scoped_planning_recovery_through_public_cli(tmp_path: Path, monkeypatch, provider: str) -> None: +@pytest.mark.parametrize("initial_state", ["active", "released"]) +def test_execution_scoped_planning_recovery_through_public_cli(tmp_path: Path, monkeypatch, provider: str, initial_state: str) -> None: mode = "hard_lease" isolate_sqlite_runtime(tmp_path, monkeypatch) runtime = tmp_path / "runtime" @@ -51,44 +52,43 @@ def snapshot() -> dict: return result first = cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "first-execution", "--ttl-seconds", "60") - cli("task-lease", "release", "--owner", "agent-a", "--idempotency-key", "first-execution", - "--expected-version", str(first["lease"]["version"])) + if initial_state == "released": + cli("task-lease", "release", "--owner", "agent-a", "--idempotency-key", "first-execution", + "--expected-version", str(first["lease"]["version"])) before = snapshot() display_before = state.read_bytes() if state.exists() else None edit = ("todo", "update", "--agent-id", "agent-a", "--text", "Recovered edit", "--unblocks-todo-id", "todo_recovery") for dry in [("--dry-run",), ()]: rejected = cli(*edit, *dry, ok=False) - assert rejected["error_code"] == "handoff_mode_requires_lease" + assert rejected["error_code"] == ("lease_fence_required" if initial_state == "active" else "handoff_mode_requires_lease") assert rejected["handoff_mode"] == mode recovery = rejected["recovery"] - assert recovery["action"] == "acquire_fresh_lease" - assert recovery["lease_state"] == "released" + assert recovery["action"] == "claim_task" + assert recovery["lease_state"] == initial_state assert recovery["owner_relation"] == "same_owner" - assert recovery["acquire"]["command"] == "loopx task-lease acquire" + assert recovery["claim"]["command"] == "loopx todo claim" assert recovery["execution_authority_granted"] is False rendered = render_todo_markdown(rejected) assert f"handoff_mode: `{mode}`" in rendered - assert "loopx task-lease acquire" in rendered + assert "loopx todo claim" in rendered + assert "task-lease" not in json.dumps(recovery) assert "first-execution" not in rendered assert "first-execution" not in json.dumps(rejected) assert snapshot() == before assert (state.read_bytes() if state.exists() else None) == display_before - observed = cli("task-lease", "inspect")["lease"] - assert observed["version"] == recovery["acquire"]["expected_version"] - acquired = cli("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "new-execution", - "--expected-version", str(observed["version"]), "--ttl-seconds", str(recovery["acquire"]["ttl_seconds"])) - proof = ("--task-lease-idempotency-key", "new-execution", "--task-lease-expected-version", str(acquired["lease"]["version"])) + acquired = cli(*recovery["claim"]["cli_args"], "--claim-operation-id", "new-execution") + assert acquired["execution_managed"] and "lease" not in acquired + proof = ("--claim-operation-id", acquired["claim_operation_id"]) before = snapshot() - stale = cli(*edit, "--task-lease-idempotency-key", "first-execution", - "--task-lease-expected-version", str(observed["version"]), ok=False) + stale = cli(*edit, "--task-lease-idempotency-key", "wrong-execution", + "--task-lease-expected-version", str(first["lease"]["version"]), ok=False) assert stale["error_code"] == "lease_cas_mismatch" assert stale["recovery"]["action"] == "inspect_current_proof" assert snapshot() == before assert cli(*edit, *proof)["ok"] is True assert snapshot()["todos"][0]["text"] == "Recovered edit" - released = cli("task-lease", "release", "--owner", "agent-a", "--idempotency-key", "new-execution", - "--expected-version", str(acquired["lease"]["version"])) - assert released["lease"]["status"] == "released" + completed = cli("todo", "complete", "--agent-id", "agent-a", *proof, "--evidence", "evidence:validated") + assert completed["ok"] assert cli("task-lease", "inspect")["lease"]["status"] == "released" assert registry.read_bytes() == registry_bytes # Successful publication may materialize a derived display; it is not an input. diff --git a/tests/control_plane_ts/managed_claim_execution_conformance.ts b/tests/control_plane_ts/managed_claim_execution_conformance.ts index e871c1b619..3ed305f20e 100644 --- a/tests/control_plane_ts/managed_claim_execution_conformance.ts +++ b/tests/control_plane_ts/managed_claim_execution_conformance.ts @@ -8,6 +8,7 @@ import {executeCoordinationTodoTerminalLifecycle as complete} from "../../loopx/ import {executeCoordinationTodoUpdate as update} from "../../loopx/control_plane/coordination/todo_update.ts"; import {executeCanonicalTaskLeaseLifecycle as maintain} from "../../loopx/control_plane/coordination/task_lease_lifecycle.ts"; import {prepareCoordinationProjectionCommit} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {todoUpdateLeaseRecovery} from "../../loopx/control_plane/coordination/task_lease_proof.ts"; import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; async function loaded(store: AuthorityStore) { @@ -107,6 +108,48 @@ export function registerManagedClaimExecutionConformance(provider: string, facto assert.equal((await update(store, progress)).status, "failed"); assert.deepEqual(await loaded(store), excluded); }); + test(`${provider} managed execution: authorized expiry takeover commits one generation and fences the old writer`, async t => { + const {store, request, progress} = await setup(t); + const accepted = await claim(store, request); + assert.equal(accepted.status, "applied"); + const original = accepted.lease as JsonObject; + const before = await loaded(store); + const target = {...request, actor_agent_id: "agent-b", claimed_by: "agent-b", operation_id: "takeover", + expected_provider_revision: before.provider_revision, now: new Date("2026-09-13T10:45:00Z")}; + assert.equal((await claim(store, {...target, + now: new Date("2026-09-13T10:44:59Z")})).reason_code, "claim_owner_mismatch"); + assert.equal((await claim(store, {...target, registered_agents: ["agent-a"]})).status, "failed"); + assert.equal((await claim(store, {...target, manage_execution: false})).reason_code, "claim_owner_mismatch"); + assert.deepEqual(await loaded(store), before); + const recovery = todoUpdateLeaseRecovery(before.head, + {...progress, claim_operation_id: undefined, actor_agent_id: "agent-b", now: target.now}, "hard_lease"); + assert.equal(recovery.action, "claim_task"); + assert.equal(recovery.execution_authority_granted, false); + assert.equal(recovery.inspect, undefined); + assert.equal(recovery.acquire, undefined); + assert.deepEqual(await loaded(store), before, "recovery advice is read-only"); + const attempts = ["takeover-one", "takeover-two"].map(operation_id => ({...target, operation_id})); + const results = await Promise.all(attempts.map(input => claim(store, input))); + const winner = results.findIndex(result => result.status === "applied"); + assert.equal(results.filter(result => result.status === "applied").length, 1, JSON.stringify(results)); + const after = await loaded(store); + const current = (after.head.leases as JsonObject[])[0]!; + assert.equal((after.head.todos as JsonObject[])[0]!.claimed_by, "agent-b"); + assert.equal(current.owner, "agent-b"); + assert.equal(current.lease_epoch, Number(original.lease_epoch) + 1); + assert.equal(current.version, Number(original.version) + 1); + assert.notEqual(current.idempotency_key, original.idempotency_key); + assert.deepEqual(current.write_scopes, original.write_scopes); + assert.equal((await update(store, {...progress, now: target.now})).status, "failed"); + for (const operation of ["renew", "release"] as const) { + assert.equal((await maintain(store, {operation, goal_id: request.goal_id, todo_id: request.todo_id, + owner: "agent-a", idempotency_key: String(original.idempotency_key), expected_version: Number(original.version), + ttl_seconds: null, registered_agents: request.registered_agents, now: target.now})).status, "failed"); + } + assert.deepEqual(await loaded(store), after, "late old execution cannot write or end its replacement"); + assert.equal((await claim(store, attempts[winner]!)).status, "replayed"); + assert.deepEqual(await loaded(store), after, "lost takeover response does not acquire another generation"); + }); test(`${provider} managed execution: unavailable receipt and changed policy reject without maintenance`, async t => { const {store, request, progress} = await setup(t); assert.equal((await claim(store, request)).status, "applied"); @@ -130,6 +173,29 @@ export function registerManagedClaimExecutionConformance(provider: string, facto assert.equal((await update(store, {...progress, operation_id: "post-policy-change"})).reason_code, "claim_execution_unavailable"); assert.deepEqual(await loaded(store), changed); }); + test(`${provider} managed execution: expiry takeover rejects ineligible or unproven recovery without writes`, async t => { + for (const scenario of ["released", "invalid-expiry", "unrelated-holder", "excluded", "blocked", "soft-claim"]) { + const {store, request} = await setup(t); + assert.equal((await claim(store, request)).status, "applied"); + const head = await loaded(store); + const todo = {...(head.head.todos as JsonObject[])[0]!}; + const lease = {...(head.head.leases as JsonObject[])[0]!}; + if (scenario === "released") lease.status = "released"; + if (scenario === "invalid-expiry") lease.expires_at = "invalid"; + if (scenario === "unrelated-holder") lease.owner = "agent-b"; + if (scenario === "excluded") todo.excluded_agents = ["agent-b"]; + if (scenario === "blocked") todo.status = "blocked"; + assert.equal((await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: request.goal_id, + operation_id: "recovery-boundary", expected_provider_revision: head.provider_revision, + projection: {...head.head, ...(scenario === "soft-claim" ? {handoff_mode: "soft_claim"} : {})}, + mutations: [{kind: "todo_upsert", todo}, {kind: "lease_upsert", lease}]}))).status, "applied"); + const before = await loaded(store); + const result = await claim(store, {...request, operation_id: "takeover", actor_agent_id: "agent-b", + claimed_by: "agent-b", now: new Date("2026-09-13T10:45:00Z")}); + assert.equal(result.status, "failed", `${scenario}: ${JSON.stringify(result)}`); + assert.deepEqual(await loaded(store), before, scenario); + } + }); test(`${provider} managed execution: pre-upgrade plain claim recovery never changes its intent`, async t => { const {store, request} = await setup(t); assert.equal((await claim(store, request)).status, "applied"); diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 6c4624d343..2a3c97186e 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -974,7 +974,7 @@ test("administrative copy retains lease history; explicit execution recovery sta assert.equal(recovery.lease_state, state); assert.equal(recovery.owner_relation, owner === "agent-a" ? "same_owner" : "foreign_owner"); assert.equal(recovery.action, state === "active" ? owner === "agent-a" ? "inspect_current_proof" : "reconcile_lease_owner" - : mode === "soft_claim" ? "resolve_acquire_rejection" : "acquire_fresh_lease"); + : mode === "soft_claim" ? "resolve_acquire_rejection" : "claim_task"); assert.equal(JSON.stringify(result).includes("private-execution-key"), false); assert.deepEqual(await store.loadAuthority(), before); assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); diff --git a/tests/test_goal_mode_mcp_completion_validation.py b/tests/test_goal_mode_mcp_completion_validation.py index 9ab079898b..1615c79255 100644 --- a/tests/test_goal_mode_mcp_completion_validation.py +++ b/tests/test_goal_mode_mcp_completion_validation.py @@ -23,7 +23,7 @@ _FAIL_COMMAND = f'{shlex.quote(sys.executable)} -c "raise SystemExit(1)"' -def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: +def _write_fixture(tmp_path: Path, *, provider="file", mode="soft_claim") -> tuple[Path, Path]: repo = tmp_path / "repo" repo.mkdir() runtime = tmp_path / "runtime" @@ -75,9 +75,9 @@ def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: build_todo_runtime_shadow_projection( goal_id=GOAL_ID, todos=[], - handoff_mode="soft_claim", + handoff_mode=mode, ), - state_path=state, + state_path=state, provider=provider, ) return registry, state @@ -180,32 +180,60 @@ def test_mcp_advancement_validation_precedes_controller_owned_settlement( assert ("terminal_closeout" in payload["settlement"]) is no_follow_up assert _agent_todo_status(state, todo_id) == "done" -def test_expected_lease_version_annotation_rejects_bool_at_the_boundary() -> None: - """FastMCP validates tool arguments with pydantic, and lax pydantic - coerces JSON ``true`` to ``1`` BEFORE any loopx code runs - by the time - the lease CAS compares versions the bool is indistinguishable from a - legitimate 1. The boundary annotation is therefore the only place the - rejection can happen, and it is single-sourced so the control-plane - method and the FastMCP wrapper cannot drift.""" - - import pydantic - - from loopx import goal_mode_mcp - - adapter = pydantic.TypeAdapter(goal_mode_mcp.ExpectedTaskLeaseVersion) - for disguised in (True, False): - try: - adapter.validate_python(disguised) - except pydantic.ValidationError: - pass - else: - raise AssertionError(f"bool {disguised} was coerced to an int") - assert adapter.validate_python(7) == 7 - assert adapter.validate_python(None) is None - - import typing - - hints = typing.get_type_hints( - goal_mode_mcp.GoalModeMCPControlPlane.complete_task, include_extras=True - ) - assert hints["task_lease_expected_version"] == goal_mode_mcp.ExpectedTaskLeaseVersion +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_mcp_claim_reference_completes_once_with_managed_execution(tmp_path, monkeypatch, provider): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = _write_fixture(tmp_path, provider=provider, mode="hard_lease") + todo_id = _add_todo(registry) + control = _control(registry) + claimed = json.loads(control.claim_task(todo_id, AGENT)) + assert claimed["ok"] and claimed["execution_managed"], claimed + assert "lease" not in claimed + reference = claimed["claim_operation_id"] + other = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="A different bounded change.", task_class="advancement_task", claimed_by=AGENT) + foreign_reference = json.loads(control.claim_task(other["todo_id"], AGENT))["claim_operation_id"] + for invalid in ("", foreign_reference): + denied = _first_json_blob(control.complete_task(todo_id, AGENT, "evidence:validated", claim_operation_id=invalid)) + assert not denied["ok"], denied + assert _agent_todo_status(state, todo_id) == "open" + args = dict(claim_operation_id=reference) + completed = _first_json_blob(control.complete_task(todo_id, AGENT, "evidence:validated", **args)) + assert completed["ok"] and completed["completed"], completed + assert completed["settlement"]["quota_spend"]["appended"] + def snapshot(): + return read_canonical_todos_if_promoted(runtime_root=tmp_path / "runtime", goal_id=GOAL_ID, include_leases=True) + before = snapshot() + assert next(lease for lease in before["leases"] if lease["todo_id"] == todo_id)["status"] == "released" + replay = _first_json_blob(control.complete_task(todo_id, AGENT, "evidence:validated", **args)) + assert replay["ok"], replay + assert snapshot() == before + assert _agent_todo_status(state, todo_id) == "done" + + +def test_mcp_completion_schema_exposes_task_reference_not_lease_protocol(monkeypatch): + import asyncio + from mcp.server.fastmcp.exceptions import ToolError + from loopx.goal_mode_mcp import create_fastmcp_server + + server, control = create_fastmcp_server(GoalModeMCPConfig( + server_name="test", runtime_profile="claude_code", legacy_host_surface="claude_code"), lambda: {}) + tool = next(tool for tool in asyncio.run(server.list_tools()) if tool.name == "complete_task") + properties = tool.inputSchema["properties"] + assert "claim_operation_id" in properties + assert not any("lease" in name for name in properties) + with pytest.raises(TypeError, match="unexpected keyword"): + control.complete_task("todo", AGENT, "evidence", task_lease_expected_version=1) + writes = [] + monkeypatch.setattr(control, "complete_task", lambda *args, **kwargs: writes.append(kwargs) or "completed") + for retired in ({"task_lease_expected_version": 1}, {"task_lease_idempotency_key": "old"}): + with pytest.raises(ToolError, match="Unsupported complete_task"): + asyncio.run(server.call_tool("complete_task", { + "todo_id": "todo", "agent_id": AGENT, "evidence": "evidence", **retired})) + assert writes == [] + asyncio.run(server.call_tool("complete_task", { + "todo_id": "todo", "agent_id": AGENT, "evidence": "evidence", "claim_operation_id": "accepted-claim"})) + assert len(writes) == 1 and writes[0]["claim_operation_id"] == "accepted-claim" diff --git a/tests/test_kunluncode_goal_mode.py b/tests/test_kunluncode_goal_mode.py index 576c0fea84..69815dceaa 100644 --- a/tests/test_kunluncode_goal_mode.py +++ b/tests/test_kunluncode_goal_mode.py @@ -209,8 +209,7 @@ def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: "todo_333333333333", "kunlun", "focused check passed", - task_lease_idempotency_key="lease-fixture", - task_lease_expected_version=7, + claim_operation_id="claim-fixture", no_follow_up=True, ) completed = json.loads(output) @@ -221,17 +220,18 @@ def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: terminal_closeout = settlement_commands[-1] assert "--no-follow-up" not in first_completion assert "--no-follow-up" in terminal_closeout - assert "--task-lease-idempotency-key" in first_completion - assert "--task-lease-idempotency-key" in terminal_closeout + assert "--claim-operation-id" in first_completion + assert "--claim-operation-id" in terminal_closeout assert completed["settlement"]["terminal_closeout"][ "completion_continuation" ] == "no_followup" - for lease_command in (first_completion, terminal_closeout): - version_at = lease_command.index("--task-lease-expected-version") - assert lease_command[version_at : version_at + 2] == [ - "--task-lease-expected-version", - "7", + for completion in (first_completion, terminal_closeout): + reference_at = completion.index("--claim-operation-id") + assert completion[reference_at : reference_at + 2] == [ + "--claim-operation-id", "claim-fixture", ] + assert not any("task-lease" in part for part in completion) + def test_fastmcp_complete_task_forwards_complete_task_lease_fence( @@ -303,8 +303,7 @@ def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: "todo_id": "todo_777777777777", "agent_id": "claude", "evidence": "focused check passed", - "task_lease_idempotency_key": "lease-fixture", - "task_lease_expected_version": 7, + "claim_operation_id": "claim-fixture", }, ) ) @@ -316,11 +315,9 @@ def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: assert complete_commands, commands for complete_command in complete_commands: assert complete_command[ - complete_command.index("--task-lease-idempotency-key") : - ][:2] == ["--task-lease-idempotency-key", "lease-fixture"] - assert complete_command[ - complete_command.index("--task-lease-expected-version") : - ][:2] == ["--task-lease-expected-version", "7"] + complete_command.index("--claim-operation-id") : + ][:2] == ["--claim-operation-id", "claim-fixture"] + assert not any("task-lease" in part for part in complete_command) def test_mcp_spends_only_after_typed_completed_state( @@ -1281,8 +1278,7 @@ def test_native_controller_blocks_model_visible_mcp_mutations( "todo_id": "todo-1", "agent_id": "kunlun", "evidence": "premature evidence", - "task_lease_idempotency_key": "lease-fixture", - "task_lease_expected_version": 7, + "claim_operation_id": "claim-fixture", }, ) )