diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index a489e6260c..1a77aef697 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2632,6 +2632,23 @@ superseded;前提是没有仍在有效期内的租约,也未提交旧执行 执行必须重新获取租约。`complete`、挤占有效租约、跨负责人修改及混入执行内容的 更新仍受原有门禁约束。 +Owner suspension closes the reverse transition as well: an open Agent Todo's +current claim/lease holder may atomically set `deferred` with an explicit wait +and reason while releasing that live execution generation. No work-content or +ownership edits are bundled. Retained lease lineage applies in legacy mode too; +reopening then follows the same no-live-holder rule. The shared TS owner and +provider CAS preserve receipts and retries. Pending registered Todo/monitor +waits remain eligible for blocked, no-spend closeout after deferral, retaining +the original Turn binding. See [causal closeout](../../reference/protocols/quota-blocked-causal-closeout-v0.md). +This closes an S3 owner-wait lifecycle gap; it does not qualify general shared +amendment or SQLite default admission. + +反向转换也由同一 TS owner 负责:当前 claim/lease 持有者可凭有效证明,把开放任务 +原子延期并释放租约;不混入任务内容或所有权修改。有租约历史的 legacy 模式同样 +适用,恢复遵守无活跃持有者规则。延期后的已注册 Todo/monitor 等待仍能按原 Turn +身份完成无扣额阻塞结算。该交付收敛 S3 等待生命周期,不等于通用 amendment 或 +SQLite 默认准入已验收。 + ### Relation to Staged Delivery Mapped to the five-stage plan from the #2787 review: the characterization diff --git a/docs/reference/protocols/quota-blocked-causal-closeout-v0.md b/docs/reference/protocols/quota-blocked-causal-closeout-v0.md index f8cf9998c4..3f614a91ed 100644 --- a/docs/reference/protocols/quota-blocked-causal-closeout-v0.md +++ b/docs/reference/protocols/quota-blocked-causal-closeout-v0.md @@ -16,7 +16,7 @@ facts; it does not implement a second wait decision. The existing - Preflight recomputes the existing Todo resume condition from the current waiting Todo and its unique registered dependency. The waiting Agent Todo - must remain open, active and pending. A monitor must remain open, have a + must remain open or deferred, active and pending. A monitor must remain open, have a captured non-negative generation, and still match that baseline exactly. A completed, archived, missing, self-referential, malformed or stale target is not proof; the monitor's current generation must be explicit. @@ -28,7 +28,7 @@ facts; it does not implement a second wait decision. The existing durable-writeback receipts, no debit and no delivery credit. Exact refresh retry replays the same result. A later spend request is a no-op for this already-closed identity; an existing debit is never erased. -- The Todo remains open with its original completion validator and canonical +- The Todo remains unfinished with its original completion validator and canonical wait. A fresh Turn can select independent work; existing Todo resume semantics still decide when this Todo is ready. Do not replace a causal dependency with a short timer, force an early monitor poll, or treat closeout as completion. @@ -46,6 +46,26 @@ CLI/provider acceptance checks both dependency kinds, replay, no debit, original validator preservation and independent next-Turn selection. It does not claim live research adoption or PostgreSQL qualification. +### Suspending leased work + +For an open, leased Agent Todo, the current claim/lease owner can use the +existing `todo update` command with `--status deferred --resume-when + todo_done:todo_dependency --reason "Dependency pending"` and the current +`--task-lease-idempotency-key` / `--task-lease-expected-version`. Submit only +those lifecycle fields. Todo deferral and lease release commit in one provider +CAS, in legacy as well as hard-lease mode when retained lease lineage exists. +Dry-run changes nothing; retry replays the receipt. Ownership, scope, work +requirements and completion validation cannot be amended through this path. +Monitor-driven automatic waiting retains `status=open`; its existing authoring +contract rejects deferral so a generation change can make it runnable again. +Reopen explicitly deferred work with `--status open --clear-resume-when` and acquire a fresh execution +lease before work; the old proof remains invalid. + +A `pr_merged` condition is still a valid Todo scheduling condition, but a PR +number alone is not a qualified blocked-closeout proof. Register a real +monitor or dependency Todo and use `monitor_changed` / `todo_done` for causal +closeout. Unsupported PR waits now name this recovery route explicitly. + ## 中文 已准入的 advancement Turn 可以发现真实依赖,以 @@ -57,7 +77,7 @@ not claim live research adoption or PostgreSQL qualification. 只传当前 Todo 事实,不另建判断源。`quota.settlement.read` 依据 `loopx_quota_blocked_wait_request_v0` 区分预检与原持久结算读回。 -- 复用 Todo resume owner,以当前开放、active、pending 的 Agent Todo 与唯一注册 +- 复用 Todo resume owner,以当前 open 或 deferred、active、pending 的 Agent Todo 与唯一注册 依赖重算条件。Monitor 须仍开放,非负 generation 与登记基线精确相等;目标 已完成、归档、缺失、自引用、格式错误、代际推进/倒退或陈旧投影均不算等待 证明;Monitor 当前 generation 必须显式存在。 @@ -66,7 +86,7 @@ not claim live research adoption or PostgreSQL qualification. - `typed_blocked_writeback_no_spend` 仅含 validation 与 durable-writeback 回执, 不扣额、不计交付进展。精确刷新幂等重放;已关闭身份的 spend 请求不再追加, 已有真实扣额不会被抹去。 -- Todo 保持开放、原验收器和 canonical 等待不变。新 Turn 可选独立工作;何时恢复 +- Todo 保持未完成、原验收器和 canonical 等待不变。新 Turn 可选独立工作;何时恢复 仍由原 Todo resume 语义判断。不得用短定时器替换依赖、强迫提前 poll,或将 Turn 结算当成 Todo 完成。 - 保留旧 v0 有界等待与 promoted Turn 自有五分钟重试。降级前须用兼容运行时 @@ -77,3 +97,18 @@ Dashboard 已消费 canonical 等待与回执,Chat/Lark 仍复用 Todo updat 不新增前端控件、Lark 命令或独立 UI 权威。File、SQLite 的真实 CLI/provider 验收覆盖两种依赖、重放、零扣额、原验收器保留与下一 Turn 独立选择;不据此 宣称投研真实采用或 PostgreSQL 资格已通过。 + +### 有租约任务的延期 + +当前 claim/lease 持有者可沿用 `todo update`,只提交 `--status deferred`、 +`--resume-when todo_done:todo_dependency`、`--reason`,并带当前 +`--task-lease-idempotency-key` 和 `--task-lease-expected-version`。 +TS 在一个 provider CAS 内同时延期 Todo、释放租约;保留租约历史的 legacy +模式也适用。Dry-run 不写入,重试重放原回执,不允许夹带任务内容、权限或验收修改。 +`monitor_changed` 的自动等待仍须保持 open,代际变化后才能自动进入可执行队列; +其原有 authoring 规则继续拒绝延期。显式延期的普通依赖任务恢复时用 +`--status open --clear-resume-when`,执行前重新获取租约,旧证明仍失效。 + +`pr_merged` 仍是合法的调度等待条件,但 PR 编号本身不能证明阻塞结算所需的 +真实依赖。应登记实际 monitor/依赖 Todo,以 `monitor_changed`/`todo_done` +完成因果结算;错误信息明确给出此恢复路径。 diff --git a/loopx/control_plane/coordination/task_lease_proof.ts b/loopx/control_plane/coordination/task_lease_proof.ts index 8b7025cbf4..88fdc15367 100644 --- a/loopx/control_plane/coordination/task_lease_proof.ts +++ b/loopx/control_plane/coordination/task_lease_proof.ts @@ -9,6 +9,8 @@ import {canonicalTaskLeaseAcquireFacts} from "./task_lease_state.ts"; import {coordinationTodoWriteScopes} from "./todo_write_scopes.ts"; import {decideTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts"; import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts"; +import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; +import {isOwnerDeferral} from "./todo_deferred_lifecycle.ts"; import {TODO_WORK_REQUIREMENT_FIELDS} from "../todos/work_requirements.ts"; import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts"; import {leaseEpoch} from "../work_items/task_lease_acquire.ts"; @@ -89,10 +91,7 @@ export function leasedTodoEditRejection(todo: JsonObject, intent: JsonObject): { /** Diagnostic only: acquisition still rechecks the current head and its CAS. * Reuse admission rather than recommend a new lease solely from its expiry. */ -export function todoUpdateLeaseRecovery(head: JsonObject, input: { - goal_id: string; todo_id: string; actor_agent_id: string | null; - registered_agents: readonly string[]; now: Date; planning_intent?: JsonObject; -}, mode: string): TodoUpdateLeaseRecovery { +export function todoUpdateLeaseRecovery(head: JsonObject, input: CoordinationTodoUpdateInput, mode: string): TodoUpdateLeaseRecovery { const index = indexCoordinationProjection(head, input.goal_id); const facts = canonicalTaskLeaseAcquireFacts(index, input.goal_id, input.todo_id, input.registered_agents, input.now); @@ -111,7 +110,7 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: { }; const todo = index.todos.get(input.todo_id)!; const intent = input.planning_intent ?? {}; - const editRejection = lease === null ? null : leasedTodoEditRejection(todo, intent); + const editRejection = lease === null || isOwnerDeferral(input, todo) ? null : leasedTodoEditRejection(todo, intent); if (editRejection !== null) { 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."}; diff --git a/loopx/control_plane/coordination/todo_deferred_reopen.ts b/loopx/control_plane/coordination/todo_deferred_lifecycle.ts similarity index 68% rename from loopx/control_plane/coordination/todo_deferred_reopen.ts rename to loopx/control_plane/coordination/todo_deferred_lifecycle.ts index 3422c121a0..774496cd63 100644 --- a/loopx/control_plane/coordination/todo_deferred_reopen.ts +++ b/loopx/control_plane/coordination/todo_deferred_lifecycle.ts @@ -1,5 +1,5 @@ -/** A deferred Todo cannot acquire a hard lease until it is open. Reopening is - * therefore a narrow lifecycle transition, not an unfenced execution edit. */ +/** Deferred work suspends execution; resuming it requires a fresh grant. + * Todo status and lease retirement commit through the same provider CAS. */ import type {JsonObject} from "../effect_program.ts"; import type {CoordinationProjectionMutation} from "./coordination_projection.ts"; import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; @@ -18,6 +18,17 @@ export function isDeferredReopen(input: CoordinationTodoUpdateInput, todo: JsonO Object.keys(intent).every(field => REOPEN_FIELDS.has(field)); } +/** The current holder may suspend unchanged work with its live execution proof. + * Admission validates that proof; this predicate never grants authority. */ +export function isOwnerDeferral(input: CoordinationTodoUpdateInput, todo: JsonObject): boolean { + const intent = input.planning_intent ?? {}; + return todo.role === "agent" && todo.status === "open" && intent.status === "deferred" && + typeof intent.resume_when === "string" && Boolean(intent.resume_when.trim()) && + typeof intent.reason === "string" && Boolean(intent.reason.trim()) && + Object.keys(input.patch).length === 0 && input.clear_fields.length === 0 && + Object.keys(intent).every(field => ["status", "resume_when", "reason"].includes(field)); +} + /** Actor, claim, exclusion and binding admission happens before this check. * A retained inactive generation is history; a currently active one blocks * the transition even if its holder also owns the Todo. */ @@ -43,22 +54,23 @@ export function deferredReopenRejection(input: { return null; } -/** Provider CAS commits the Todo reopening and any stale lease retirement - * together. The next execution still has to acquire a fresh lease. */ -export function planDeferredReopen(input: { +/** Admission has verified a live owner proof for suspension, or absence of a + * live holder for reopening. The next execution must acquire a fresh lease. */ +export function planDeferredLifecycle(input: { goal_id: string; before: JsonObject; after: JsonObject; lease: JsonObject | undefined; now: Date; }): {mutations: CoordinationProjectionMutation[]; transition: JsonObject | null} { - if (input.before.status !== "deferred" || input.after.status !== "open" || + const deferring = input.before.status === "open" && input.after.status === "deferred"; + if ((!deferring && !(input.before.status === "deferred" && input.after.status === "open")) || input.before.role !== "agent") return {mutations: [], transition: null}; const lease = input.lease === undefined ? null : canonicalTaskLease(input.lease, input.goal_id, String(input.before.todo_id)); const retiring = lease !== null && lease.status !== "released"; return { mutations: retiring ? [{kind: "lease_upsert", lease: releasedTaskLeaseRecord(lease, input.now)}] : [], - transition: {kind: "deferred_resumed", execution_authority_granted: false, + transition: {kind: deferring ? "todo_deferred" : "deferred_resumed", execution_authority_granted: false, lease_retirement: lease === null ? "absent" : retiring ? "released" : "already_released", - next_execution: "acquire_fresh_lease", + next_execution: deferring ? "wait_then_acquire_fresh_lease" : "acquire_fresh_lease", ...(lease === null ? {} : {retired_lease_version: leaseVersion(lease), retired_lease_epoch: leaseEpoch(lease)})}, }; diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 6bc52c3a54..1ff28f40a7 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -15,7 +15,7 @@ import { } from "./coordination_projection.ts"; import {planMonitorCycleTransition} from "./todo_monitor_cycle.ts"; -import {isDeferredReopen, planDeferredReopen} from "./todo_deferred_reopen.ts"; +import {isDeferredReopen, isOwnerDeferral, planDeferredLifecycle} from "./todo_deferred_lifecycle.ts"; import {isBlockedLifecycleTransition, planBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts"; import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts"; import { CoordinationCommandReceipt } from "./command_receipt.ts"; @@ -71,6 +71,8 @@ function updateReceipt(input: CoordinationTodoUpdateInput, requestSha: string) { monitor_poll_transition: canonicalAuthorityObject(original.monitor_poll_transition, "Monitor update receipt transition")}), ...(original.monitor_lifecycle_transition === undefined ? {} : {monitor_lifecycle_transition: canonicalAuthorityObject(original.monitor_lifecycle_transition, "Monitor lifecycle receipt transition")}), + ...(original.deferred_transition === undefined ? {} : {deferred_transition: + canonicalAuthorityObject(original.deferred_transition, "Deferred lifecycle receipt transition")}), ...(original.deferred_resume_transition === undefined ? {} : {deferred_resume_transition: canonicalAuthorityObject(original.deferred_resume_transition, "Deferred resume receipt transition")}), ...(original.blocked_lifecycle_transition === undefined ? {} : {blocked_lifecycle_transition: @@ -226,13 +228,14 @@ export async function executeCoordinationTodoUpdate( } } let cycle: ReturnType; - let deferredCycle: ReturnType | null = null; + let deferredCycle: ReturnType | null = null; let blockedCycle: ReturnType | null = null; try { cycle = planMonitorCycleTransition({goal_id: input.goal_id, before: target.todo, after: next, lease: target.leases.get(input.todo_id), handoff_mode: head.head.handoff_mode, now: input.now}); - if (head.head.handoff_mode === "hard_lease" && isDeferredReopen(input, target.todo)) { - deferredCycle = planDeferredReopen({goal_id: input.goal_id, before: target.todo, after: next, + if ((head.head.handoff_mode === "hard_lease" || target.leases.has(input.todo_id)) && + (isDeferredReopen(input, target.todo) || isOwnerDeferral(input, target.todo))) { + deferredCycle = planDeferredLifecycle({goal_id: input.goal_id, before: target.todo, after: next, lease: target.leases.get(input.todo_id), now: input.now}); } if (head.head.handoff_mode === "hard_lease" && isBlockedLifecycleTransition(input, target.todo)) { @@ -242,6 +245,9 @@ export async function executeCoordinationTodoUpdate( } catch (error) { return failure("invalid_coordination_projection", error instanceof Error ? error.message : "invalid retained lease"); } + const deferredTransition = deferredCycle?.transition == null ? {} : { + [isOwnerDeferral(input, target.todo) ? "deferred_transition" : "deferred_resume_transition"]: deferredCycle.transition, + }; const commit: AuthorityStoreCommit = changed ? prepareCoordinationProjectionCommit({ goal_id: input.goal_id, operation_id: input.operation_id, expected_provider_revision: head.provider_revision, projection: head.head, @@ -269,7 +275,7 @@ export async function executeCoordinationTodoUpdate( ...(completionValidationRevisionReceipt === null ? {} : {completion_validation_revision: completionValidationRevisionReceipt}), ...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}), - ...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}), + ...deferredTransition, ...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})}; commit.receipts = [{schema_version: COORDINATION_TODO_UPDATE_RECEIPT_SCHEMA, operation_id: input.operation_id, goal_id: input.goal_id, @@ -278,7 +284,7 @@ export async function executeCoordinationTodoUpdate( ...(completionValidationRevisionReceipt === null ? {} : {completion_validation_revision: completionValidationRevisionReceipt}), ...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}), - ...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}), + ...deferredTransition, ...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})}]; return receipt.commit(store, commit); } diff --git a/loopx/control_plane/coordination/todo_update_admission.ts b/loopx/control_plane/coordination/todo_update_admission.ts index 394a56649a..e5cd687107 100644 --- a/loopx/control_plane/coordination/todo_update_admission.ts +++ b/loopx/control_plane/coordination/todo_update_admission.ts @@ -10,7 +10,7 @@ import {TODO_OWNERSHIP_INTENT_FIELDS} from "../todos/authoring_scope.ts"; import {evaluateCoordinationTodoMutationDecision, COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA} from "./todo_lifecycle_decision.ts"; import {decodeTaskLeaseProof, evaluateCanonicalTaskLeaseProof, todoUpdateLeaseRecovery, leasedTodoEditRejection} from "./task_lease_proof.ts"; -import {deferredReopenRejection, isDeferredReopen} from "./todo_deferred_reopen.ts"; +import {deferredReopenRejection, isDeferredReopen, isOwnerDeferral} from "./todo_deferred_lifecycle.ts"; import {blockedLifecycleRejection, isBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts"; interface TodoUpdateRejection {code: string; reason: string; handoff_mode?: string; recovery?: JsonObject} @@ -125,7 +125,7 @@ export function todoUpdateAdmissionRejection( error instanceof Error ? error.message : "invalid retained lease facts"); } } - if (mode === "hard_lease" && isDeferredReopen(input, todo)) { + if ((mode === "hard_lease" || lease !== undefined) && isDeferredReopen(input, todo)) { try { return deferredReopenRejection({goal_id: input.goal_id, todo_id: input.todo_id, actor_agent_id: input.actor_agent_id, registered_agents: input.registered_agents, @@ -156,7 +156,7 @@ export function todoUpdateAdmissionRejection( return reject("update_owner_mismatch", "Leased Todo update requires the current claim owner"); } if (lease !== undefined) { - return leasedTodoEditRejection(todo, input.planning_intent ?? {}); + return isOwnerDeferral(input, todo) ? null : leasedTodoEditRejection(todo, input.planning_intent ?? {}); } } catch (error) { return reject("invalid_coordination_projection", diff --git a/loopx/control_plane/quota/blocked_wait.ts b/loopx/control_plane/quota/blocked_wait.ts index 6dda7c0121..eec64a79f2 100644 --- a/loopx/control_plane/quota/blocked_wait.ts +++ b/loopx/control_plane/quota/blocked_wait.ts @@ -17,7 +17,7 @@ function reject(message: string): never { } function causalCondition(waiting: JsonObject, target: JsonObject): JsonObject | null { - if (waiting.status !== "open" || waiting.role !== "agent" || + if (!["open", "deferred"].includes(String(waiting.status)) || waiting.role !== "agent" || (waiting.archive_state != null && waiting.archive_state !== "active") || waiting.task_class !== "advancement_task" || waiting.resume_ready !== false || waiting.todo_id === target.todo_id || @@ -112,6 +112,9 @@ export function prepareBlockedWait(value: unknown): JsonObject { resume_when: resume, observed_at: request.observed_at, waiting_todo: retainedTodo(todo), target_todo: retainedTodo(target) }; } + if (typeof resume === "string" && resume.startsWith("pr_merged:")) { + reject("cannot qualify a PR merge wait from Todo facts alone; use a registered monitor_changed or todo_done dependency with current readback, then retry this same Turn. A PR number is not pending-dependency evidence"); + } if (!resume && request.allow_turn_settlement_retry === true && todo.status === "open") { const due = new Date(observed + 300_000).toISOString().replace(".000Z", "Z"); return { schema_version: "quota_blocked_retry_v0", source: "turn_settlement", @@ -147,7 +150,7 @@ export function projectReceiptBoundWait(value: unknown): JsonObject { const todos = request.todos.map(row => requireJsonObject(row, "receipt-bound Todo")); const matches = todos.filter(row => row.todo_id === request.todo_id); const todo = matches[0]; - if (matches.length !== 1 || !todo || todo.role !== "agent" || todo.status !== "open" || + if (matches.length !== 1 || !todo || todo.role !== "agent" || !["open", "deferred"].includes(String(todo.status)) || todo.archive_state !== "active" || todo.task_class !== "advancement_task" || todo.resume_ready !== false || (todo.claimed_by && todo.claimed_by !== request.agent_id)) { return {status: "none"}; diff --git a/tests/control_plane/test_causal_blocked_closeout_cli.py b/tests/control_plane/test_causal_blocked_closeout_cli.py index f69bc19a9e..47476571da 100644 --- a/tests/control_plane/test_causal_blocked_closeout_cli.py +++ b/tests/control_plane/test_causal_blocked_closeout_cli.py @@ -20,10 +20,10 @@ @pytest.mark.parametrize("provider", ["file", "sqlite"]) -@pytest.mark.parametrize("kind", ["monitor_changed", "todo_done"]) +@pytest.mark.parametrize("kind,defer", [("monitor_changed", False), ("todo_done", False), ("todo_done", True)]) def test_pending_causal_wait_settles_once_and_releases_independent_work( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], - provider: str, kind: str, + provider: str, kind: str, defer: bool, ) -> None: if provider == "sqlite": isolate_sqlite_runtime(tmp_path, monkeypatch) @@ -67,8 +67,10 @@ def cli(*args: str, cwd: Path = project) -> tuple[int, dict]: )) rc, listed = cli("todo", "list", "--goal-id", GOAL_ID) assert rc == 0, listed + if defer: + next(todo for todo in listed["todos"] if todo["todo_id"] == TODO_ID)["claimed_by"] = AGENT_ID initialize_canonical_authority(runtime, GOAL_ID, build_todo_runtime_shadow_projection( - goal_id=GOAL_ID, todos=listed["todos"], handoff_mode="soft_claim", leases=[], + goal_id=GOAL_ID, todos=listed["todos"], handoff_mode="legacy" if defer else "soft_claim", leases=[], ), state_path=state, provider=provider) binding = ("--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", f"causal-blocked-{kind}-{provider}") @@ -83,6 +85,18 @@ def cli(*args: str, cwd: Path = project) -> tuple[int, dict]: "--resume-when", f"{kind}:{MONITOR_ID}", "--successor-todo-id", ALTERNATIVE_TODO_ID) assert rc == 0, wait + if defer: + rc, acquired = cli("task-lease", "acquire", "--goal-id", GOAL_ID, "--todo-id", TODO_ID, + "--owner", AGENT_ID, "--idempotency-key", "execution-wait", "--ttl-seconds", "900") + assert rc == 0, acquired + rc, suspended = cli("todo", "update", "--goal-id", GOAL_ID, "--todo-id", TODO_ID, + "--agent-id", AGENT_ID, "--status", "deferred", + "--resume-when", f"{kind}:{MONITOR_ID}", "--reason", "Dependency pending", + "--task-lease-idempotency-key", "execution-wait", + "--task-lease-expected-version", str(acquired["lease"]["version"])) + assert rc == 0, json.dumps(suspended, indent=2) + rc, lease = cli("task-lease", "inspect", "--goal-id", GOAL_ID, "--todo-id", TODO_ID) + assert rc == 0 and lease["lease"]["status"] == "released", lease refresh_args = ("refresh-state", "--goal-id", GOAL_ID, "--classification", "causal_wait_writeback", "--delivery-batch-scale", "single_surface", "--delivery-outcome", "outcome_gap", *binding, @@ -108,7 +122,7 @@ def cli(*args: str, cwd: Path = project) -> tuple[int, dict]: rc, after = cli("todo", "list", "--goal-id", GOAL_ID, "--todo-id", TODO_ID) assert rc == 0, after todo = after["todos"][0] - assert todo["status"] == "open" and todo["resume_ready"] is False + assert todo["status"] == ("deferred" if defer else "open") and todo["resume_ready"] is False assert todo["resume_when"] == f"{kind}:{MONITOR_ID}" assert todo["completion_validation_sha256"] == digest rc, next_turn = cli("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, diff --git a/tests/control_plane_ts/causal_blocked_wait.test.ts b/tests/control_plane_ts/causal_blocked_wait.test.ts index a660e8eb9a..ce50059f00 100644 --- a/tests/control_plane_ts/causal_blocked_wait.test.ts +++ b/tests/control_plane_ts/causal_blocked_wait.test.ts @@ -64,3 +64,20 @@ test("unregistered, fabricated and self-referential wait strings never suffice", assert.throws(() => prepareBlockedWait({ ...request, todos: [{ ...request.todos[0], role: "user" }, request.todos[1]] }), /registered pending/); assert.throws(() => prepareBlockedWait({ ...request, todos: [{ ...request.todos[0], archive_state: "archived" }, request.todos[1]] }), /registered pending/); }); + +test("deferred dependency work retains the original Turn's blocked closeout proof", () => { + const request = fixture("todo_done"); + request.todos[0].status = "deferred"; + const wait = prepareBlockedWait(request); + assert.equal(isBoundedBlockedRetry(wait, "todo_waiting"), true); + assert.equal(isBoundedBlockedRetry(wait, "todo_other"), false); + assert.throws(() => prepareBlockedWait({...request, + todos: [request.todos[0], {...request.todos[1], status: "done"}]}), /registered pending/); +}); + + +test("a PR wait gives a causal recovery route without accepting caller-authored PR evidence", () => { + const request = fixture("todo_done"); + request.todos[0].resume_when = "pr_merged:example/project#1"; + assert.throws(() => prepareBlockedWait(request), /cannot qualify a PR merge wait.*registered monitor_changed or todo_done/); +}); diff --git a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts index 445800918b..7393a5773b 100644 --- a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts +++ b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts @@ -160,3 +160,48 @@ for (const provider of ["file", "sqlite"] as const) { assert.deepEqual(await read(store), after); }); } + +for (const provider of ["file", "sqlite"] as const) { + for (const mode of ["legacy", "hard_lease"] as const) { + test(`${provider}/${mode}: owner deferral releases its grant atomically and can resume`, + {skip: provider === "sqlite" && sqliteSkip}, async () => { + const store = await seeded(provider, {status: "open", done: false}, + oldLease("active", "2026-09-06T00:00:00Z")); + const seededHead = await read(store); + await store.commitAuthority({operation_id: "set-mode", expected_provider_revision: seededHead.provider_revision, + next_projection: {...seededHead.head, handoff_mode: mode}, events: [], receipts: []}); + const input = {...resume("defer-owner"), lease_idempotency_key: "old-execution", lease_expected_version: 3, + planning_intent: {status: "deferred", resume_when: "todo_done:todo_dependency", reason: "Dependency is pending"}}; + const before = await read(store); + for (const change of [{lease_expected_version: 2}, {lease_idempotency_key: "foreign"}, + {actor_agent_id: "agent-b"}, {lease_idempotency_key: null, lease_expected_version: null}, + {patch: {text: "Changed work"}}, {planning_intent: {...input.planning_intent, completion_criteria: "New contract"}}]) { + assert.equal((await executeCoordinationTodoUpdate(store, {...input, ...change})).status, "failed"); + assert.deepEqual(await read(store), before); + } + assert.equal((await executeCoordinationTodoUpdate(store, {...input, dry_run: true})).status, "planned"); + assert.deepEqual(await read(store), before); + const applied = await executeCoordinationTodoUpdate(store, input); + assert.equal(applied.status, "applied", JSON.stringify(applied)); + assert.equal((applied.deferred_transition as JsonObject).kind, "todo_deferred"); + assert.equal((applied.deferred_transition as JsonObject).execution_authority_granted, false); + const after = await read(store); + assert.equal((after.head.todos as JsonObject[])[0].status, "deferred"); + assert.equal((after.head.todos as JsonObject[])[0].claimed_by, OWNER); + assert.equal((after.head.leases as JsonObject[])[0].status, "released"); + assert.equal((after.head.leases as JsonObject[])[0].version, 3); + assert.equal((await executeCoordinationTodoUpdate(store, input)).status, "replayed"); + assert.deepEqual(await read(store), after); + assert.equal((await executeCoordinationTodoUpdate(store, {...input, + planning_intent: {...input.planning_intent, reason: "Changed intent"}})).reason_code, + "coordination_operation_identity_mismatch"); + assert.equal((await executeCoordinationTodoUpdate(store, resume("resume-after-wait"))).status, "applied"); + const retry = await executeCoordinationTodoUpdate(store, {...input, operation_id: "defer-again"}); + assert.equal(retry.status, "failed"); + assert.equal((retry.recovery as JsonObject).action, "acquire_fresh_lease"); + assert.equal((await executeCoordinationTodoUpdate(store, {...resume("old-proof"), + patch: {note: "Unauthorized execution"}, planning_intent: {}, + lease_idempotency_key: "old-execution", lease_expected_version: 3})).status, "failed"); + }); + } +} diff --git a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts index 64b5075fdd..601421724b 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -170,6 +170,41 @@ if (database && installed) { if (cleared.status === "loaded") assert.equal((cleared.head.todos as {priority?: string}[])[0]!.priority, undefined); }); + test("PostgreSQL owner deferral atomically retires the lease and replays after reopen", async t => { + await installed; + const options = {tenant_id: `tenant-${randomUUID()}`, goal_id: `goal-${randomUUID()}`}; + t.after(() => cleanScope(options.tenant_id, options.goal_id)); + const store = new PostgreSqlAuthorityStore(database, options); + const todos = [{schema_version: "todo_domain_record_v0", todo_id: "todo_wait", role: "agent", + status: "open", done: false, archive_state: "active", text: "Wait for dependency", + task_class: "advancement_task", claimed_by: "agent-a"}]; + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + events: [], receipts: [], next_projection: {goal_id: options.goal_id, handoff_mode: "hard_lease", todos, + leases: [{schema_version: "task_lease_v0", goal_id: options.goal_id, todo_id: "todo_wait", + owner: "agent-a", idempotency_key: "execution", version: 1, lease_epoch: 1, + status: "active", expires_at: "2030-01-01T01:00:00Z", write_scopes: []}], + todo_read_model: {schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, todo_count: 1, + records_sha256: canonicalAuthoritySha256(todos), contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}}}); + const request = {goal_id: options.goal_id, todo_id: "todo_wait", expected_role: "agent", + actor_agent_id: "agent-a", registered_agents: ["agent-a"], operation_id: "defer", + patch: {}, clear_fields: [], planning_intent: {status: "deferred", + resume_when: "todo_done:todo_dependency", reason: "Dependency pending"}, + lease_idempotency_key: "execution", lease_expected_version: 1, + dry_run: false, now: new Date("2030-01-01T00:00:00Z")}; + const before = await store.loadAuthority(); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, lease_expected_version: 2})).status, "failed"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "applied"); + const reopened = new PostgreSqlAuthorityStore(database, options); + const after = await reopened.loadAuthority(); + assert.equal(after.status, "loaded"); + if (after.status !== "loaded") return; + assert.equal((after.head.todos as {status: string}[])[0].status, "deferred"); + assert.equal((after.head.leases as {status: string}[])[0].status, "released"); + assert.equal((await executeCoordinationTodoUpdate(reopened, request)).status, "replayed"); + assert.deepEqual(await reopened.loadAuthority(), after); + }); + test("PostgreSQL scan binds head and rows to one snapshot during concurrent commit", async t => { await installed; const options = {tenant_id: `tenant-${randomUUID()}`, goal_id: `goal-${randomUUID()}`}; diff --git a/tests/control_plane_ts/receipt_bound_wait.test.ts b/tests/control_plane_ts/receipt_bound_wait.test.ts index 3f9e2dc104..a65cc85c10 100644 --- a/tests/control_plane_ts/receipt_bound_wait.test.ts +++ b/tests/control_plane_ts/receipt_bound_wait.test.ts @@ -64,3 +64,14 @@ for (const resume_when of ["resume_at:2026-01-01T00:10:00Z", "resume_at:2026-01- ]}), {status: "none"}); }); } + + +test("a deferred Todo closes its original Turn rather than selecting a replacement", () => { + const input = request(); + input.todos[0].status = "deferred"; + const result = projectReceiptBoundWait(input); + assert.equal(result.status, "recovery_required"); + const recovery = result.recovery as Record; + assert.equal(recovery.binding_id, "todo_waiting"); + assert.equal(recovery.turn_instance_id, "host-turn-1"); +});