diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7ffae74377..7f3ab0579b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -48,6 +48,18 @@ - Target base branch: - Direction tracker or promotion unit: +## Shared-authority RFC fixture impact + + + +- Production-scale fixture schema: +- Semantic dimensions changed, or reviewed no-impact rationale: +- Provider conformance arms run: +- Read-only legacy/file/PostgreSQL three-arm rehearsal (required for promotion, runtime-routing, or compatibility-projection changes): + ## Boundary Checklist - [ ] I did not commit `.loopx/`, `.codex/goals/`, live `ACTIVE_GOAL_STATE.md`, credentials, private benchmark traces, verifier output, raw agent sessions, internal document links, or local machine paths. diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 40de891fef..31ffedca67 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -87,7 +87,15 @@ jobs: run: | npm ci --ignore-scripts npm run typecheck:control-plane - npm run test:control-plane + npm run test:control-plane:coverage + + - name: Upload TypeScript control-plane coverage + uses: actions/upload-artifact@v7 + with: + name: typescript-control-plane-coverage + path: coverage/control-plane/lcov.info + if-no-files-found: error + retention-days: 3 - name: Lint test suite run: >- @@ -247,7 +255,7 @@ jobs: env: LOOPX_SHADOW_COMPARISON_OUTPUT: .local/stage2c-observables # Keep each module's shared workspace and ordered parity rows on one worker. - run: python -m pytest -q -n 2 --dist loadfile -m stage2c_e2e --durations=20 --junitxml=stage2c-e2e.xml + run: python -m pytest -q -n 4 --dist loadfile -m stage2c_e2e --durations=20 --junitxml=stage2c-e2e.xml - name: Reject deliberate correctness regressions if: matrix.suite == 'mutants' run: python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ceb1c04153..e6e522ff18 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -42,6 +42,13 @@ jobs: with: name: python-coverage-xml + - name: Download this run's TypeScript coverage + if: steps.sonar-token.outputs.available == 'true' + uses: actions/download-artifact@v7 + with: + name: typescript-control-plane-coverage + path: coverage/control-plane + - name: SonarCloud scan if: steps.sonar-token.outputs.available == 'true' # Analysis-only: findings are reported to the PR/dashboard but never diff --git a/.gitignore b/.gitignore index 1c3ece6228..0d598a192d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist/ .mypy_cache/ .coverage coverage.xml +coverage/ htmlcov/ .playwright-cli/ output/playwright/ 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 8d07894e42..58abc25e28 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -3,7 +3,7 @@ - Status: Draft, under maintainer review - Initially proposed by: NoKV Lab - Widened by: LoopX maintainers -- Date: 2026-08-05; revised 2026-09-05 +- Date: 2026-08-05; revised 2026-09-07 - Scope: one provider-neutral LoopX authority contract with built-in file, optional NoKV, and optional PostgreSQL provider profiles, complementing [`host-integration-surface-v0`](../../reference/protocols/host-integration-surface-v0.md) @@ -2344,12 +2344,12 @@ write that cannot preserve the contract. promotion, add sustained mixed-writer parity runs, event-only Todo coverage, and the selected provider profile's recovery/capacity evidence. - Completion of the compatibility projection outbox and conformance rows for - file, NoKV, and PostgreSQL. The first provider-first slice now reuses the - committed authority journal as the durable intent for native Todo create, - claim, and narrow update, then renders native active/archive records into - machine-owned Markdown regions with idempotent replay. Remaining native Todo - mutations, lease-file projection, backlog/status readback, and provider- - neutral authority binding still need the same contract. Providers do not + file, NoKV, and PostgreSQL. Provider-first create, claim, narrow update, + complete, supersede, and role-scoped archive reuse the committed authority + journal as durable intent and render native active/archive records into + machine-owned Markdown regions with idempotent replay. Lease-file projection, + backlog/status readback, the provider-neutral authority binding, and the + remaining command inventory still need the same contract. Providers do not promote together; each profile must pass it before it is eligible. - Retention, fast path, and measured capacity for the selected first-promotion profile; the reference executor's removal and status flips (question 13). @@ -2419,14 +2419,56 @@ parity at the same revision before changing a binding or manifest. Questions 8 and 10's completeness rule applies to domain facts and retained compatibility provenance; it does not require native callers to manufacture Markdown addresses. +### Provider-first terminal lifecycle checkpoint (2026-09-07) + +Promoted `complete`, `supersede`, and role-scoped `archive` now use one native +TypeScript transaction across file, NoKV, and PostgreSQL. The authority owner +decides actor/claim/lease admission; derives successor priority, capability and +Agent bindings, exclusions, continuation, and predecessor relations from typed +caller intent; reduces completion policy; commits the Todo/lease/head/outbox +write set with CAS; and persists replay receipts. Python remains an adapter for +registry facts, the caller-approved validation effect, intent/result transport, +and compatibility projection drain; it does not select a different terminal or +successor outcome for a provider. The legacy Markdown and event writers reuse +the same pure TypeScript successor decision before materializing their records. + +Validation declarations cross the canonical boundary as a required marker and +SHA-256 digest only. Raw argv stays in a 0600 host-local sidecar and recovery +must prove the digest before executing it. This keeps provider heads portable +and public-safe without turning recovery into a silent validation bypass. +Imported v0 `index` remains the archive-order compatibility fact; native records +fall back to durable completion/update time and Todo identity. Legacy lease +files whose Todo no longer exists in the current canonical collection remain +historical audit material and are excluded from live projection. + +Qualification uses one read-only, production-complex snapshot for three arms: +an immutable legacy baseline clone, an isolated file store, and an isolated +real PostgreSQL tenant. The provider heads compare exactly; the legacy result +compares through the declared compatibility projection. Archive comparison +removes provider-retained archived records and their historical leases from the +legacy hot view, and ignores absolute imported indexes only after separately +proving identical per-role relative order. Domain fields, archive selection, +active leases, and non-target records are never normalized; the source snapshot +must remain unchanged. The executable rehearsal is +`examples/control_plane/authority-three-arm-rehearsal.py`. A checked-in, +deterministic, public-safe scale fixture exercises the same distribution and +pressure, including hard-lease fences, across every provider conformance suite. It cannot replace the +read-only three-arm rehearsal because all providers share the new semantic +owner and can therefore agree on the same regression. + +Every pull request that claims progress against this RFC follows the +[production-scale fixture stewardship contract](../../development/testing-and-quality.md#production-scale-fixture-stewardship--生产规模-fixture-维护契约). +It declares fixture impact, exercises every affected provider arm, and keeps +the read-only three-arm rehearsal as a separate promotion gate. + ### Next delivery and parallel provider work -The immediate kernel sequence is: (1) a real provider-first Todo lifecycle caller -with the replaced Python decisions removed; (2) explicit v0 import plus sustained -consumer/capture/recovery qualification; (3) reviewed promotion with fenced -export and cleanup. Each slice must prove an end-to-end transaction, not merely -another schema identifier consolidation. Native contract acceptance alone is -not permission to bypass any promotion hold. +The immediate kernel sequence is: (1) finish the remaining provider-first Todo +command inventory behind the same runtime boundary; (2) explicit v0 import plus +sustained consumer/capture/recovery qualification; (3) reviewed promotion with +fenced export and cleanup. Each slice must prove an end-to-end transaction, not +merely another schema identifier consolidation. Native contract acceptance +alone is not permission to bypass any promotion hold. The first replacement-first `claim` slice routes both the default Markdown writer and the promoted provider transaction through one TypeScript decision. diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 673bcb082d..fc7fae5f9c 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -3,7 +3,7 @@ - 状态:Draft,正在接受 maintainer review - 最初提案方:NoKV Lab - 扩展修订方:LoopX maintainer -- 日期:2026-08-05;修订于 2026-09-05 +- 日期:2026-08-05;修订于 2026-09-07 - 范围:一个 provider-neutral 的 LoopX 权威合同,支持内置 file、可选 NoKV 与可选 PostgreSQL provider profile,用来补充 [`host-integration-surface-v0`](../../reference/protocols/host-integration-surface-v0.md) @@ -1858,12 +1858,12 @@ integrity chain、确定性 scan 与 recovery readback。物理 profile 可以 提交到既有 `coordination.runtime_shadow` file-v0 lineage,不再创建第二套 local-shadow candidate。晋升前仍需补持续 mixed-writer parity、event-only Todo 覆盖和所选 provider profile 的 recovery/capacity 证据。 -- 补齐兼容投影 outbox 与 file、NoKV、PostgreSQL 的 conformance row。首个 - provider-first 切片已把 committed authority journal 复用为 native Todo create、 - claim 和窄 update 的持久 intent,再以幂等 replay 把 native active/archive record - 渲染到机器所有的 Markdown region。其余 native Todo mutation、lease-file 投影、 - backlog/status 回读和 provider-neutral authority binding 仍需落实同一合同。三个 - provider 不必同时晋升,但每个 profile 都必须先通过该合同才具备资格。 +- 补齐兼容投影 outbox 与 file、NoKV、PostgreSQL 的 conformance row。Provider-first + create、claim、窄 update、complete、supersede 与按 role 执行的 archive 已把 committed + authority journal 复用为持久 intent,再以幂等 replay 把 native active/archive record + 渲染到机器所有的 Markdown region。lease-file 投影、backlog/status 回读、 + provider-neutral authority binding 与剩余 command inventory 仍需落实同一合同。 + 三个 provider 不必同时晋升,但每个 profile 都必须先通过该合同才具备资格。 - 首个晋升 profile 的 retention、fast path 与实测 capacity;参考执行器的删除与 status flip(问题 13)。 - 晋升后的 rollback:已交付的 rollback 隔离的是晋升前 lineage。首次权威写 @@ -1917,10 +1917,45 @@ render/export/rollback 与 selection parity,再改变 binding 或 manifest。 的完整性要求覆盖 domain fact 与保留的 compatibility provenance,但不要求原生 caller 伪造 Markdown 地址。 +### Provider-first terminal lifecycle 检查点(2026-09-07) + +Promotion 后的 `complete`、`supersede` 与按 role 执行的 `archive`,现在在 file、 +NoKV、PostgreSQL 上使用同一笔 TypeScript 原生事务。authority owner 决定 +actor/claim/lease admission,从 typed caller intent 推导 successor 的 priority、capability +与 Agent binding、exclusion、continuation 和 predecessor relation,reduce completion +policy,以 CAS 提交 Todo/lease/head/outbox write set,并持久化 replay receipt。Python +只保留 registry fact、caller-approved validation effect、intent/result transport 与兼容投影 +drain 的 adapter 职责,不再针对不同 provider 选择另一种 terminal 或 successor outcome。 +Legacy Markdown 与 event writer 在物化 record 前复用同一个纯 TypeScript successor +decision。 + +Validation declaration 只以 required marker 与 SHA-256 digest 跨越 canonical 边界。 +raw argv 留在权限为 0600 的 host-local sidecar,恢复时必须先证明 digest 匹配才可执行。 +这使 provider head 保持可移植、public-safe,同时不会让 recovery 静默绕过 validation。 +导入 v0 的 `index` 继续作为归档顺序兼容事实;native record 回退到持久 +completion/update 时间与 Todo identity。Todo 已不在当前 canonical collection 中的 +legacy lease file 继续作为历史审计材料保留,但不进入 live projection。 + +资格验证使用同一份只读、生产复杂度快照做三臂对照:不可变 legacy baseline clone、 +隔离 file store、隔离的真实 PostgreSQL tenant。两个 provider head 精确比较;legacy +结果按显式 compatibility projection 比较。归档时仅从 legacy hot view 排除 provider +保留的 archive 记录及其历史 lease,并且只有先证明每个 role 的相对顺序完全一致,才可 +忽略导入 `index` 的绝对值;domain 字段、归档选择、active lease 与非目标记录不得归一化, +源快照必须不变。可执行演练为 +`examples/control_plane/authority-three-arm-rehearsal.py`。受检入的确定性 public-safe +规模 fixture 在每个 provider conformance suite 中制造同样的分布、压力与 hard-lease +fence。它不能替代只读三臂演练,因为所有 provider 共享新的 semantic owner,可能同时 +同意同一个回归。 + +凡声称推进本 RFC 的 PR,都必须遵守 +[production-scale fixture 维护契约](../../development/testing-and-quality.md#production-scale-fixture-stewardship--生产规模-fixture-维护契约): +声明 fixture 影响、覆盖所有受影响的 provider arm,并把只读三臂演练保留为独立的 +promotion gate。 + ### 下一步交付与并行 provider 工作 -kernel 的近期顺序是:(1)真实 provider-first Todo lifecycle caller,并删除被替代 -的 Python decision;(2)显式 v0 import 加持续 consumer/capture/recovery 资格化; +kernel 的近期顺序是:(1)在同一 runtime boundary 后补完剩余 provider-first Todo +command inventory;(2)显式 v0 import 加持续 consumer/capture/recovery 资格化; (3)具备 fenced export 和 cleanup 的已评审 promotion。每个切片必须证明端到端 transaction,不能只做另一轮 schema identifier 统一。接受 native contract 不等于 可以绕过任何 promotion hold。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 8e42dab950..7e3c53eeda 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -3,7 +3,7 @@ - Status: Accepted, transaction-payoff phase in progress - Proposed by: LoopX maintainers - Date: 2026-08-15 -- Last revised: 2026-09-05 +- Last revised: 2026-09-07 - Scope: an incremental, replacement-first migration of the LoopX control-plane core from Python to TypeScript without maintaining two semantic implementations @@ -64,7 +64,7 @@ mode without writing or promoting anything; omit it to retain default behavior. It grants neither a lease nor current ownership on historical replay. Combined claim/lease acquisition remains follow-up work. -The next replacement slice makes promoted `todo add` a native create +Promoted `todo add` is a native create transaction on that same authority owner. Python validates the established CLI arguments and adapts them once into the versioned domain record; TypeScript owns duplicate identity, replay, actor/owner eligibility, CAS, receipt, and @@ -74,6 +74,52 @@ Markdown write path. Completion-validation argv remains typed data rather than a shell-encoded compatibility field. Default, unpromoted goals retain their existing Markdown transaction until their explicit promotion boundary. +The terminal-lifecycle stage package extends that boundary to promoted +`complete`, `supersede`, and role-scoped `archive`. TypeScript owns admission, +claim/lease fencing, successor validation, completion-policy reduction, CAS, +receipts, projection intent, and archive selection. Python projects registry +facts, executes an explicitly declared validation effect between typed +reductions, and drains compatibility projections; it no longer recreates the +terminal state machine for promoted goals. The canonical Todo stores only a +validation-required marker and declaration digest. Raw argv remains in a +0600 host-local sidecar and must match that digest on recovery before the +effect may run. Imported v0 Todos retain legacy `index` archive ordering; +provider-native records use durable completion/update time and Todo identity. +Historical lease files absent from the current Todo graph remain audit history +and are not projected back into the canonical live head. + +Every non-preview terminal or archive entrypoint acquires the same per-goal +shadow-maintenance mutex used by bootstrap and rollback, then rechecks the +durable management state before opening the canonical provider. A lifecycle +write therefore cannot overlap a bootstrap/rollback transition or bypass its +write hold. After the durable promotion fence is present, a missing canonical +head is reported as a typed canonical-authority outage with an explicit +restore-before-retry recovery action. It is not relabeled as a legacy-writer +fence, and it never authorizes a Markdown fallback. + +This stage is qualified with a three-arm rehearsal from one read-only, +production-complex snapshot: an immutable legacy baseline clone, an isolated +file provider, and an isolated real PostgreSQL provider. The provider heads +must match exactly and the legacy arm must match semantically after normalizing +the declared compatibility projection. For archive, that projection excludes +provider-retained archived records and their historical leases from the legacy +hot view, and ignores absolute imported indexes only after proving identical +per-role relative order. It never normalizes domain fields, archive selection, +active leases, or non-target records; the source snapshot remains unchanged. +The versioned rehearsal command lives in +`examples/control_plane/authority-three-arm-rehearsal.py`. A deterministic +public-safe scale fixture exercises +the same status mix, current/retired leases, standing decisions, validation, +successor, replay, concurrency, archive pressure, and hard-lease fences in every provider suite. +That fixture is durable regression coverage, not a substitute for the current +read-only three-arm rehearsal. + +From this checkpoint onward, every pull request that claims progress against +this RFC follows the +[production-scale fixture stewardship contract](../../development/testing-and-quality.md#production-scale-fixture-stewardship--生产规模-fixture-维护契约). +It declares fixture impact, exercises every affected provider arm, and keeps +the read-only three-arm rehearsal as a separate promotion gate. + The old v0 consumer manifest remains readable and retains all existing fields. Default Markdown capture still emits v0; this PR neither rewrites stored heads nor auto-promotes a goal. The schema split is not permission to drop v0 @@ -585,6 +631,20 @@ not start a second independent operation while that handler may still be live. | Locking debt | PID liveness, token claims, stale reclaim, and replacement-resistant file identity make the shared lock safe across Python and Node. This bounded protocol is deleted after the handoff-mode transition and every remaining Python lease-lock holder move in-process. | | Out of scope | This cutover shares the ordinary lifecycle decision but does not implement #3669's shared-provider execution, CAS, or authority receipts, and does not promise exactly-once execution for a second request issued while the original Node handler is still running after a client timeout. | +#### Todo terminal lifecycle migration economics + +| Field | Receipt | +| --- | --- | +| Canonical owner | Before: Python owned terminal admission, successor derivation, and archive retention, while completion reduction and lease operations crossed narrower TS boundaries. After: `todo_terminal_decision.ts`, `todo_successor_derivation.ts`, `todo_terminal_lifecycle.ts`, and `todo_archive_selection.ts` are the typed owners of terminal admission, successor defaults/inheritance/bindings, lease release, completion reduction, CAS, receipt replay, and archive selection. The terminal transaction imports the successor and archive owners directly; legacy Markdown/event writers call their strict wire handlers and only materialize the returned proposal. | +| Legacy semantic code deleted | 284 Python product LOC are removed from semantic ownership: 74 lines for terminal decision plus archive eligibility/order/standing-receipt selection, and 210 lines of duplicated successor priority, capability/binding, exclusion, continuation, and predecessor-link derivation across Markdown complete/supersede and event completion. The remaining Python complete/supersede bodies are unpromoted compatibility writers, not a second terminal decision owner. Other deleted lines are adapter reshaping and moves and are not counted as payoff. | +| Bridge code added | 937 gross product LOC are classified as bounded transport/compatibility: the 538-line `provider_terminal_lifecycle.py`, 135-line successor intent/result adapter, 173-line local TS request decoder/router delta, 33-line legacy archive result adapter, 10 handler-registration lines, 6 projection-settlement lines, and 42 lines that route Turn durable readback to canonical authority after promotion. The 29-line `resolve_todo_state_path` extraction is a move, not payoff. Host-local validation declaration storage/execution is a retained external effect and is not mislabeled as bridge deletion. | +| Successor ownership | The public caller owns requested successor text and options. Python serializes that intent and adapts the typed proposal to the legacy writer. TypeScript alone derives inherited priority, default task class, capability binding, user binding, exclusions, same-agent continuity, and `unblocks_todo_id`; the promoted lifecycle derives and validates these facts inside the same provider transaction before atomically committing the target, successors, lease, and receipt. The legacy and event paths invoke the same pure TS decision through one effect-runtime call. | +| Cross-runtime calls | Measured at the public facade: promoted complete without validation, supersede, and archive use three request/responses (`todo_list`, terminal/archive transaction, projection readback). A validated complete uses four (`todo_list`, terminal preflight, terminal finalization, projection readback) plus one declared host-local validation effect. After an injected post-commit projection crash, the first attempt uses two calls and the receipt replay uses three. The promoted happy path did not exist before this cutover; a legacy terminal call uses the existing TS admission decision and adds one coarse successor-derivation call only when it has generated successor intent. | +| Product-code net change | Final merge-base classification is +4,051/−364 product LOC, net +3,687; tests/fixtures/examples are +3,594/−156, net +3,438; generated contracts are +3/−0 and docs are excluded. The increase delivers a complete provider-neutral transaction, one successor semantic owner, real File/PostgreSQL conformance, public facade parity, and durable mutation gates; it is not counted as deletion payoff. | +| Migration scaffolding | The production-scale fixture, three-arm rehearsal, provider conformance, public legacy/promoted parity matrix, and mutation cases remain because they express durable migration contracts. The compatibility facade and its call-count assertions exit with the facade; provider-neutral transaction and archive-order mutation coverage remain. | +| Facade exit | Delete `provider_terminal_lifecycle.py`, the successor wire adapter, their Python call-count tests, and `resolve_todo_state_path` after the top-level Todo CLI runs in-process TypeScript, registry/lifecycle facts arrive through the native caller, validation is executed by a native host adapter, and compatibility consumers drain the canonical journal directly. Delete the legacy successor-derivation and archive-selection crossings when the default Markdown/event writers migrate. | +| Correctness evidence | Independent archive-order/standing-receipt semantics kill an oldest-selection mutant; optional `note`/`evidence`/`reason` cover `None`, empty, ordinary, Python-Unicode-whitespace-only, and whitespace compaction before/after promotion. Public-entry tests prove canonical commit followed by Turn-journal crash/retry settles once, logical retry tolerates prose changes but rejects different successor intent, rejected/concurrent/crash-recovered validated create publishes only the accepted digest sidecar, and legacy/promoted illegal actors retain a domain-rejection class. Independent successor tests pin priority, binding, exclusion, continuation, and predecessor-link inheritance. Stage 2C proves provider-first fence routing, live-lease import, orphan-history filtering, management-lock exclusion, replay, and zero-write previews. File and real PostgreSQL providers execute the same terminal conformance, while the independent legacy arm remains mandatory compatibility evidence. | + The monitor-poll cutover removes the Python admission-policy and monitor-target modules and the Python event/replay/artifact writer. Its bounded facade exits when quota `should-run`, Todo monitor persistence, status projection, and the diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 4eda63f82e..792e5a3cf0 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -3,7 +3,7 @@ - Status:Accepted,transaction-payoff 阶段进行中 - Proposed by:LoopX maintainers - Date:2026-08-15 -- Last revised:2026-09-05 +- Last revised:2026-09-07 - Scope:LoopX 控制面核心从 Python 到 TypeScript 的增量、replacement-first 迁移;不长期维护两份语义实现 - Tracking issue:[#3225](https://github.com/huangruiteng/loopx/issues/3225) @@ -53,7 +53,7 @@ preview 不消耗该 id。legacy 模式会拒绝此选项,不写入也不自 选项即可保持默认行为。历史 replay 不授予 lease 或当前所有权,claim/lease 联合 获取仍是后续工作。 -下一 replacement slice 让 promotion 后的 `todo add` 成为同一 authority owner 上的 +Promotion 后的 `todo add` 已成为同一 authority owner 上的 原生 create transaction。Python 只校验既有 CLI 参数并一次性适配为带版本的 domain record;语义重复、replay、actor/owner 资格、CAS、receipt 和 projection-outbox mutation 都由 TypeScript 持有。preview 与真实 subprocess CLI 路径会先删除 Markdown @@ -61,6 +61,41 @@ state file 再验证,因此 promotion 不会悄悄恢复 Markdown 写入。com argv 保持 typed data,不退回 shell 编码的兼容字段。未 promotion 的默认 goal 在显式 promotion 边界前继续使用既有 Markdown transaction。 +terminal-lifecycle stage package 将该边界扩展到 promotion 后的 `complete`、 +`supersede` 与按 role 执行的 `archive`。TypeScript 持有 admission、claim/lease fence、 +successor 校验、completion-policy reduction、CAS、receipt、projection intent 与归档 +选择;Python 只投影 registry fact,在两次 typed reduction 之间执行显式声明的 +validation effect,并 drain 兼容投影,不再为 promoted goal 重建 terminal state +machine。canonical Todo 只保存 validation-required marker 与声明摘要;raw argv 留在 +权限为 0600 的 host-local sidecar,恢复时必须先匹配摘要,才允许执行 effect。导入的 +v0 Todo 继续按旧 `index` 归档;provider-native record 按持久 completion/update 时间 +和 Todo identity 排序。当前 Todo graph 中已经不存在的历史 lease file 继续作为审计 +历史保留,不再投影回 canonical live head。 + +所有非 preview 的 terminal/archive 入口都会取得 bootstrap 与 rollback 共用的逐 goal +shadow-maintenance mutex,并在打开 canonical provider 之前重新检查持久 management +state。因此 lifecycle write 不能与 bootstrap/rollback transition 重叠,也不能绕过其 +write hold。durable promotion fence 已存在但 canonical head 缺失时,调用方收到 typed +canonical-authority outage 和明确的“恢复后再重试”动作;该错误不会被重新包装成 legacy +writer fence,也绝不授权回退 Markdown。 + +该阶段使用同一份只读、生产复杂度快照做三臂资格验证:不可变 legacy baseline clone、 +隔离 file provider、隔离的真实 PostgreSQL provider。两个 provider head 必须精确相等; +legacy 臂按显式 compatibility projection 比较。归档时仅从 legacy hot view 排除 +provider 保留的 archive 记录及其历史 lease,并且只有先证明每个 role 的相对顺序完全 +一致,才可忽略导入 `index` 的绝对值;domain 字段、归档选择、active lease 与非目标 +记录不得归一化,源快照必须不变。可复现命令位于 +`examples/control_plane/authority-three-arm-rehearsal.py`。同时提供确定性、public-safe +的规模 fixture,让每个 provider suite +覆盖相同的 status 组合、当前/已退役 lease、standing decision、validation、successor、 +replay、concurrency、归档压力与 hard-lease fence。该 fixture 是持久回归覆盖,不能替代 +对当前状态的只读三臂演练。 + +从该 checkpoint 起,凡声称推进本 RFC 的 PR,都必须遵守 +[production-scale fixture 维护契约](../../development/testing-and-quality.md#production-scale-fixture-stewardship--生产规模-fixture-维护契约): +声明 fixture 影响、覆盖所有受影响的 provider arm,并把只读三臂演练保留为独立的 +promotion gate。 + 旧 v0 consumer manifest 继续可读,并保留所有已有字段。默认 Markdown capture 仍 输出 v0;本 PR 不改写已存 head,也不自动晋升 goal。schema 分层不等于允许后续迁移 丢失 v0 provenance 或改变旧排序。 @@ -486,6 +521,20 @@ exactly-once 保证;原 handler 可能仍存活时,caller 不得启动第二 | 锁迁移债务 | PID liveness、token claim、stale reclaim 与抗替换文件身份使 Python/Node 共享锁可安全恢复。handoff-mode transition 与所有剩余 Python lease-lock holder 进程内迁移后,删除这层有界协议。 | | 非目标 | 本次 cutover 共享 ordinary lifecycle decision,但不实现 #3669 的 shared-provider execution、CAS 或 authority receipt;也不承诺 client timeout 后原 Node handler 仍运行时,第二个请求具备 exactly-once execution。 | +#### Todo terminal lifecycle 迁移经济账 + +| 字段 | 回执 | +| --- | --- | +| Canonical owner | 迁移前,Python 持有 terminal admission、successor derivation 与 archive retention,completion reduction 和 lease operation 则跨越更窄的 TS 边界。迁移后,`todo_terminal_decision.ts`、`todo_successor_derivation.ts`、`todo_terminal_lifecycle.ts` 与 `todo_archive_selection.ts` 成为 terminal admission、successor 默认值/继承/绑定、lease release、completion reduction、CAS、receipt replay 与 archive selection 的 typed owner。Terminal transaction 直接 import successor 与 archive owner;legacy Markdown/event writer 只调用其严格 wire handler 并物化返回 proposal。 | +| 删除的旧语义代码 | 从 Python 语义 ownership 删除 284 行产品代码:74 行 terminal decision 与 archive eligibility/order/standing-receipt selection,加上 Markdown complete/supersede 和 event completion 三条路径中重复的 210 行 successor priority、capability/binding、exclusion、continuation 与 predecessor-link derivation。其余 Python complete/supersede body 是未 promotion 路径的 compatibility writer,不是第二个 terminal decision owner。其他删除属于 adapter reshaping 或搬移,不计为 payoff。 | +| 新增的 bridge 代码 | 有界 transport/compatibility 共 937 行 gross 产品代码:538 行 `provider_terminal_lifecycle.py`、135 行 successor intent/result adapter、173 行 local TS request decoder/router 增量、33 行 legacy archive result adapter、10 行 handler registration、6 行 projection settlement,以及 42 行 promotion 后将 Turn durable readback 指向 canonical authority 的路由。29 行 `resolve_todo_state_path` extraction 是搬移,不是收益。Host-local validation declaration 的存储与执行是保留的 external effect,不冒充已删除 bridge。 | +| Successor ownership | Public caller 持有请求的 successor text 与 option。Python 仅序列化 intent,并把 typed proposal 适配给 legacy writer。只有 TypeScript 推导继承 priority、默认 task class、capability binding、user binding、exclusion、same-agent continuity 与 `unblocks_todo_id`;promotion 后 lifecycle 在同一 provider transaction 内完成推导和校验,再原子提交 target、successor、lease 与 receipt。Legacy 与 event 路径通过一次 effect-runtime 调用复用同一纯 TS 决策。 | +| 跨 runtime 调用 | 从 public facade 实测:promotion 后无 validation 的 complete、supersede 与 archive 均为三次 request/response(`todo_list`、terminal/archive transaction、projection readback)。带 validation 的 complete 为四次(`todo_list`、terminal preflight、terminal finalization、projection readback),另执行一次声明式 host-local validation effect。注入 post-commit projection crash 后,首次尝试为两次调用,receipt replay 为三次。该 cutover 前不存在合法 promoted happy path;legacy terminal 调用沿用既有 TS admission decision,仅在存在 generated successor intent 时增加一次 coarse successor-derivation 调用。 | +| 产品代码净增减 | 最终 merge-base 分类为产品代码 +4,051/−364,净增 3,687;test/fixture/example 为 +3,594/−156,净增 3,438;generated contract 为 +3/−0,docs 不计入。该增长交付完整 provider-neutral transaction、单一 successor semantic owner、真实 File/PostgreSQL conformance、public facade parity 与持久 mutation gate,不记作 deletion payoff。 | +| 迁移 scaffolding | Production-scale fixture、三臂演练、provider conformance、public legacy/promoted parity matrix 与 mutation case 因表达持久迁移 contract 而保留。Compatibility facade 及其 call-count assertion 随 facade 退出;provider-neutral transaction 与 archive-order mutation coverage 保留。 | +| Facade 退出 | 顶层 Todo CLI 在进程内执行 TypeScript、registry/lifecycle fact 由 native caller 提供、validation 由 native host adapter 执行、compatibility consumer 直接 drain canonical journal 后,删除 `provider_terminal_lifecycle.py`、successor wire adapter、Python call-count test 与 `resolve_todo_state_path`。默认 Markdown/event writer 迁移后,删除 legacy successor-derivation 与 archive-selection crossing。 | +| 正确性证据 | 独立 archive order/standing receipt 语义可以 kill oldest-selection mutant;可选 `note`/`evidence`/`reason` 覆盖 promotion 前后的 `None`、empty、ordinary、Python Unicode 纯空白与空白压缩。Public-entry 测试证明 canonical commit 后 Turn-journal 崩溃重试只结算一次;logical retry 允许说明文本变化但拒绝不同 successor intent;validated create 在拒绝、并发与 canonical commit 后崩溃恢复时只发布 accepted digest sidecar;非法 actor 在 legacy/promoted 下都保持领域拒绝分类。独立 successor 测试钉住 priority、binding、exclusion、continuation 与 predecessor-link inheritance。Stage 2C 证明 provider-first fence routing、live-lease import、orphan-history filtering、management-lock exclusion、replay 与 zero-write preview。File 与真实 PostgreSQL provider 执行同一 terminal conformance,独立 legacy 臂仍是强制 compatibility evidence。 | + Monitor-poll cutover 删除了 Python admission-policy、monitor-target module,以及 Python event/replay/artifact writer。它的 bounded facade 会在 quota `should-run`、 Todo monitor persistence、status projection 与剩余 run-index writer 都进入原生 diff --git a/docs/development/contributor-tasks.md b/docs/development/contributor-tasks.md index 31aca339ad..c43ce9aec8 100644 --- a/docs/development/contributor-tasks.md +++ b/docs/development/contributor-tasks.md @@ -155,6 +155,7 @@ for contributors who can run local CLI smokes and keep changes scoped. | GH-C88 | cli | Implement one budget-aware CLI output ergonomics slice for #2881: shorter default summaries with a typed `--json` escape hatch on one command family, keeping hot-path payload budgets and differential allowances intact. | `python3 examples/control_plane/cli-output-budget-regression-smoke.py`, focused command smoke, and `loopx check --scan-path docs/status-data-contract.md --scan-path docs/development/contributor-tasks.md` | | GH-C70 | runtime | Claimed: PR #3664 narrows host-loop parity to one producer-generated bounded-wait scheduler-hint contract between the external scheduler worker and Pi: both real consumers must produce the same provider-neutral stop/wait plan, including the final quota/replan recheck triggered by the third unchanged poll. | `python3 -m pytest -q tests/test_host_loop_runtime_parity.py tests/test_external_scheduler_worker.py tests/test_pi_goal_mode.py`, `node --test tests/pi_goal_loop_runtime.test.mjs`, `python3 examples/external-scheduler-worker-smoke.py`, and `loopx check --scan-path docs/integrations/runtime-connector-catalog.md --scan-path docs/development/contributor-tasks.md` | | GH-C100 | state | Characterize the shipped file-backed `claim_work` executor with a provider-neutral parity fixture (#3700): same-target competition has exactly one winner, independent targets rebase, replay returns the original receipt, same-operation-id with different command semantics is rejected with no mutation, and stale provider generation does not duplicate transitions. Keep fixtures synthetic and public-safe. | `python3 -m pytest -q tests/control_plane/test_coordination_executor.py tests/control_plane/test_coordination_file_provider.py`, the new parity fixture, and `loopx check --scan-path loopx/control_plane/coordination --scan-path docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md --scan-path docs/development/contributor-tasks.md` | +| GH-C102 | state / tests | Extend the shared production-scale coordination fixture with one accepted RFC invariant or reproduced public regression that its current envelope does not cover. Update the checked-in envelope, shared generator, and an independent negative or mutation-style assertion; run the same dimension through every affected provider conformance arm. Keep the data deterministic and public-safe, do not copy a production snapshot, and do not weaken an existing dimension merely to make a provider pass. | `npm run test:control-plane`; when a provider-backed arm changes, run its isolated real integration suite (for PostgreSQL, `LOOPX_TEST_POSTGRES_URL="$DISPOSABLE_POSTGRES_URL" npm run test:postgresql-authority-store`); `loopx check --scan-path tests/fixtures/control_plane --scan-path tests/control_plane_ts --scan-path docs/development/testing-and-quality.md` | ### Advanced Implementation diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 8cbfab192e..2be7b9a472 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -172,6 +172,58 @@ failures or limits. If no safe real environment is available, hold delivery. `LOOPX_TEST_POSTGRES_URL`,运行 `npm run test:postgresql-authority-store`;跳过不算 通过。记录精确 commit、后端版本、验证行为与失败或局限;没有安全的真实环境则暂停交付。 +When one semantic owner fronts multiple authority providers, use a three-arm +refactor comparison: the immutable legacy baseline, the file provider, and a +real PostgreSQL provider. All three arms must start from the same +production-complex snapshot and execute the same public operations. Compare +the two provider heads exactly, then compare the baseline semantically after +the declared compatibility projection. The allowed archive normalization is +narrow: omit provider-retained `archive_state=archive` records from the legacy +hot view, omit their historical leases from that hot view, and ignore absolute +imported `index` values only after separately proving identical per-role +relative order. Provider provenance may also differ. No domain field, archive +selection, relative order, active lease, or non-target record may be normalized. +File/PostgreSQL agreement alone is not compatibility evidence because both +providers execute the same new rule. Verify that every non-target record and +the source snapshot are unchanged. + +The source snapshot's `lease_inventory` is byte-level audit evidence for every +legacy lease file observed under the source locks. It is intentionally broader +than canonical `projection.leases`, which contains only live edges whose Todo is +present in the current canonical graph. Historical orphan lease files remain in +the source inventory and never enter the canonical head. + +当同一个 semantic owner 服务多个 authority provider 时,重构对照必须包含三臂: +不可变 legacy baseline、file provider、真实 PostgreSQL provider。三臂从同一份生产 +复杂度快照出发,执行相同 public operation;两个 provider head 要精确相等,baseline +按显式 compatibility projection 比较。归档场景只允许三项窄归一化:legacy hot view +不包含 provider 保留的 `archive_state=archive` 记录、不包含这些记录的历史 lease;并且 +只有先单独证明每个 role 的相对顺序完全一致,才可忽略导入 `index` 的绝对值。provider +provenance 也可不同。domain 字段、归档选择、相对顺序、active lease 和非目标记录均 +不得归一化。File/PostgreSQL 一致不能单独证明兼容,因为它们执行的是同一套新规则。 +还要验证所有非目标记录与源快照保持不变。 + +源快照中的 `lease_inventory` 是在 source lock 下观察到的全部 legacy lease 文件的 +字节级审计证据;它有意比 canonical `projection.leases` 更宽。后者只包含当前 canonical +Todo 图中仍有对应 Todo 的 live edge。历史 orphan lease 文件继续留在 source inventory, +但绝不进入 canonical head。 + +The archive rehearsal is executable and emits only bounded counts and digest +prefixes. It never prints raw projections, Todo identifiers, source paths, or +the PostgreSQL URL. The URL must name a disposable isolated server: + +```bash +LOOPX_TEST_POSTGRES_URL="$DISPOSABLE_POSTGRES_URL" \ +python examples/control_plane/authority-three-arm-rehearsal.py \ + --registry "$REGISTRY_PATH" \ + --goal-id "$GOAL_ID" \ + --execute-isolated-postgresql +``` + +归档三臂演练可直接执行,且只输出有界计数和 digest 前缀;不输出 raw projection、 +Todo 标识、源路径或 PostgreSQL URL。URL 必须指向一次性隔离服务。命令中的显式 +`--execute-isolated-postgresql` 只是安全确认,不会授权连接共享或生产数据库。 + Keep tests separate from active state: use a disposable database/tenant and runtime directory, with synthetic fixtures or an owner-authorized read-only snapshot. Never run the integration suite against a shared or production @@ -188,6 +240,64 @@ not overwritten or restored by the test. Stop the temporary server afterward. lease。私有快照和原始输出不得进入 Git 或公开 review;快照演练前后比较源指纹。 发现并发源变更只报告,不擅自覆盖或恢复。测试后停止临时数据库。 +Keep a deterministic, public-safe production-scale fixture beside the focused +cases. Its envelope should cover realistic role/status distributions, +multi-agent claims, user gates and standing decisions, current and retired +leases, successor links, validation markers, archival pressure, and enough +history to exercise ordering and capacity-sensitive paths. Generate content +from public-safe seeds rather than copying production text or identifiers, and +run the same fixture through every provider conformance suite. This fixture is +a durable regression layer; it complements, but never substitutes for, the +read-only three-arm rehearsal against current production-complex state. + +在聚焦用例之外,长期保留确定性、public-safe 的生产规模 fixture。其 envelope 应覆盖 +真实的 role/status 分布、多 Agent claim、User gate 与 standing decision、当前与已退役 +lease、successor link、validation marker、归档压力,以及足以触发顺序和容量敏感路径的 +历史规模。内容必须由公开安全的 seed 生成,不复制生产文本或标识;同一 fixture 要进入 +所有 provider conformance suite。它是持久回归层,只补充、不替代针对当前生产复杂状态 +的只读三臂演练。 + +### Production-scale fixture stewardship / 生产规模 fixture 维护契约 + +Treat `tests/fixtures/control_plane/coordination_production_scale_v0.json` +and its generator as a shared acceptance input for both the TypeScript +control-plane migration and shared-goal-authority RFCs. A pull request that +changes provider-neutral fields, coordination semantics, or capacity and +retention assumptions must carry a fixture impact declaration: extend the +fixture and an independently derived assertion through every affected provider +arm, or state why the existing dimensions fully cover the change. Storage-only +provider work may use the unchanged fixture, but still runs the affected arm. +Runtime routing, promotion, or compatibility work also runs the read-only +three-arm rehearsal; the fixture never upgrades synthetic agreement into live +promotion evidence. + +Fixture improvements are welcome when they encode an accepted RFC invariant or +a reproduced public regression that the current envelope misses. Keep each +addition deterministic, bounded, and public-safe; derive expected behavior +from the invariant rather than the generator output. Add at least one negative +or mutation-style assertion that would fail if the new dimension were ignored, +reuse the same envelope and generator across providers, and do not weaken or +remove an existing dimension without a reviewed compatibility reason. Never +copy production text, identifiers, paths, logs, credentials, or private +snapshots into the fixture. Report the fixture schema, semantic dimension, +provider arms, and intentional deltas in the PR validation evidence. + +将 `tests/fixtures/control_plane/coordination_production_scale_v0.json` 及其 +generator 视为 TypeScript control-plane migration 与 shared-goal-authority 两份 RFC +共用的验收输入。修改 provider-neutral field、coordination 语义,或容量/保留假设的 PR, +必须附带 fixture 影响声明:扩展 fixture 与独立推导的断言,并让它通过所有受影响的 +provider arm;或者说明现有维度为何已经完整覆盖该改动。仅修改 provider 物理存储时可以 +复用未变化的 fixture,但仍要运行受影响的 arm。涉及 runtime routing、promotion 或 +compatibility 的工作还要执行只读三臂演练;fixture 绝不能把合成数据一致性升级为真实 +promotion 证据。 + +欢迎开发者把已接受的 RFC invariant 或已复现、但当前 envelope 尚未覆盖的公共回归沉淀 +进 fixture。每次增强都要保持确定性、有界且 public-safe;expected behavior 必须从 +invariant 独立推导,不能从 generator 当前输出反推。至少增加一个在忽略新维度时会失败的 +negative 或 mutation-style 断言,并让各 provider 复用同一 envelope 和 generator;没有 +经 review 的兼容理由,不得削弱或删除已有维度。禁止复制生产文本、标识、路径、日志、 +凭据或私有快照。PR 验证证据需报告 fixture schema、语义维度、provider arms 与有意差异。 + Install the test dependencies once: ```bash diff --git a/examples/control_plane/authority-three-arm-rehearsal.py b/examples/control_plane/authority-three-arm-rehearsal.py new file mode 100644 index 0000000000..475b938ebe --- /dev/null +++ b/examples/control_plane/authority-three-arm-rehearsal.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +"""Compare legacy, file, and PostgreSQL archive semantics from one live snapshot. + +The selected Goal is read only. All mutations happen in a temporary Markdown +clone, an isolated file store, and a caller-supplied disposable PostgreSQL +tenant. Output contains bounded counts and digest prefixes, never Todo text, +identifiers, paths, connection strings, or raw projections. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +REPOSITORY = Path(__file__).resolve().parents[2] +if str(REPOSITORY) not in sys.path: + sys.path.insert(0, str(REPOSITORY)) + +from loopx.control_plane.coordination.runtime_shadow import ( # noqa: E402 + build_runtime_shadow_source_snapshot, +) +from loopx.history import load_registry # noqa: E402 +from loopx.paths import resolve_runtime_root # noqa: E402 +from loopx.state_refresh import resolve_goal_state # noqa: E402 +from loopx.todos import archive_completed_todos # noqa: E402 + + +NODE_REHEARSAL = r""" +import assert from 'node:assert/strict'; +import {createHash, randomUUID} from 'node:crypto'; +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {Pool} from 'pg'; +import {FileAuthorityStore} from '__FILE_STORE__'; +import {PostgreSqlAuthorityStore, installPostgreSqlAuthorityStoreSchema} from '__PG_STORE__'; +import {executeCoordinationTodoArchiveCompleted} from '__TERMINAL__'; +import {canonicalAuthorityBytes} from '__CODEC__'; + +let input = ''; +for await (const chunk of process.stdin) input += chunk; +const request = JSON.parse(input); +const requestedTodoIds = new Set(request.initial.todos.map((todo) => todo.todo_id)); +assert.equal( + request.initial.leases.filter((lease) => !requestedTodoIds.has(lease.todo_id)).length, + 0, + 'input projection has orphan leases', +); +const digest = (value) => createHash('sha256') + .update(canonicalAuthorityBytes(value)).digest('hex'); +const archiveFailureCategory = (result) => { + const reason = String(result.reason ?? ''); + for (const [marker, category] of [ + ['lease references an unknown todo', 'orphan_lease'], + ['deterministic todo_id order', 'todo_order'], + ['read-model schema mismatch', 'read_model_schema'], + ['read-model count mismatch', 'read_model_count'], + ['read-model digest mismatch', 'read_model_digest'], + ['read-model field contract mismatch', 'read_model_contract'], + ['unversioned fields', 'unversioned_todo_fields'], + ['omits required fields', 'missing_todo_fields'], + ['invalid required semantics', 'invalid_todo_semantics'], + ]) { + if (reason.includes(marker)) return category; + } + return 'unclassified'; +}; +const semanticTodo = (todo) => { + const result = {...todo}; + delete result.index; + return result; +}; +const pool = new Pool({connectionString: process.env.LOOPX_TEST_POSTGRES_URL, max: 4}); +const database = {connect: async () => { + const client = await pool.connect(); + return { + query: async (text, values) => await client.query(text, values), + release: (error) => client.release(error), + }; +}}; +await installPostgreSqlAuthorityStoreSchema( + database, + `postgresql:${'b'.repeat(32)}`, +); +const tenant = `three-arm-${randomUUID()}`; +const fileRoot = await mkdtemp(join(tmpdir(), 'loopx-three-arm-file-')); +const stores = [ + ['file', new FileAuthorityStore(fileRoot, request.goal_id)], + ['postgresql', new PostgreSqlAuthorityStore(database, { + tenant_id: tenant, + goal_id: request.goal_id, + })], +]; +const results = {}; +try { + for (const [name, store] of stores) { + const initialized = await store.commitAuthority({ + expected_provider_revision: null, + operation_id: `${name}-three-arm-initialize`, + events: [], + receipts: [], + next_projection: request.initial, + }); + assert.equal(initialized.status, 'applied', `${name} initialization failed`); + const archived = await executeCoordinationTodoArchiveCompleted(store, { + goal_id: request.goal_id, + role: request.role, + max_active_done: request.max_active_done, + operation_id: `${name}-three-arm-archive`, + dry_run: false, + now: new Date('2026-01-01T00:00:00Z'), + }); + assert.equal( + archived.status, + 'applied', + `${name} archive failed (${String(archived.reason_code ?? 'unknown')}:` + + `${archiveFailureCategory(archived)})`, + ); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, 'loaded', `${name} readback failed`); + const receipt = await store.readReceipt(`${name}-three-arm-archive`); + assert.equal(receipt.status, 'found', `${name} receipt missing`); + results[name] = {archived, head: loaded.head}; + } + + assert.equal( + digest(results.file.head), + digest(results.postgresql.head), + 'file and PostgreSQL heads differ', + ); + const initialById = new Map(request.initial.todos.map((todo) => [todo.todo_id, todo])); + const providerById = new Map(results.file.head.todos.map((todo) => [todo.todo_id, todo])); + const moved = results.file.head.todos.filter((todo) => + todo.archive_state === 'archive' && + initialById.get(todo.todo_id)?.archive_state !== 'archive'); + const movedIds = new Set(moved.map((todo) => todo.todo_id)); + const legacyIds = new Set(request.legacy.todos.map((todo) => todo.todo_id)); + const removedByLegacy = request.initial.todos + .filter((todo) => !legacyIds.has(todo.todo_id)) + .map((todo) => todo.todo_id) + .sort(); + assert.equal( + digest([...movedIds].sort()), + digest(removedByLegacy), + 'legacy and provider archive selection differs', + ); + assert.equal(moved.length, request.legacy_moved_count, 'archive counts differ'); + + const initialUntouched = request.initial.todos + .filter((todo) => !movedIds.has(todo.todo_id)) + .map(semanticTodo); + const providerUntouched = results.file.head.todos + .filter((todo) => !movedIds.has(todo.todo_id)) + .map(semanticTodo); + assert.equal( + digest(initialUntouched), + digest(providerUntouched), + 'non-target Todo semantics changed', + ); + assert.equal( + digest(results.file.head.leases), + digest(request.initial.leases), + 'provider archive changed lease history', + ); + + // Legacy Markdown moves archived blocks outside its hot projection and + // compacts the remaining display indexes. Provider heads retain archived + // records and stable imported indexes. Compare active domain records without + // absolute display ordinals, then prove the per-role relative order itself. + const activeTodos = results.file.head.todos.filter((todo) => + todo.archive_state === 'active'); + const activeIds = new Set(activeTodos.map((todo) => todo.todo_id)); + const activeLeases = results.file.head.leases.filter((lease) => + activeIds.has(lease.todo_id)); + assert.equal( + digest(activeTodos.map(semanticTodo)), + digest(request.legacy.todos.map(semanticTodo)), + 'legacy and provider active Todo semantics differ', + ); + assert.equal( + digest(activeLeases), + digest(request.legacy.leases), + 'legacy and provider active lease semantics differ', + ); + for (const role of ['agent', 'user']) { + const order = (todos) => todos + .filter((todo) => todo.role === role) + .sort((left, right) => Number(left.index) - Number(right.index)) + .map((todo) => todo.todo_id); + assert.equal( + digest(order(activeTodos)), + digest(order(request.legacy.todos)), + `${role} relative order differs`, + ); + } + const movedDigest = createHash('sha256') + .update([...movedIds].sort().join('\n')) + .digest('hex'); + process.stdout.write(JSON.stringify({ + schema_version: 'loopx_authority_three_arm_rehearsal_result_v0', + status: 'passed', + todo_count: request.initial.todos.length, + lease_count: request.initial.leases.length, + moved_count: moved.length, + active_todo_count_after: activeTodos.length, + active_lease_count_after: activeLeases.length, + moved_ids_sha256_prefix: movedDigest.slice(0, 16), + provider_heads_exact: true, + legacy_active_semantics_exact: true, + relative_order_exact: true, + non_target_semantics_unchanged: true, + provider_receipts_found: true, + })); +} finally { + for (const table of [ + 'authority_receipts', + 'authority_events', + 'authority_commits', + 'authority_heads', + ]) { + await pool.query( + `DELETE FROM loopx_control_plane.${table} WHERE tenant_id=$1 AND goal_id=$2`, + [tenant, request.goal_id], + ); + } + await pool.end(); + await rm(fileRoot, {recursive: true, force: true}); +} +""" + + +def _module_uri(repository: Path, relative: str) -> str: + return (repository / relative).resolve().as_uri() + + +def _node_script(repository: Path) -> str: + return ( + NODE_REHEARSAL.replace( + "__FILE_STORE__", + _module_uri( + repository, + "loopx/control_plane/coordination/file_authority_store.ts", + ), + ) + .replace( + "__PG_STORE__", + _module_uri( + repository, + "loopx/control_plane/coordination/postgresql_authority_store.ts", + ), + ) + .replace( + "__TERMINAL__", + _module_uri( + repository, + "loopx/control_plane/coordination/todo_terminal_lifecycle.ts", + ), + ) + .replace( + "__CODEC__", + _module_uri( + repository, + "loopx/control_plane/coordination/authority_store_codec.ts", + ), + ) + ) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--registry", type=Path, required=True) + parser.add_argument("--goal-id", required=True) + parser.add_argument("--role", choices=("agent", "user"), default="agent") + parser.add_argument("--max-active-done", type=int, default=5) + parser.add_argument( + "--execute-isolated-postgresql", + action="store_true", + help="acknowledge that LOOPX_TEST_POSTGRES_URL names a disposable server", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + if not args.execute_isolated_postgresql: + raise SystemExit("--execute-isolated-postgresql is required") + if not os.environ.get("LOOPX_TEST_POSTGRES_URL"): + raise SystemExit("LOOPX_TEST_POSTGRES_URL is required") + if args.max_active_done < 0: + raise SystemExit("--max-active-done must be non-negative") + + registry_path = args.registry.expanduser().resolve() + registry = load_registry(registry_path) + goal = next( + (item for item in registry.get("goals", []) if item.get("id") == args.goal_id), + None, + ) + if goal is None: + raise SystemExit("goal is not registered") + runtime_root = resolve_runtime_root(registry, None, registry_path=registry_path) + _, _, state_path = resolve_goal_state( + registry=registry, + goal_id=args.goal_id, + project_override=None, + state_file_override=None, + ) + source_bytes = state_path.read_bytes() + initial, source_snapshot = build_runtime_shadow_source_snapshot( + goal=goal, + runtime_root=runtime_root, + state_path=state_path, + registry_path=registry_path, + ) + # The live source arm is a point-in-time input. Keep it detached from any + # compatibility code exercised by the cloned legacy arm below. + initial = copy.deepcopy(initial) + + with tempfile.TemporaryDirectory(prefix="loopx-three-arm-legacy-") as temporary: + root = Path(temporary) + project = root / "project" + clone_runtime = root / "runtime" + clone_state = project / "ACTIVE_GOAL_STATE.md" + project.mkdir() + clone_state.write_bytes(source_bytes) + source_leases = runtime_root / "goals" / args.goal_id / "task-leases" + if source_leases.exists(): + shutil.copytree( + source_leases, + clone_runtime / "goals" / args.goal_id / "task-leases", + ) + clone_goal = copy.deepcopy(goal) + clone_goal["repo"] = str(project) + clone_goal["state_file"] = clone_state.name + clone_registry = { + "schema_version": registry.get("schema_version", 1), + "common_runtime_root": str(clone_runtime), + "goals": [clone_goal], + } + clone_registry_path = root / "registry.json" + clone_registry_path.write_text(json.dumps(clone_registry), encoding="utf-8") + legacy_result = archive_completed_todos( + registry_path=clone_registry_path, + goal_id=args.goal_id, + role=args.role, + max_active_done=args.max_active_done, + dry_run=False, + ) + if int(legacy_result.get("moved_count") or 0) < 1: + raise SystemExit("snapshot has no archive pressure for this rehearsal") + legacy, _ = build_runtime_shadow_source_snapshot( + goal=clone_goal, + runtime_root=clone_runtime, + state_path=clone_state, + registry_path=clone_registry_path, + ) + request: dict[str, Any] = { + "goal_id": args.goal_id, + "role": args.role, + "max_active_done": args.max_active_done, + "initial": initial, + "legacy": legacy, + "legacy_moved_count": legacy_result["moved_count"], + } + initial_todo_ids = {str(item["todo_id"]) for item in initial["todos"]} + initial_orphan_lease_count = sum( + 1 + for item in initial["leases"] + if str(item["todo_id"]) not in initial_todo_ids + ) + if initial_orphan_lease_count: + raise SystemExit( + "source projection contains " + f"{initial_orphan_lease_count} orphan leases; " + f"todos={len(initial['todos'])}, leases={len(initial['leases'])}; " + "three-arm rehearsal held" + ) + process = subprocess.run( + [ + "node", + "--no-warnings", + "--experimental-strip-types", + "--input-type=module", + "-e", + _node_script(REPOSITORY), + ], + cwd=REPOSITORY, + input=json.dumps(request, separators=(",", ":")), + capture_output=True, + text=True, + check=False, + timeout=180, + ) + if process.returncode != 0: + raise SystemExit(process.stderr.strip() or "three-arm provider rehearsal failed") + result = json.loads(process.stdout) + + # Detect concurrent live-state movement; never overwrite or restore it. + if state_path.read_bytes() != source_bytes: + raise SystemExit("source state changed concurrently; discard this rehearsal") + current, current_snapshot = build_runtime_shadow_source_snapshot( + goal=goal, + runtime_root=runtime_root, + state_path=state_path, + registry_path=registry_path, + ) + if current != initial or current_snapshot != source_snapshot: + raise SystemExit("source projection changed concurrently; discard this rehearsal") + result.update( + { + "source_unchanged": True, + "source_bytes_sha256_prefix": hashlib.sha256(source_bytes) + .hexdigest()[:16], + "source_projection_sha256_prefix": str( + source_snapshot["projection_sha256"] + )[:16], + } + ) + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/shared-goal-authority-e2e/correctness.md b/examples/shared-goal-authority-e2e/correctness.md index 4a66aaf632..24aa6ffd1e 100644 --- a/examples/shared-goal-authority-e2e/correctness.md +++ b/examples/shared-goal-authority-e2e/correctness.md @@ -144,20 +144,34 @@ settlement identity when the request carries `owner` and `idempotency_key` and `null` otherwise. Previews: `archive-completed` without `--execute` and `capture-followups ---dry-run` skip every fence check and write nothing. A terminal Todo preview -(`todo complete --dry-run`, `todo supersede --dry-run`) still enters the -native verify path, which now checks the fence before its first receipt, so -under a fence the preview reports the typed rejection with `dry_run: true` -and leaves nothing behind; without a fence it behaves as before. A fenced +--dry-run` write nothing. After promotion, public terminal/archive commands +route to canonical authority instead of treating the promotion fence as an +error. A hard-lease terminal preview therefore rejects with +`handoff_mode_requires_lease` when its target has no lease, succeeds for a +matching lease, and may preview the declared user-gate auto-acquire path; all +three leave the primary record and receipts unchanged. Legacy-only writers +such as `todo update` and `capture-followups --execute` remain fenced. A fenced committed releasing `fence_close` releases the caller's claimed mutation lock in `finally` while the lease stays `active` and its `held` receipt is untouched; the caller's fence token is spent, a retry reports `fence_token_invalid`, and recovery is lease expiry or a canonical release. +A promoted `archive-completed --execute` that selects no records is also a +provider no-op: it returns `no_change` without advancing the canonical cursor, +revision, or receipt history. Archive operation identity is bound to the +canonical source revision, so concurrent attempts over one snapshot share the +same logical operation. Projection recovery may create a missing machine-owned +User, Agent, or non-empty archive region next to an existing Todo region; it +still proves byte-for-byte preservation of all narrative outside those regions +and remains idempotent on replay. Empty successor intent is an identity at the +typed derivation boundary: it does not impose successor-only Agent registry or +completion-policy admission on an otherwise unchanged legacy terminal call. + The complete observable behaviour is pinned row by row in `tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json` -(21 TypeScript entry rows, 25 real-process CLI rows; whole-object envelopes, -exit status, exclusion-free effect snapshots, declared after-state) and +(21 TypeScript entry rows, 26 real-process CLI rows; whole-object legacy +envelopes, stable-field subsets for provider-first rows, exact exit status, +exclusion-free effect snapshots, and declared after-state) and enforced by `tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts` and `tests/control_plane/test_shadow_fence_caller_parity_e2e.py`; the `baseline` entries of that fixture document earlier revisions and are never @@ -176,6 +190,7 @@ reviewed head): | Python Todo rejections | no `error_code` | flat check keys, `schema_version` injected | `error_code` plus `write_check` | same | | terminal or holder verify under a fence | fence bypassed on the held and holder branches; auto-acquire branch not consulted | auto-acquire branch guarded; held branch previews reported `ok: true` and wrote receipts | every verify branch guarded before its first receipt | a preview must not succeed for a write the fence forbids | | committed releasing fence-close | fence bypassed | guarded | guarded; lock released in `finally`, retry `fence_token_invalid` | declared and pinned | +| public terminal/archive after promotion | legacy fence rejection | legacy fence rejection | canonical provider result; lease admission, preview, CAS, receipt and projection semantics come from the promoted authority | the fence blocks legacy writes; it is not a rejection of the canonical replacement path | | generic CLI branch of `task-lease` and `turn` | typed payload spread after envelope keys | same | payload first, envelope keys win | `LocalCoordinationAuthorityUnavailable` carries a `schema_version` | Known gap, unchanged on both revisions: `loopx quota monitor-poll --execute` @@ -246,7 +261,7 @@ separately without skip or relaxation flags: ```bash python -m pip install -e '.[test]' 'build==1.6.0' npm ci --ignore-scripts -python -m pytest -q -n 2 --dist loadfile -m stage2c_e2e --durations=20 --junitxml=stage2c-e2e.xml +python -m pytest -q -n 4 --dist loadfile -m stage2c_e2e --durations=20 --junitxml=stage2c-e2e.xml python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants python -m build python examples/shared-goal-authority-e2e/installed.py --artifact dist/*.whl --report-json .local/installed-wheel.json @@ -270,7 +285,7 @@ the complete installation sequence and retains normal source discovery. E2E, deliberate mutants, and independently installed wheel/sdist qualification run in separate jobs; the stable `stage2c-correctness-e2e` check requires all three to succeed. No path-based skipping or reduced case selection is used. E2E uses -two file-grouped workers so a module's shared workspaces and ordered parity rows +four file-grouped workers so a module's shared workspaces and ordered parity rows stay together. Each lane has a distinct evidence artifact. Mutants remain serial inside their isolated source copy: parallel edits to that copy would invalidate the control/mutant comparison. Single-row fence probes initialize only the diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index 5645614cc1..aab87183e2 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -8,18 +8,18 @@ import argparse import ast -from dataclasses import dataclass import difflib import hashlib import json import os -from pathlib import Path import re import shutil import subprocess import sys import tempfile from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path COORDINATION = "loopx/control_plane/coordination/" @@ -129,9 +129,20 @@ def remove_fence(source: str) -> str: return "".join(lines[:function.body[0].lineno - 1]) + " return\n" + "".join(lines[function.end_lineno:]) +def typescript_exported_function_span(source: str, name: str) -> tuple[int, int]: + """Bound one top-level exported function without depending on its successor.""" + + signature = f"export async function {name}(" + start = source.index(signature) + next_export = re.search(r"(?m)^export (?:async )?function ", source[start + len(signature):]) + end = len(source) if next_export is None else start + len(signature) + next_export.start() + return start, end + + def remove_native_update_maintenance(source: str) -> str: - start = source.index("export async function updateLocalCoordinationTodo(") - end = source.index("export async function editLocalCoordinationTodo(", start) + start, end = typescript_exported_function_span( + source, "updateLocalCoordinationTodo" + ) function = source[start:end] function = replacement( "return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => {", @@ -181,6 +192,10 @@ def apply(source: str) -> str: Case("native_update_maintenance", ((COORDINATION + "local_authority_runtime.ts", remove_native_update_maintenance),), "tests/control_plane/test_shadow_native_todo_update_e2e.py::test_native_update_holds_before_primary_for_management[native-bootstrapping-cli]"), + Case("archive_oldest_selection", ((COORDINATION + "todo_archive_selection.ts", replacement( + "movable.slice(0, moveCount)", "movable.slice(-moveCount)")),), + "tests/control_plane_ts/todo_archive_selection.test.ts", + "archive selection preserves imported order"), Case("remove_fence", ((COORDINATION + "legacy_writer_fence.py", remove_fence),), FENCE_TEST), Case("fence_outside_lock", ((COORDINATION + "legacy_writer_fence.py", move_guard_outside_lock("require_legacy_coordination_write_allowed")),), FENCE_TEST), @@ -216,7 +231,7 @@ def apply(source: str) -> str: if (left > right) return 1; return 0; });''', ".sort((left, right) => left.localeCompare(right));")),), - "tests/control_plane/test_runtime_shadow_bounded_e2e.py::test_source_snapshot_preserves_ordinal_mixed_case_lease_inventory"), + "tests/control_plane/test_runtime_shadow_bounded_e2e.py::test_source_snapshot_preserves_inventory_without_projecting_orphan_leases"), ]) # Restore both halves of the obsolete mirror: the public CLI hook and an actual @@ -259,7 +274,7 @@ def apply(source: str) -> str: (COORDINATION + "legacy_writer_fence.py", replacement( 'LEGACY_WRITER_FENCED_REMEDIATION = (\n "legacy coordination writer is fenced; use the promoted canonical authority "\n "({authority_mode}) for goal {goal_id}; fence {fence_id}; "\n "the primary record was not changed"\n)', 'LEGACY_WRITER_FENCED_REMEDIATION = "legacy coordination writer is fenced"')), -), "tests/control_plane/test_shadow_fence_caller_parity_e2e.py::test_fence_caller_parity[cli-todo_complete-engaged]")) +), "tests/control_plane/test_shadow_fence_caller_parity_e2e.py::test_fence_caller_parity[cli-todo_update_status-engaged]")) CASES.append(Case("fence_envelope_schema_leak", ( (COORDINATION + "legacy_writer_fence.ts", replacement( " this.payload = { write_check: writeCheck };", @@ -282,7 +297,7 @@ def run(case: Case, directory: Path, log: Path) -> subprocess.CompletedProcess[s if not key.startswith(("PYTHON", "LOOPX", "NODE", "COVERAGE"))} environment.update(PYTHONPATH=str(directory), PYTHONNOUSERSITE="1") result = subprocess.run(case.command(), cwd=directory, env=environment, - capture_output=True, text=True, timeout=120) + capture_output=True, text=True, timeout=120, check=False) log.write_text(result.stdout + result.stderr, encoding="utf-8") return result diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 6b8eb8baa2..a2057bfa72 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -481,6 +481,7 @@ def completion_intent(_result: dict[str, object]) -> dict[str, object]: todo_id=todo_id, registry_path=registry_path, goal_id=args.goal_id, + runtime_root=runtime_root, ) return project_durable_completion_intent( todo=durable_todo, @@ -531,6 +532,7 @@ def todo_completion( todo_id=todo_id, registry_path=registry_path, goal_id=args.goal_id, + runtime_root=runtime_root, ) completion_outcome = project_durable_completion_outcome( todo=durable_todo, @@ -675,6 +677,7 @@ def completion_readback() -> dict[str, object] | None: todo_id=todo_id, registry_path=registry_path, goal_id=args.goal_id, + runtime_root=runtime_root, ) return project_durable_completion_outcome( todo=durable_todo, @@ -703,6 +706,7 @@ def terminal_completion_readback() -> dict[str, object] | None: todo_id=todo_id, registry_path=registry_path, goal_id=args.goal_id, + runtime_root=runtime_root, ) readback = project_durable_terminal_completion_readback( todo=durable_todo, diff --git a/loopx/control_plane/coordination/authority_core.py b/loopx/control_plane/coordination/authority_core.py index 11012489fd..66e31f401f 100644 --- a/loopx/control_plane/coordination/authority_core.py +++ b/loopx/control_plane/coordination/authority_core.py @@ -513,53 +513,158 @@ def _terminal_fence_decision( ) -def _terminal_decision( +def _decision_scope_payload(scope: DecisionScope | None) -> dict[str, str] | None: + if scope is None: + return None + return { + "kind": scope[0], + "granularity": scope[1], + "scope_key": scope[2], + } + + +def _todo_fact_payload(todo: TodoSnapshot) -> dict[str, Any]: + return { + "todo_id": todo.todo_id, + "status": todo.status, + "role": todo.role, + "task_class": todo.task_class, + "claimed_by": todo.claimed_by, + "excluded_agents": sorted(todo.excluded_agents), + "bound_agent": todo.bound_agent, + "blocks_agent": todo.blocks_agent, + "decision_scope": _decision_scope_payload(todo.decision_scope), + "required_decision_scopes": [ + _decision_scope_payload(scope) + for scope in sorted(todo.required_decision_scopes) + ], + "unblocks_todo_id": todo.unblocks_todo_id, + } + + +def _lease_fact_payload(lease: LeaseSnapshot | None) -> dict[str, Any] | None: + if lease is None: + return None + return { + "present": lease.present, + "active": lease.active, + "status": lease.status, + "owner": lease.owner, + "idempotency_key": lease.idempotency_key, + "version": lease.version, + "lease_epoch": lease.lease_epoch, + "write_scopes": list(lease.write_scopes), + "acquire_ttl_seconds": lease.acquire_ttl_seconds, + } + + +def _lease_fact_from_payload(value: Any) -> LeaseSnapshot | None: + if value is None: + return None + if not isinstance(value, dict): + raise RuntimeError("TypeScript terminal decision next_lease shape mismatch") + return LeaseSnapshot( + present=bool(value.get("present")), + active=bool(value.get("active")), + status=str(value["status"]) if value.get("status") is not None else None, + owner=str(value["owner"]) if value.get("owner") is not None else None, + idempotency_key=( + str(value["idempotency_key"]) + if value.get("idempotency_key") is not None + else None + ), + version=int(value.get("version") or 0), + lease_epoch=int(value.get("lease_epoch") or 0), + write_scopes=tuple(str(item) for item in value.get("write_scopes") or []), + acquire_ttl_seconds=( + int(value["acquire_ttl_seconds"]) + if value.get("acquire_ttl_seconds") is not None + else None + ), + ) + + +def _typescript_terminal_decision( snapshot: CoordinationSnapshot, command: TodoMutationCommand, - *, - authority_mode: str, - ownership_gate: OwnershipGate, ) -> TransitionPlan: + """Adapt the TypeScript-owned complete/supersede decision into the legacy plan.""" + todo = snapshot.todo assert todo is not None - if todo.status == "done": - return _result( - DecisionOutcome.NO_CHANGE, - "terminal_replay", - next_snapshot=snapshot, - authority_mode=authority_mode, - ownership_gate=ownership_gate, - idempotent=True, - ) - fence = _terminal_fence_decision( - snapshot, - actor_agent_id=command.actor_agent_id, - lease_idempotency_key=command.lease_idempotency_key, - lease_expected_version=command.lease_expected_version, - delegated_authority=( - authority_mode == "delegated_orchestration_override" - ), - allow_user_gate_auto_acquire=( - command.allow_user_gate_auto_acquire - ), - require_active_when_fence_supplied=True, + payload = effect_runtime_result( + "todo.terminal.decide", + { + "schema_version": "loopx_coordination_todo_terminal_decision_request_v0", + "command": command.action.value, + "handoff_mode": snapshot.handoff_mode.value, + "registered_agents": list(snapshot.registered_agents), + "lifecycle_grants": [ + { + "agent_id": grant.agent_id, + "actions": sorted(grant.actions), + "requires_reason": grant.requires_reason, + } + for grant in snapshot.lifecycle_grants + ], + "todo": _todo_fact_payload(todo), + "decision_target": ( + _todo_fact_payload(snapshot.decision_target) + if snapshot.decision_target is not None + else None + ), + "lease": _lease_fact_payload(snapshot.lease), + "actor_agent_id": command.actor_agent_id, + "authority_action": command.authority_action or command.action.value, + "authority_reason": command.authority_reason, + "decision_outcome": command.decision_outcome, + "lease_idempotency_key": command.lease_idempotency_key, + "lease_expected_version": command.lease_expected_version, + "allow_user_gate_auto_acquire": command.allow_user_gate_auto_acquire, + }, ) - if fence.outcome is not DecisionOutcome.APPLY: - return replace( - fence, - authority_mode=authority_mode, - ownership_gate=ownership_gate, + if not isinstance(payload, dict) or payload.get("schema_version") != ( + "loopx_coordination_todo_terminal_decision_result_v0" + ): + raise RuntimeError("TypeScript terminal decision result shape mismatch") + try: + outcome = DecisionOutcome(str(payload["outcome"])) + ownership_gate = OwnershipGate(str(payload["ownership_gate"])) + lease_fence = LeaseFence(str(payload["lease_fence"])) + except (KeyError, ValueError) as exc: + raise RuntimeError( + "TypeScript terminal decision result shape mismatch" + ) from exc + next_snapshot = None + if outcome is DecisionOutcome.APPLY: + if payload.get("next_todo_status") != "done": + raise RuntimeError( + "TypeScript terminal decision omitted terminal Todo state" + ) + next_lease_payload = payload.get("next_lease") + next_snapshot = replace( + snapshot, + todo=replace(todo, status="done"), + lease=( + snapshot.lease + if next_lease_payload is None + else _lease_fact_from_payload(next_lease_payload) + ), ) - next_snapshot = fence.next_snapshot or snapshot - return replace( - fence, - code="terminal_transition", - next_snapshot=replace( - next_snapshot, - todo=_todo_after_command(todo, command), + elif outcome is DecisionOutcome.NO_CHANGE: + next_snapshot = snapshot + return TransitionPlan( + outcome=outcome, + code=str(payload.get("code") or "terminal_decision_invalid"), + next_snapshot=next_snapshot, + authority_mode=( + str(payload["authority_mode"]) + if payload.get("authority_mode") is not None + else None ), - authority_mode=authority_mode, ownership_gate=ownership_gate, + lease_fence=lease_fence, + idempotent=bool(payload.get("idempotent")), ) @@ -588,6 +693,8 @@ def _decide_todo( snapshot: CoordinationSnapshot, command: TodoMutationCommand, ) -> TransitionPlan: + if command.action in {TodoAction.COMPLETE, TodoAction.SUPERSEDE}: + return _typescript_terminal_decision(snapshot, command) authority_mode, rejection = _authority_for_todo(snapshot, command) if rejection is not None: return _result(DecisionOutcome.REJECTED, rejection) @@ -613,13 +720,6 @@ def _decide_todo( authority_mode=authority_mode, ownership_gate=ownership_gate, ) - if command.action in {TodoAction.COMPLETE, TodoAction.SUPERSEDE}: - return _terminal_decision( - snapshot, - command, - authority_mode=authority_mode, - ownership_gate=ownership_gate, - ) return _result( DecisionOutcome.APPLY, "todo_transition", diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 173f761261..c8849a88a7 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -176,6 +176,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "updated_at", "superseded_by", "completion_validation_required", + "completion_validation_sha256", "handoff_note" ], "required_fields": [ diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 99a23788f2..522fff6a95 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -79,6 +79,7 @@ def _freeze(value: Any) -> Any: 'updated_at', 'superseded_by', 'completion_validation_required', + 'completion_validation_sha256', 'handoff_note'], 'required_fields': ['schema_version', 'todo_id', diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index 4d53a8fa72..f2515388fa 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -68,6 +68,7 @@ "updated_at", "superseded_by", "completion_validation_required", + "completion_validation_sha256", "handoff_note" ], "required_fields": [ diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index f85a4aface..aa70dee4bf 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -52,16 +52,27 @@ import { COORDINATION_TODO_UPDATE_RESULT_SCHEMA, executeCoordinationTodoUpdate, } from "./todo_update.ts"; +import { + COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, + COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + executeCoordinationTodoArchiveCompleted, + executeCoordinationTodoTerminalLifecycle, +} from "./todo_terminal_lifecycle.ts"; import { editCoordinationTodo, TODO_COMPATIBILITY_EDIT_RESULT_SCHEMA } from "./todo_compatibility_edit.ts"; import { normalizeIdempotencyKey, normalizeTtl, } from "../work_items/task_lease_acquire.ts"; +import { compactPythonWhitespace } from "./todo_agents.ts"; export const LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v0"; export const LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA = "loopx_local_coordination_todo_create_request_v0"; +export const LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA = + "loopx_local_coordination_todo_terminal_lifecycle_request_v0"; +export const LOCAL_COORDINATION_TODO_ARCHIVE_REQUEST_SCHEMA = + "loopx_local_coordination_todo_archive_request_v0"; export { LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA, LOCAL_COORDINATION_MUTATION_RESULT_SCHEMA, @@ -103,6 +114,14 @@ function claimAgentValue(value: unknown, label: string): string { return value; } +function optionalProseValue(value: unknown, label: string): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") { + throw new Error(`${label} must be a string or null`); + } + return compactPythonWhitespace(value) || null; +} + function claimObservedAt(value: unknown): Date { if (typeof value !== "string" || value.trim() !== value) { throw new Error("observed_at must be a trimmed ISO-8601 timestamp"); @@ -129,6 +148,19 @@ function requiredPositiveSafeInteger(value: unknown, label: string): number { return Number(value); } +function requiredNonNegativeSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${label} must be a non-negative safe integer`); + } + return value as number; +} + +function optionalNonNegativeSafeInteger(value: unknown, label: string): number | null { + return value === null || value === undefined + ? null + : requiredNonNegativeSafeInteger(value, label); +} + function requiredUniqueStrings(value: unknown, label: string): string[] { if (!Array.isArray(value) || value.length > 32) { throw new Error(`${label} must be an array with at most 32 entries`); @@ -731,6 +763,147 @@ export async function updateLocalCoordinationTodo( } } +/** Local file-provider adapter for the provider-neutral terminal transaction. */ +export async function terminalLifecycleLocalCoordinationTodo( + value: unknown, + dependencies: LocalAuthorityRuntimeDependencies = {}, +): Promise { + const providerEvidence = {source_authority: "file_v0", + decision_read_from_provider: true, legacy_fallback_used: false}; + try { + const input = requireJsonObject(value, "local coordination Todo terminal request"); + if (input.schema_version !== LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA) { + throw new TypeError("local coordination Todo terminal request schema mismatch"); + } + if (!Array.isArray(input.registered_agents) || !Array.isArray(input.lifecycle_grants) || + !Array.isArray(input.successor_intents) || + !Array.isArray(input.linked_successor_todo_ids)) { + throw new TypeError( + "registered_agents, lifecycle_grants, successor_intents, and " + + "linked_successor_todo_ids must be arrays", + ); + } + const root = runtimeRoot(input.runtime_root); + const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); + const leaseExpectedVersion = optionalNonNegativeSafeInteger( + input.lease_expected_version, + "lease_expected_version", + ); + const registeredAgents = input.registered_agents.map((agent) => + claimAgentValue(agent, "registered agent")); + const lifecycleGrants = input.lifecycle_grants.map((grant, index) => + requireJsonObject(grant, `lifecycle_grants[${index}]`)); + const linkedSuccessorTodoIds = input.linked_successor_todo_ids.map((todoId) => + requireAuthorityStoreId(todoId, "linked successor Todo id")); + const successorIntents = input.successor_intents.map((intent, index) => + requireJsonObject(intent, `successor_intents[${index}]`)); + return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? + new FileAuthorityStore(authorityDirectory(root), goalId); + return {...await executeCoordinationTodoTerminalLifecycle(store, { + goal_id: goalId, + todo_id: requireAuthorityStoreId(input.todo_id, "todo id"), + expected_role: input.role === null || input.role === undefined + ? null : requireAuthorityStoreId(input.role, "role") as "agent" | "user", + command: requireAuthorityStoreId(input.command, "command") as "complete" | "supersede", + actor_agent_id: input.actor_agent_id === null || input.actor_agent_id === undefined + ? null : claimAgentValue(input.actor_agent_id, "actor_agent_id"), + registered_agents: registeredAgents, + lifecycle_grants: lifecycleGrants, + authority_reason: input.authority_reason === null || input.authority_reason === undefined + ? null : claimAgentValue(input.authority_reason, "authority_reason"), + decision_outcome: input.decision_outcome === null || input.decision_outcome === undefined + ? null : requireAuthorityStoreId(input.decision_outcome, "decision_outcome") as + "approve" | "reject" | "cancel", + operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + lease_idempotency_key: + input.lease_idempotency_key === null || input.lease_idempotency_key === undefined + ? null : requireAuthorityStoreId(input.lease_idempotency_key, "lease idempotency key"), + lease_expected_version: leaseExpectedVersion, + allow_user_gate_auto_acquire: input.allow_user_gate_auto_acquire as boolean, + requested_no_followup: input.requested_no_followup as boolean, + requested_completion_turn_key: + input.requested_completion_turn_key === null || + input.requested_completion_turn_key === undefined + ? null : claimAgentValue( + input.requested_completion_turn_key, + "requested_completion_turn_key", + ), + requested_completion_identity_source: + input.requested_completion_identity_source === null || + input.requested_completion_identity_source === undefined + ? null : requireAuthorityStoreId( + input.requested_completion_identity_source, + "requested_completion_identity_source", + ) as "turn_settlement" | "unscoped_completion" | "lifecycle_reentry", + linked_successor_todo_ids: linkedSuccessorTodoIds, + successor_intents: successorIntents, + note: optionalProseValue(input.note, "note"), + evidence: optionalProseValue(input.evidence, "evidence"), + reason: optionalProseValue(input.reason, "reason"), + clear_claim: input.clear_claim as boolean, + validation_declaration: + input.validation_declaration === null || input.validation_declaration === undefined + ? null : requireJsonObject(input.validation_declaration, "validation_declaration"), + validation_receipt: input.validation_receipt === null || input.validation_receipt === undefined + ? null : requireJsonObject(input.validation_receipt, "validation_receipt"), + completion_policy_request: + input.completion_policy_request === null || input.completion_policy_request === undefined + ? null : requireJsonObject(input.completion_policy_request, "completion_policy_request"), + dry_run: input.dry_run as boolean, + now: claimObservedAt(input.observed_at), + }), ...providerEvidence}; + }); + } catch (error) { + return {schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + status: "failed", changed: false, + reason_code: error instanceof ShadowManagementError ? error.reason_code : + "invalid_local_coordination_todo_terminal_lifecycle_request", + reason: error instanceof Error ? error.message : "invalid local Todo terminal request", + ...providerEvidence}; + } +} + +/** Local file-provider adapter for provider-owned completed-Todo compaction. */ +export async function archiveLocalCoordinationTodos( + value: unknown, + dependencies: LocalAuthorityRuntimeDependencies = {}, +): Promise { + const providerEvidence = {source_authority: "file_v0", + decision_read_from_provider: true, legacy_fallback_used: false}; + try { + const input = requireJsonObject(value, "local coordination Todo archive request"); + if (input.schema_version !== LOCAL_COORDINATION_TODO_ARCHIVE_REQUEST_SCHEMA) { + throw new TypeError("local coordination Todo archive request schema mismatch"); + } + const root = runtimeRoot(input.runtime_root); + const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); + const maxActiveDone = requiredNonNegativeSafeInteger( + input.max_active_done, + "max_active_done", + ); + return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? + new FileAuthorityStore(authorityDirectory(root), goalId); + return {...await executeCoordinationTodoArchiveCompleted(store, { + goal_id: goalId, + role: requireAuthorityStoreId(input.role, "role") as "agent" | "user", + max_active_done: maxActiveDone, + operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + dry_run: input.dry_run as boolean, + now: claimObservedAt(input.observed_at), + }), ...providerEvidence}; + }); + } catch (error) { + return {schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, + status: "failed", changed: false, + reason_code: error instanceof ShadowManagementError ? error.reason_code : + "invalid_local_coordination_todo_archive_request", + reason: error instanceof Error ? error.message : "invalid local Todo archive request", + ...providerEvidence}; + } +} + /** Embedded file adapter; no Markdown input or projection write is accepted. */ export async function editLocalCoordinationTodo( value: unknown, diff --git a/loopx/control_plane/coordination/runtime_shadow.py b/loopx/control_plane/coordination/runtime_shadow.py index 4b13c201d8..a74fc94ada 100644 --- a/loopx/control_plane/coordination/runtime_shadow.py +++ b/loopx/control_plane/coordination/runtime_shadow.py @@ -116,6 +116,7 @@ def build_todo_runtime_shadow_projection( ) compact = todo_partition_projection(handoff_mode=handoff_mode, todos=todos if isinstance(todos, list) else [])["todos"] + current_todo_ids = {str(item["todo_id"]) for item in compact} compact_leases: list[dict[str, object]] = [] if isinstance(leases, list): for item in leases: @@ -124,6 +125,12 @@ def build_todo_runtime_shadow_projection( todo_id = item.get("todo_id") if not isinstance(todo_id, str) or not todo_id: continue + # The legacy lease directory is an append-retained history while a + # canonical coordination head models only the current Todo graph. + # Retired lease files stay on disk for audit, but projecting them + # without their retired Todo would create an invalid orphan edge. + if todo_id not in current_todo_ids: + continue compact_leases.append(canonical_value(dict(item))) compact_leases.sort(key=lambda item: str(item["todo_id"])) todo_records_sha256 = hashlib.sha256(canonical_bytes(compact)).hexdigest() diff --git a/loopx/control_plane/coordination/runtime_shadow.ts b/loopx/control_plane/coordination/runtime_shadow.ts index e03d2efb0e..765ca0807b 100644 --- a/loopx/control_plane/coordination/runtime_shadow.ts +++ b/loopx/control_plane/coordination/runtime_shadow.ts @@ -142,6 +142,10 @@ export async function verifyShadowSourceSnapshot(request: ShadowRequest): Promis } const inventory: JsonObject[] = []; const leases: JsonObject[] = []; + const currentTodoIds = new Set( + (request.projection.todos as JsonObject[]).map((todo) => + String(canonicalAuthorityObject(todo, "source Todo").todo_id)), + ); // ASCII filenames must use the same ordinal order as Python's source snapshot. const leaseNames = names.filter((name) => /^[A-Za-z0-9_.-]+\.json$/.test(name)).sort((left, right) => { if (left < right) return -1; @@ -153,7 +157,10 @@ export async function verifyShadowSourceSnapshot(request: ShadowRequest): Promis const lease = canonicalAuthorityObject(JSON.parse(data.toString("utf8")), "lease"); if (lease.goal_id !== request.goal_id || lease.todo_id !== name.slice(0, -5)) throw new ShadowManagementError("source_lease_identity_mismatch"); inventory.push({ name, bytes_sha256: bytesDigest(data) }); - leases.push(lease); + // The legacy directory is append-retained audit history. The source + // inventory proves every file while the canonical head contains only live + // edges to Todos that still exist in the current projection. + if (currentTodoIds.has(String(lease.todo_id))) leases.push(lease); } if (!canonicalAuthorityBytes(inventory).equals(canonicalAuthorityBytes(snapshot.lease_inventory)) || !canonicalAuthorityBytes(leases).equals(canonicalAuthorityBytes(request.projection.leases))) { diff --git a/loopx/control_plane/coordination/todo_agents.ts b/loopx/control_plane/coordination/todo_agents.ts index c402000405..cf241802a8 100644 --- a/loopx/control_plane/coordination/todo_agents.ts +++ b/loopx/control_plane/coordination/todo_agents.ts @@ -21,6 +21,11 @@ export function stripPythonWhitespace(value: string): string { return value.replace(PYTHON_LEADING_TRAILING_WHITESPACE, ""); } +/** Match Python's ``" ".join(str(value).strip().split())`` text compaction. */ +export function compactPythonWhitespace(value: string): string { + return stripPythonWhitespace(value).replace(PYTHON_WHITESPACE_RUN, " "); +} + /** Match Python's bool(str(value).strip()) presence contract. */ export function hasPythonNonWhitespaceText(value: string): boolean { return stripPythonWhitespace(value).length > 0; @@ -34,8 +39,7 @@ export function normalizeTodoAgent(value: unknown, label: string): string { // typed with any Python whitespace (including U+0085 NEL, U+001C..U+001F, // tabs, and NBSP) fold exactly like the Python kernel's compact_todo_text path // (loopx/control_plane/todos/contract.py normalize_todo_claimed_by). - const stripped = stripPythonWhitespace(value); - const candidate = stripped.toLowerCase().replace(PYTHON_WHITESPACE_RUN, "-"); + const candidate = compactPythonWhitespace(value).toLowerCase().replaceAll(" ", "-"); if (!/^[a-z][a-z0-9_.:@-]{0,79}$/u.test(candidate)) { throw new AuthorityStoreProtocolError(`${label} must be a public-safe agent id`); } diff --git a/loopx/control_plane/coordination/todo_archive_selection.ts b/loopx/control_plane/coordination/todo_archive_selection.ts new file mode 100644 index 0000000000..be42a6d6d9 --- /dev/null +++ b/loopx/control_plane/coordination/todo_archive_selection.ts @@ -0,0 +1,132 @@ +import type { JsonObject } from "../effect_program.ts"; +import { + authorityUnicodeCompare, + canonicalAuthorityObject, + requireAuthorityStoreId, +} from "./authority_store_codec.ts"; + +export const COORDINATION_TODO_ARCHIVE_SELECTION_SCHEMA = + "loopx_coordination_todo_archive_selection_v0"; + +const TODO_ROLES = ["agent", "user"] as const; +const DECISION_OUTCOMES = ["approve", "reject", "cancel"] as const; +const STANDING_DECISION_GRANULARITIES = new Set(["goal", "project", "global"]); + +export type CoordinationTodoArchiveRole = typeof TODO_ROLES[number]; + +export interface CoordinationTodoArchiveSelectionInput { + readonly role: CoordinationTodoArchiveRole; + readonly max_active_done: number; + readonly todos: readonly JsonObject[]; +} + +export type CoordinationTodoArchiveSelectionResult = JsonObject & { + readonly schema_version: typeof COORDINATION_TODO_ARCHIVE_SELECTION_SCHEMA; + readonly role: CoordinationTodoArchiveRole; + readonly active_done_before: number; + readonly active_done_after: number; + readonly max_active_done: number; + readonly moved_count: number; + readonly moved_todo_ids: readonly string[]; + readonly retained_standing_decision_count: number; +}; + +function archiveRole(value: unknown): CoordinationTodoArchiveRole { + if (value !== "agent" && value !== "user") { + throw new TypeError("archive role must be one of: agent, user"); + } + return value; +} + +function archiveLimit(value: unknown): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) { + throw new TypeError("max_active_done must be a non-negative safe integer"); + } + return Number(value); +} + +function isStandingDecisionReceipt(todo: JsonObject): boolean { + if (todo.role !== "user" || todo.task_class !== "user_gate" || todo.status !== "done" || + typeof todo.unblocks_todo_id === "string") return false; + const scope = todo.decision_scope; + if (scope === null || typeof scope !== "object" || Array.isArray(scope) || + typeof (scope as JsonObject).granularity !== "string" || + !STANDING_DECISION_GRANULARITIES.has(String((scope as JsonObject).granularity)) || + !DECISION_OUTCOMES.includes(todo.decision_outcome as typeof DECISION_OUTCOMES[number])) { + return false; + } + return todo.global_gate === true || typeof todo.blocks_agent === "string"; +} + +function archiveOrder(left: JsonObject, right: JsonObject): number { + const leftIndex = Number.isSafeInteger(left.index) && Number(left.index) >= 0 + ? Number(left.index) : null; + const rightIndex = Number.isSafeInteger(right.index) && Number(right.index) >= 0 + ? Number(right.index) : null; + if (leftIndex !== null || rightIndex !== null) { + if (leftIndex === null) return 1; + if (rightIndex === null) return -1; + if (leftIndex !== rightIndex) return leftIndex - rightIndex; + } + const leftTime = typeof left.completed_at === "string" + ? left.completed_at : typeof left.updated_at === "string" ? left.updated_at : ""; + const rightTime = typeof right.completed_at === "string" + ? right.completed_at : typeof right.updated_at === "string" ? right.updated_at : ""; + if (leftTime !== rightTime) return authorityUnicodeCompare(leftTime, rightTime); + return authorityUnicodeCompare(String(left.todo_id), String(right.todo_id)); +} + +/** Select completed Todo ids without owning storage or applying mutations. */ +export function selectCoordinationTodoArchive( + input: CoordinationTodoArchiveSelectionInput, +): CoordinationTodoArchiveSelectionResult { + const role = archiveRole(input.role); + const maxActiveDone = archiveLimit(input.max_active_done); + const completed = input.todos + .filter((todo) => todo.role === role && todo.archive_state === "active" && + todo.status === "done") + .map((todo) => ({ + ...todo, + todo_id: requireAuthorityStoreId(todo.todo_id, "completed Todo id"), + })) + .sort(archiveOrder); + const completedIds = completed.map((todo) => String(todo.todo_id)); + if (new Set(completedIds).size !== completedIds.length) { + throw new TypeError("completed Todo ids must be unique"); + } + const retainedStanding = role === "user" + ? completed.filter(isStandingDecisionReceipt) : []; + const retainedIds = new Set(retainedStanding.map((todo) => String(todo.todo_id))); + const movable = completed.filter((todo) => !retainedIds.has(String(todo.todo_id))); + const moveCount = Math.min( + movable.length, + Math.max(0, completed.length - maxActiveDone), + ); + const movedTodoIds = movable.slice(0, moveCount).map((todo) => String(todo.todo_id)); + return { + schema_version: COORDINATION_TODO_ARCHIVE_SELECTION_SCHEMA, + role, + active_done_before: completed.length, + active_done_after: completed.length - movedTodoIds.length, + max_active_done: maxActiveDone, + moved_count: movedTodoIds.length, + moved_todo_ids: movedTodoIds, + retained_standing_decision_count: retainedStanding.length, + }; +} + +/** Strict wire decoder used by the transitional Python compatibility writer. */ +export function evaluateCoordinationTodoArchiveSelection( + value: JsonObject, +): CoordinationTodoArchiveSelectionResult { + const request = canonicalAuthorityObject(value, "Todo archive selection request"); + if (!Array.isArray(request.todos)) { + throw new TypeError("todos must be an array"); + } + return selectCoordinationTodoArchive({ + role: archiveRole(request.role), + max_active_done: archiveLimit(request.max_active_done), + todos: request.todos.map((todo, index) => + canonicalAuthorityObject(todo, `todos[${index}]`)), + }); +} diff --git a/loopx/control_plane/coordination/todo_successor_derivation.ts b/loopx/control_plane/coordination/todo_successor_derivation.ts new file mode 100644 index 0000000000..4f52ea03f5 --- /dev/null +++ b/loopx/control_plane/coordination/todo_successor_derivation.ts @@ -0,0 +1,412 @@ +import type { JsonObject } from "../effect_program.ts"; +import { + AuthorityStoreProtocolError, + canonicalAuthorityObject, + requireAuthorityStoreId, +} from "./authority_store_codec.ts"; +import { + compactPythonWhitespace, + normalizeRegisteredTodoAgents, + normalizeTodoAgent, + stripPythonWhitespace, +} from "./todo_agents.ts"; + +export const TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA = + "loopx_todo_successor_derivation_request_v0"; +export const TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA = + "loopx_todo_successor_derivation_result_v0"; + +const TERMINAL_COMMANDS = ["complete", "supersede"] as const; +const TODO_ROLES = ["agent", "user"] as const; +const AGENT_TASK_CLASSES = new Set([ + "advancement_task", + "continuous_monitor", + "blocker", +]); +const USER_TASK_CLASSES = new Set(["user_action", "user_gate"]); +const CONTINUATION_POLICIES = new Set([ + "independent_handoff", + "same_agent_non_delivery", +]); + +type TerminalCommand = typeof TERMINAL_COMMANDS[number]; +type TodoRole = typeof TODO_ROLES[number]; + +export interface TodoSuccessorDerivationInput { + readonly schema_version: typeof TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA; + readonly command: TerminalCommand; + readonly predecessor: JsonObject; + readonly registered_agents: readonly string[]; + readonly actor_agent_id: string | null; + readonly completion_policy: JsonObject | null; + readonly successor_intents: readonly JsonObject[]; +} + +interface NormalizedSuccessorIntent extends JsonObject { + role: TodoRole; + text: string; + task_class: string; +} + +function requireLiteral( + value: unknown, + allowed: readonly T[], + label: string, +): T { + if (typeof value !== "string" || !allowed.includes(value as T)) { + throw new AuthorityStoreProtocolError( + `${label} must be one of: ${allowed.join(", ")}`, + ); + } + return value as T; +} + +function optionalString(value: unknown, label: string): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") { + throw new AuthorityStoreProtocolError(`${label} must be a string or null`); + } + return value; +} + +function optionalRegisteredAgent( + value: unknown, + label: string, + registeredAgents: readonly string[], +): string | null { + const raw = optionalString(value, label); + if (raw === null) return null; + const agent = normalizeTodoAgent(raw, label); + if (!registeredAgents.includes(agent)) { + throw new AuthorityStoreProtocolError(`${label} is not a registered agent`); + } + return agent; +} + +function optionalStringArray(value: unknown, label: string): string[] { + if (value === null || value === undefined) return []; + if (!Array.isArray(value)) { + throw new AuthorityStoreProtocolError(`${label} must be an array`); + } + const values = value.map((item, index) => { + if (typeof item !== "string" || compactPythonWhitespace(item).length === 0) { + throw new AuthorityStoreProtocolError(`${label}[${index}] must be a string`); + } + return compactPythonWhitespace(item); + }); + if (new Set(values).size !== values.length) { + throw new AuthorityStoreProtocolError(`${label} must contain unique values`); + } + return values; +} + +function optionalRegisteredAgents( + value: unknown, + label: string, + registeredAgents: readonly string[], +): string[] { + return optionalStringArray(value, label).map((raw, index) => { + const agent = normalizeTodoAgent(raw, `${label}[${index}]`); + if (!registeredAgents.includes(agent)) { + throw new AuthorityStoreProtocolError(`${label}[${index}] is not registered`); + } + return agent; + }); +} + +function priorityPrefix(text: string): string | null { + const match = /^\[(P[0-4])\] /iu.exec(text); + return match === null ? null : match[1]!.toUpperCase(); +} + +function inheritPriority(nextText: string, predecessorText: string): string { + const text = compactPythonWhitespace(nextText); + if (text.length === 0) { + throw new AuthorityStoreProtocolError("successor intent text must not be empty"); + } + if (priorityPrefix(text) !== null) return text; + const inherited = priorityPrefix(compactPythonWhitespace(predecessorText)); + return inherited === null ? text : `[${inherited}] ${text}`; +} + +function normalizeIntent( + value: unknown, + index: number, + registeredAgents: readonly string[], +): NormalizedSuccessorIntent { + const raw = canonicalAuthorityObject(value, `successor_intents[${index}]`); + const role = requireLiteral(raw.role, TODO_ROLES, `successor_intents[${index}].role`); + const rawTaskClass = optionalString( + raw.task_class, + `successor_intents[${index}].task_class`, + ); + const taskClass = rawTaskClass === null + ? (role === "agent" ? "advancement_task" : "") + : stripPythonWhitespace(rawTaskClass).toLowerCase(); + if (role === "agent" && !AGENT_TASK_CLASSES.has(taskClass)) { + throw new AuthorityStoreProtocolError( + "Agent successor task_class must be advancement_task, continuous_monitor, or blocker", + ); + } + if (role === "user" && !USER_TASK_CLASSES.has(taskClass)) { + throw new AuthorityStoreProtocolError( + "User successor task_class must be user_action or user_gate", + ); + } + const claimedBy = optionalRegisteredAgent( + raw.claimed_by, + `successor_intents[${index}].claimed_by`, + registeredAgents, + ); + const excludedAgents = optionalRegisteredAgents( + raw.excluded_agents, + `successor_intents[${index}].excluded_agents`, + registeredAgents, + ); + if (claimedBy !== null && excludedAgents.includes(claimedBy)) { + throw new AuthorityStoreProtocolError( + "successor claimed_by cannot also appear in excluded_agents", + ); + } + const rawContinuationPolicy = optionalString( + raw.continuation_policy, + `successor_intents[${index}].continuation_policy`, + ); + const continuationPolicy = rawContinuationPolicy === null + ? null + : stripPythonWhitespace(rawContinuationPolicy).toLowerCase(); + if (continuationPolicy !== null && !CONTINUATION_POLICIES.has(continuationPolicy)) { + throw new AuthorityStoreProtocolError( + "successor continuation_policy is unsupported", + ); + } + const requiredCapabilities = optionalStringArray( + raw.required_capabilities, + `successor_intents[${index}].required_capabilities`, + ); + const rawActionKind = optionalString( + raw.action_kind, + `successor_intents[${index}].action_kind`, + ); + const actionKind = rawActionKind === null + ? null + : stripPythonWhitespace(rawActionKind).toLowerCase(); + if (actionKind !== null && !/^[a-z][a-z0-9_-]{0,63}$/u.test(actionKind)) { + throw new AuthorityStoreProtocolError( + "successor action_kind must be a public-safe token", + ); + } + return { + ...raw, + role, + text: optionalString(raw.text, `successor_intents[${index}].text`) ?? "", + task_class: taskClass, + ...(claimedBy === null ? {} : {claimed_by: claimedBy}), + excluded_agents: excludedAgents, + required_capabilities: requiredCapabilities, + ...(actionKind === null ? {} : {action_kind: actionKind}), + ...(continuationPolicy === null ? {} : {continuation_policy: continuationPolicy}), + }; +} + +function compactOptionalField( + target: JsonObject, + key: string, + value: unknown, +): void { + if (value !== null && value !== undefined && value !== "" && + (!Array.isArray(value) || value.length > 0)) { + target[key] = value; + } +} + +function completionPolicyAgent( + policy: JsonObject, + field: "effective_claimed_by" | "effective_next_claimed_by", + registeredAgents: readonly string[], +): string | null { + return optionalRegisteredAgent(policy[field], `completion_policy.${field}`, registeredAgents); +} + +/** + * Derive terminal successor proposals from caller intent and predecessor facts. + * + * This function owns priority, capability/binding, exclusion, and predecessor + * relation inheritance for both canonical transactions and the legacy facade. + * It deliberately does not assign provider- or Markdown-specific identities. + */ +export function deriveCoordinationTodoSuccessorProposals( + rawInput: TodoSuccessorDerivationInput, +): JsonObject[] { + if (rawInput.schema_version !== TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA) { + throw new AuthorityStoreProtocolError("Todo successor derivation schema mismatch"); + } + const command = requireLiteral(rawInput.command, TERMINAL_COMMANDS, "command"); + if (!Array.isArray(rawInput.successor_intents)) { + throw new AuthorityStoreProtocolError("successor_intents must be an array"); + } + if (rawInput.successor_intents.length === 0) { + return []; + } + const predecessor = canonicalAuthorityObject(rawInput.predecessor, "predecessor"); + const predecessorId = requireAuthorityStoreId(predecessor.todo_id, "predecessor.todo_id"); + const predecessorText = optionalString(predecessor.text, "predecessor.text") ?? ""; + const registeredAgents = normalizeRegisteredTodoAgents(rawInput.registered_agents); + const actor = optionalRegisteredAgent( + rawInput.actor_agent_id, + "actor_agent_id", + registeredAgents, + ); + const intents = rawInput.successor_intents.map((intent, index) => + normalizeIntent(intent, index, registeredAgents)); + for (const role of TODO_ROLES) { + if (intents.filter((intent) => intent.role === role).length > 1) { + throw new AuthorityStoreProtocolError( + `terminal lifecycle permits at most one generated ${role} successor`, + ); + } + } + const completionPolicy = rawInput.completion_policy === null + ? null + : canonicalAuthorityObject(rawInput.completion_policy, "completion_policy"); + if (command === "complete" && completionPolicy === null && intents.length > 0) { + throw new AuthorityStoreProtocolError( + "complete successor derivation requires the committed completion policy", + ); + } + if (command === "supersede" && completionPolicy !== null) { + throw new AuthorityStoreProtocolError( + "supersede successor derivation must not carry a completion policy", + ); + } + + const proposals: JsonObject[] = []; + for (const intent of intents) { + const proposal: JsonObject = { + role: intent.role, + text: inheritPriority(intent.text, predecessorText), + task_class: intent.task_class, + }; + compactOptionalField(proposal, "created_by", actor); + if (intent.role === "agent") { + let claimedBy = optionalRegisteredAgent( + intent.claimed_by, + "successor_intent.claimed_by", + registeredAgents, + ); + let excludedAgents = optionalRegisteredAgents( + intent.excluded_agents, + "successor_intent.excluded_agents", + registeredAgents, + ); + if (completionPolicy !== null) { + claimedBy = completionPolicyAgent( + completionPolicy, + "effective_next_claimed_by", + registeredAgents, + ); + excludedAgents = optionalRegisteredAgents( + completionPolicy.effective_next_excluded_agents, + "completion_policy.effective_next_excluded_agents", + registeredAgents, + ); + } else if (claimedBy === null && + intent.continuation_policy === "same_agent_non_delivery") { + claimedBy = optionalRegisteredAgent( + predecessor.claimed_by, + "predecessor.claimed_by", + registeredAgents, + ); + } + if (claimedBy !== null && excludedAgents.includes(claimedBy)) { + throw new AuthorityStoreProtocolError( + "derived successor claimed_by cannot also appear in excluded_agents", + ); + } + compactOptionalField(proposal, "action_kind", intent.action_kind); + compactOptionalField( + proposal, + "capability_binding_ref", + predecessor.capability_binding_ref, + ); + compactOptionalField(proposal, "task_repository", intent.task_repository); + compactOptionalField( + proposal, + "required_capabilities", + intent.required_capabilities, + ); + compactOptionalField( + proposal, + "continuation_policy", + intent.continuation_policy, + ); + compactOptionalField(proposal, "claimed_by", claimedBy); + proposal.excluded_agents = excludedAgents; + compactOptionalField( + proposal, + "unblocks_todo_id", + command === "complete" ? predecessorId : predecessor.unblocks_todo_id, + ); + } else { + let boundAgent: string | null = null; + if (command === "complete") { + if (registeredAgents.length > 1 && completionPolicy !== null) { + boundAgent = completionPolicyAgent( + completionPolicy, + "effective_claimed_by", + registeredAgents, + ); + if (boundAgent === null) { + throw new AuthorityStoreProtocolError( + "multi-agent completion requires an effective completing Agent for its User successor", + ); + } + } + } else { + boundAgent = optionalRegisteredAgent( + predecessor.bound_agent ?? predecessor.blocks_agent ?? + predecessor.claimed_by ?? intents.find((candidate) => + candidate.role === "agent")?.claimed_by, + "supersede user successor binding", + registeredAgents, + ); + if (registeredAgents.length > 1 && boundAgent === null) { + throw new AuthorityStoreProtocolError( + "multi-agent supersede requires an inherited or requested User successor binding", + ); + } + } + compactOptionalField(proposal, "bound_agent", boundAgent); + if (intent.task_class === "user_gate") { + compactOptionalField(proposal, "blocks_agent", boundAgent); + proposal.action_kind = "gate"; + } + } + proposals.push(proposal); + } + return proposals; +} + +/** Pure effect-runtime wire handler used by the legacy Python facade. */ +export function evaluateCoordinationTodoSuccessorDerivation( + value: unknown, +): JsonObject { + try { + const input = canonicalAuthorityObject(value, "Todo successor derivation request"); + const proposals = deriveCoordinationTodoSuccessorProposals( + input as unknown as TodoSuccessorDerivationInput, + ); + return { + schema_version: TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA, + status: "derived", + successors: proposals, + }; + } catch (error) { + return { + schema_version: TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA, + status: "failed", + reason_code: "invalid_todo_successor_derivation", + reason: error instanceof Error ? error.message : "invalid Todo successor derivation", + }; + } +} diff --git a/loopx/control_plane/coordination/todo_terminal_decision.ts b/loopx/control_plane/coordination/todo_terminal_decision.ts new file mode 100644 index 0000000000..d52f4904c5 --- /dev/null +++ b/loopx/control_plane/coordination/todo_terminal_decision.ts @@ -0,0 +1,452 @@ +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { + requireBoolean, + requireInteger, + requireJsonObject, + requireNonEmptyString, + requireStringArray, + requireStringLiteral, +} from "../runtime_decode.ts"; +import { normalizeRegisteredTodoAgents, normalizeTodoAgent } from "./todo_agents.ts"; + +export const COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA = + "loopx_coordination_todo_terminal_decision_request_v0"; +export const COORDINATION_TODO_TERMINAL_DECISION_RESULT_SCHEMA = + "loopx_coordination_todo_terminal_decision_result_v0"; + +const COMMANDS = ["complete", "supersede"] as const; +const HANDOFF_MODES = ["legacy", "soft_claim", "hard_lease"] as const; +const OUTCOMES = ["approve", "reject", "cancel"] as const; +const AUTHORITY_ACTIONS = ["complete", "reassign", "supersede", "update"] as const; + +type TerminalCommand = typeof COMMANDS[number]; +type HandoffMode = typeof HANDOFF_MODES[number]; +type DecisionOutcome = typeof OUTCOMES[number]; + +interface DecisionScope extends JsonObject { + readonly kind: string; + readonly granularity: string; + readonly scope_key: string; +} + +interface TodoFact extends JsonObject { + readonly todo_id: string; + readonly status: string; + readonly role: "user" | "agent"; + readonly task_class: string | null; + readonly claimed_by: string | null; + readonly excluded_agents: readonly string[]; + readonly bound_agent: string | null; + readonly blocks_agent: string | null; + readonly decision_scope: DecisionScope | null; + readonly required_decision_scopes: readonly DecisionScope[]; + readonly unblocks_todo_id: string | null; +} + +interface LeaseFact extends JsonObject { + readonly present: boolean; + readonly active: boolean; + readonly status: string | null; + readonly owner: string | null; + readonly idempotency_key: string | null; + readonly version: number; + readonly lease_epoch: number; + readonly write_scopes: readonly string[]; + readonly acquire_ttl_seconds: number | null; +} + +interface LifecycleGrant extends JsonObject { + readonly agent_id: string; + readonly actions: readonly string[]; + readonly requires_reason: boolean; +} + +interface TerminalDecisionRequest { + readonly command: TerminalCommand; + readonly handoff_mode: HandoffMode; + readonly registered_agents: readonly string[]; + readonly lifecycle_grants: readonly LifecycleGrant[]; + readonly todo: TodoFact; + readonly decision_target: TodoFact | null; + readonly lease: LeaseFact | null; + readonly actor_agent_id: string | null; + readonly authority_action: string; + readonly authority_reason: string | null; + readonly decision_outcome: DecisionOutcome | null; + readonly lease_idempotency_key: string | null; + readonly lease_expected_version: number | null; + readonly allow_user_gate_auto_acquire: boolean; +} + +export interface CoordinationTodoTerminalDecisionResult extends JsonObject { + readonly schema_version: typeof COORDINATION_TODO_TERMINAL_DECISION_RESULT_SCHEMA; + readonly outcome: "apply" | "no_change" | "conflict" | "rejected"; + readonly code: string; + readonly authority_mode: string | null; + readonly ownership_gate: "not_required" | "require_holder" | "delegated_override"; + readonly lease_fence: "not_required" | "required" | "auto_acquire" | "delegated_override"; + readonly idempotent: boolean; + readonly next_todo_status: "done" | null; + readonly next_lease: LeaseFact | null; +} + +function optionalString(value: unknown, label: string): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") { + throw new EffectRuntimeRequestError(`${label} must be a string or null`); + } + return value; +} + +function optionalAgent(value: unknown, label: string): string | null { + const candidate = optionalString(value, label); + return candidate === null ? null : normalizeTodoAgent(candidate, label); +} + +function optionalNonNegativeInteger(value: unknown, label: string): number | null { + if (value === null || value === undefined) return null; + const candidate = requireInteger(value, label); + if (candidate < 0) { + throw new EffectRuntimeRequestError(`${label} must be a non-negative integer or null`); + } + return candidate; +} + +function decisionScope(value: unknown, label: string): DecisionScope | null { + if (value === null || value === undefined) return null; + const scope = requireJsonObject(value, label); + return { + kind: requireNonEmptyString(scope.kind, `${label}.kind`), + granularity: requireNonEmptyString(scope.granularity, `${label}.granularity`), + scope_key: requireNonEmptyString(scope.scope_key, `${label}.scope_key`), + ...(typeof scope.schema_version === "string" + ? { schema_version: scope.schema_version } + : {}), + ...(typeof scope.decision_id === "string" + ? { decision_id: scope.decision_id } + : {}), + }; +} + +function decisionScopes(value: unknown, label: string): DecisionScope[] { + if (!Array.isArray(value)) { + throw new EffectRuntimeRequestError(`${label} must be an array`); + } + return value.map((scope, index) => decisionScope(scope, `${label}[${index}]`)!); +} + +function todoFact(value: unknown, label: string): TodoFact { + const todo = requireJsonObject(value, label); + const role = requireStringLiteral(todo.role, ["user", "agent"] as const, `${label}.role`); + return { + todo_id: requireNonEmptyString(todo.todo_id, `${label}.todo_id`), + status: requireNonEmptyString(todo.status, `${label}.status`), + role, + task_class: optionalString(todo.task_class, `${label}.task_class`), + claimed_by: optionalAgent(todo.claimed_by, `${label}.claimed_by`), + excluded_agents: normalizeRegisteredTodoAgents( + requireStringArray(todo.excluded_agents ?? [], `${label}.excluded_agents`), + ), + bound_agent: optionalAgent(todo.bound_agent, `${label}.bound_agent`), + blocks_agent: optionalAgent(todo.blocks_agent, `${label}.blocks_agent`), + decision_scope: decisionScope(todo.decision_scope, `${label}.decision_scope`), + required_decision_scopes: decisionScopes( + todo.required_decision_scopes ?? [], + `${label}.required_decision_scopes`, + ), + unblocks_todo_id: optionalString(todo.unblocks_todo_id, `${label}.unblocks_todo_id`), + }; +} + +function leaseFact(value: unknown): LeaseFact | null { + if (value === null || value === undefined) return null; + const lease = requireJsonObject(value, "lease"); + const version = optionalNonNegativeInteger(lease.version, "lease.version"); + const epoch = optionalNonNegativeInteger(lease.lease_epoch, "lease.lease_epoch"); + if (version === null || epoch === null) { + throw new EffectRuntimeRequestError("lease version and lease_epoch are required"); + } + return { + present: requireBoolean(lease.present, "lease.present"), + active: requireBoolean(lease.active, "lease.active"), + status: optionalString(lease.status, "lease.status"), + owner: optionalAgent(lease.owner, "lease.owner"), + idempotency_key: optionalString(lease.idempotency_key, "lease.idempotency_key"), + version, + lease_epoch: epoch, + write_scopes: requireStringArray(lease.write_scopes ?? [], "lease.write_scopes"), + acquire_ttl_seconds: optionalNonNegativeInteger( + lease.acquire_ttl_seconds, + "lease.acquire_ttl_seconds", + ), + }; +} + +function lifecycleGrants( + value: unknown, + registeredAgents: readonly string[], +): LifecycleGrant[] { + if (!Array.isArray(value)) { + throw new EffectRuntimeRequestError("lifecycle_grants must be an array"); + } + const seen = new Set(); + return value.map((raw, index) => { + const grant = requireJsonObject(raw, `lifecycle_grants[${index}]`); + const agentId = normalizeTodoAgent(grant.agent_id, `lifecycle_grants[${index}].agent_id`); + if (!registeredAgents.includes(agentId)) { + throw new EffectRuntimeRequestError( + `todo lifecycle authority agent_id='${agentId}' must already be registered`, + ); + } + if (seen.has(agentId)) { + throw new EffectRuntimeRequestError(`duplicate todo lifecycle authority grant for '${agentId}'`); + } + seen.add(agentId); + const actions = requireStringArray(grant.actions, `lifecycle_grants[${index}].actions`) + .map((action) => action.trim().toLowerCase()); + if (actions.length === 0 || actions.some((action) => + !AUTHORITY_ACTIONS.some((candidate) => candidate === action))) { + throw new EffectRuntimeRequestError( + "todo lifecycle authority actions must contain supported actions", + ); + } + return { + agent_id: agentId, + actions: [...new Set(actions)], + requires_reason: requireBoolean( + grant.requires_reason, + `lifecycle_grants[${index}].requires_reason`, + ), + }; + }); +} + +function decodeRequest(value: unknown): TerminalDecisionRequest { + const request = requireJsonObject(value, "Todo terminal decision request"); + requireStringLiteral( + request.schema_version, + [COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA] as const, + "schema_version", + ); + const registeredAgents = normalizeRegisteredTodoAgents( + requireStringArray(request.registered_agents, "registered_agents"), + ); + const outcome = optionalString(request.decision_outcome, "decision_outcome"); + return { + command: requireStringLiteral(request.command, COMMANDS, "command"), + handoff_mode: requireStringLiteral(request.handoff_mode, HANDOFF_MODES, "handoff_mode"), + registered_agents: registeredAgents, + lifecycle_grants: lifecycleGrants(request.lifecycle_grants ?? [], registeredAgents), + todo: todoFact(request.todo, "todo"), + decision_target: request.decision_target === null || request.decision_target === undefined + ? null + : todoFact(request.decision_target, "decision_target"), + lease: leaseFact(request.lease), + actor_agent_id: optionalAgent(request.actor_agent_id, "actor_agent_id"), + authority_action: requireStringLiteral( + request.authority_action, + AUTHORITY_ACTIONS, + "authority_action", + ), + authority_reason: optionalString(request.authority_reason, "authority_reason"), + decision_outcome: outcome === null + ? null + : requireStringLiteral(outcome, OUTCOMES, "decision_outcome"), + lease_idempotency_key: optionalString( + request.lease_idempotency_key, + "lease_idempotency_key", + ), + lease_expected_version: optionalNonNegativeInteger( + request.lease_expected_version, + "lease_expected_version", + ), + allow_user_gate_auto_acquire: requireBoolean( + request.allow_user_gate_auto_acquire, + "allow_user_gate_auto_acquire", + ), + }; +} + +function scopeKey(scope: DecisionScope): string { + return `${scope.kind}\u0000${scope.granularity}\u0000${scope.scope_key}`; +} + +function exactUserGateOverride(request: TerminalDecisionRequest): boolean { + const { todo, decision_target: target } = request; + return request.command === "complete" && target !== null && todo.role === "user" && + todo.task_class === "user_gate" && request.decision_outcome !== null && + todo.decision_scope !== null && todo.unblocks_todo_id === target.todo_id && + target.required_decision_scopes.some((scope) => + scopeKey(scope) === scopeKey(todo.decision_scope!)); +} + +function result( + outcome: CoordinationTodoTerminalDecisionResult["outcome"], + code: string, + options: Partial> = {}, +): CoordinationTodoTerminalDecisionResult { + return { + schema_version: COORDINATION_TODO_TERMINAL_DECISION_RESULT_SCHEMA, + outcome, + code, + authority_mode: options.authority_mode ?? null, + ownership_gate: options.ownership_gate ?? "not_required", + lease_fence: options.lease_fence ?? "not_required", + idempotent: options.idempotent ?? false, + next_todo_status: options.next_todo_status ?? null, + next_lease: options.next_lease ?? null, + }; +} + +function authority(request: TerminalDecisionRequest): + | { mode: string; ownershipGate: CoordinationTodoTerminalDecisionResult["ownership_gate"] } + | CoordinationTodoTerminalDecisionResult { + const { todo, actor_agent_id: actor, registered_agents: registered } = request; + if (registered.length <= 1) { + if (actor !== null && registered.length > 0 && !registered.includes(actor)) { + return result("rejected", "actor_not_registered"); + } + return { mode: "single_agent_compatibility", ownershipGate: "not_required" }; + } + if (exactUserGateOverride(request)) { + return { mode: "exact_user_gate_decision_scope_override", ownershipGate: "not_required" }; + } + if (actor === null) return result("rejected", "actor_required"); + if (!registered.includes(actor)) return result("rejected", "actor_not_registered"); + if (todo.excluded_agents.includes(actor)) return result("rejected", "actor_excluded"); + const boundAgent = todo.bound_agent ?? (todo.role === "user" ? todo.blocks_agent : null); + if (boundAgent !== null && boundAgent !== actor) { + return result("rejected", "bound_agent_mismatch"); + } + if (todo.claimed_by !== null && todo.claimed_by !== actor) { + const grant = request.lifecycle_grants.find((candidate) => candidate.agent_id === actor); + if (grant === undefined) return result("rejected", "claim_owner_mismatch"); + if (!grant.actions.includes(request.authority_action)) { + return result("rejected", "delegation_action_not_granted"); + } + if (grant.requires_reason && !String(request.authority_reason ?? "").trim()) { + return result("rejected", "delegation_reason_required"); + } + return { mode: "delegated_orchestration_override", ownershipGate: "not_required" }; + } + return { mode: "registered_peer_actor", ownershipGate: "not_required" }; +} + +function ownerEligible(request: TerminalDecisionRequest, owner: string | null): boolean { + const todo = request.todo; + return todo.status === "open" && owner !== null && + request.registered_agents.includes(owner) && !todo.excluded_agents.includes(owner) && + (todo.claimed_by === null || todo.claimed_by === owner); +} + +function terminalFence( + request: TerminalDecisionRequest, + authorityMode: string, +): CoordinationTodoTerminalDecisionResult { + const lease = request.lease; + const timeActive = lease !== null && lease.present && lease.active; + const effective = timeActive && ownerEligible(request, lease.owner); + const explicitFence = request.lease_idempotency_key !== null || + request.lease_expected_version !== null; + const delegated = authorityMode === "delegated_orchestration_override"; + const autoAcquire = request.handoff_mode === "hard_lease" && !delegated && + request.allow_user_gate_auto_acquire && request.todo.role === "user" && + request.todo.task_class === "user_gate"; + if (autoAcquire && !effective && !timeActive) { + if (!ownerEligible(request, request.actor_agent_id)) { + return result("rejected", "handoff_mode_requires_lease", { + authority_mode: authorityMode, + lease_fence: "auto_acquire", + }); + } + const version = (lease?.present ? lease.version : 0) + 1; + const epoch = (lease?.lease_epoch ?? 0) + 1; + return result("apply", "terminal_transition", { + authority_mode: authorityMode, + lease_fence: "auto_acquire", + next_todo_status: "done", + next_lease: { + present: true, + active: false, + status: "released", + owner: request.actor_agent_id, + idempotency_key: request.lease_idempotency_key ?? `auto-${request.todo.todo_id}`, + version, + lease_epoch: epoch, + write_scopes: [], + acquire_ttl_seconds: 2700, + }, + }); + } + if (!effective) { + if (request.handoff_mode === "hard_lease" && !delegated) { + return result("rejected", timeActive + ? "handoff_mode_lease_claim_divergence" + : "handoff_mode_requires_lease", { + authority_mode: authorityMode, + lease_fence: "required", + }); + } + if (explicitFence) { + return result("rejected", "lease_not_active", { authority_mode: authorityMode }); + } + return result("apply", "terminal_transition", { + authority_mode: authorityMode, + lease_fence: delegated && request.handoff_mode === "hard_lease" + ? "delegated_override" + : "not_required", + next_todo_status: "done", + }); + } + if (request.lease_idempotency_key === null) { + return result("rejected", "lease_fence_required", { + authority_mode: authorityMode, + lease_fence: "required", + }); + } + if (lease!.owner !== request.actor_agent_id || + lease!.idempotency_key !== request.lease_idempotency_key) { + return result("rejected", "lease_cas_mismatch", { + authority_mode: authorityMode, + lease_fence: "required", + }); + } + if (request.lease_expected_version === null) { + return result("rejected", "version_required", { + authority_mode: authorityMode, + lease_fence: "required", + }); + } + if (lease!.version !== request.lease_expected_version) { + return result("conflict", "version_mismatch", { + authority_mode: authorityMode, + lease_fence: "required", + }); + } + return result("apply", "terminal_transition", { + authority_mode: authorityMode, + lease_fence: "required", + next_todo_status: "done", + next_lease: { ...lease!, active: false, status: "released" }, + }); +} + +/** One semantic owner for complete/supersede authority and the terminal lease fence. */ +export function evaluateCoordinationTodoTerminalDecision( + value: unknown, +): CoordinationTodoTerminalDecisionResult { + const request = decodeRequest(value); + const authorityResult = authority(request); + if ("outcome" in authorityResult) return authorityResult; + if (request.todo.status === "done") { + return result("no_change", "terminal_replay", { + authority_mode: authorityResult.mode, + ownership_gate: authorityResult.ownershipGate, + idempotent: true, + }); + } + return terminalFence(request, authorityResult.mode); +} diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts new file mode 100644 index 0000000000..5993bdcacf --- /dev/null +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -0,0 +1,1256 @@ +import { createHash } from "node:crypto"; + +import type { JsonObject } from "../effect_program.ts"; +import type { + AuthorityStore, + AuthorityStoreCommit, + AuthorityStoreReceiptResult, +} from "./authority_store.ts"; +import { + AuthorityStoreProtocolError, + canonicalAuthorityObject, + canonicalAuthoritySha256, + requireAuthorityStoreId, +} from "./authority_store_codec.ts"; +import { + TODO_DOMAIN_ITEM_SCHEMA, + TODO_DOMAIN_READ_RECORD_SCHEMA, + TODO_ITEM_SCHEMA, + canonicalCoordinationTodoRecord, + canonicalTodoDomainRecord, +} from "./coordination_state_contract.ts"; +import { + indexCoordinationProjection, + prepareCoordinationProjectionCommit, + validateCoordinationTodoReadModel, + type CoordinationProjectionMutation, +} from "./coordination_projection.ts"; +import { + compactPythonWhitespace, + normalizeRegisteredTodoAgents, + normalizeTodoAgent, +} from "./todo_agents.ts"; +import { + evaluateCoordinationTodoTerminalDecision, + type CoordinationTodoTerminalDecisionResult, +} from "./todo_terminal_decision.ts"; +import { + reduceTodoCompletionTransaction, + TODO_COMPLETION_TRANSACTION_REQUEST_SCHEMA, +} from "../todos/completion_transaction.ts"; +import { + TASK_LEASE_SCHEMA_VERSION, + leaseEpoch, + leaseInteger, + normalizeAgent, + normalizeWriteScopes, +} from "../work_items/task_lease_acquire.ts"; +import { selectCoordinationTodoArchive } from "./todo_archive_selection.ts"; +import { + deriveCoordinationTodoSuccessorProposals, + TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, +} from "./todo_successor_derivation.ts"; + +export const COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA = + "loopx_coordination_todo_terminal_lifecycle_result_v0"; +export const COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA = + "loopx_coordination_todo_terminal_lifecycle_receipt_v0"; +export const COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA = + "loopx_coordination_todo_archive_result_v0"; +export const COORDINATION_TODO_ARCHIVE_RECEIPT_SCHEMA = + "loopx_coordination_todo_archive_receipt_v0"; + +const TERMINAL_COMMANDS = ["complete", "supersede"] as const; +const TODO_ROLES = ["agent", "user"] as const; +const DECISION_OUTCOMES = ["approve", "reject", "cancel"] as const; +const COMPLETION_IDENTITY_SOURCES = [ + "turn_settlement", + "unscoped_completion", + "lifecycle_reentry", +] as const; +const USER_TODO_TASK_CLASSES = new Set(["user_action", "user_gate"]); + +type TerminalCommand = typeof TERMINAL_COMMANDS[number]; +type TodoRole = typeof TODO_ROLES[number]; +type CompletionIdentitySource = typeof COMPLETION_IDENTITY_SOURCES[number]; + +export interface CoordinationTodoTerminalLifecycleInput { + readonly goal_id: string; + readonly todo_id: string; + readonly expected_role: TodoRole | null; + readonly command: TerminalCommand; + readonly actor_agent_id: string | null; + readonly registered_agents: readonly string[]; + readonly lifecycle_grants: readonly JsonObject[]; + readonly authority_reason: string | null; + readonly decision_outcome: typeof DECISION_OUTCOMES[number] | null; + readonly operation_id: string; + readonly lease_idempotency_key: string | null; + readonly lease_expected_version: number | null; + readonly allow_user_gate_auto_acquire: boolean; + readonly requested_no_followup: boolean; + readonly requested_completion_turn_key: string | null; + readonly requested_completion_identity_source: CompletionIdentitySource | null; + readonly linked_successor_todo_ids: readonly string[]; + readonly successor_intents: readonly JsonObject[]; + readonly note: string | null; + readonly evidence: string | null; + readonly reason: string | null; + readonly clear_claim: boolean; + readonly validation_declaration: JsonObject | null; + readonly validation_receipt: JsonObject | null; + readonly completion_policy_request: JsonObject | null; + readonly dry_run: boolean; + readonly now: Date; +} + +export interface CoordinationTodoArchiveInput { + readonly goal_id: string; + readonly role: TodoRole; + readonly max_active_done: number; + readonly operation_id: string; + readonly dry_run: boolean; + readonly now: Date; +} + +export type CoordinationTodoTerminalLifecycleResult = JsonObject & { + readonly schema_version: typeof COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA; +}; +export type CoordinationTodoArchiveResult = JsonObject & { + readonly schema_version: typeof COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA; +}; + +type CoordinationTodoTerminalFailureKind = + | "decision_rejection" + | "protocol_failure"; + +function optionalString(value: unknown, label: string): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") { + throw new AuthorityStoreProtocolError(`${label} must be a string or null`); + } + return value; +} + +function optionalAgent(value: unknown, label: string): string | null { + const candidate = optionalString(value, label); + return candidate === null ? null : normalizeTodoAgent(candidate, label); +} + +function optionalSafeInteger(value: unknown, label: string): number | null { + if (value === null || value === undefined) return null; + if (!Number.isSafeInteger(value) || Number(value) < 0) { + throw new AuthorityStoreProtocolError(`${label} must be a non-negative safe integer or null`); + } + return Number(value); +} + +function requireBoolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new AuthorityStoreProtocolError(`${label} must be a boolean`); + } + return value; +} + +function optionalBoolean(value: unknown, label: string): boolean | null { + if (value === null || value === undefined) return null; + return requireBoolean(value, label); +} + +function requireDate(value: unknown, label: string): Date { + if (!(value instanceof Date) || Number.isNaN(value.valueOf())) { + throw new AuthorityStoreProtocolError(`${label} must be a valid Date`); + } + return value; +} + +function requireLiteral( + value: unknown, + values: readonly T[], + label: string, +): T { + if (typeof value !== "string" || !values.includes(value as T)) { + throw new AuthorityStoreProtocolError(`${label} must be one of: ${values.join(", ")}`); + } + return value as T; +} + +function uniqueIds(value: readonly string[], label: string): string[] { + if (!Array.isArray(value)) { + throw new AuthorityStoreProtocolError(`${label} must be an array`); + } + const values = value.map((item, index) => + requireAuthorityStoreId(item, `${label}[${index}]`)); + if (new Set(values).size !== values.length) { + throw new AuthorityStoreProtocolError(`${label} must contain unique ids`); + } + return values; +} + +function lifecycleGrants(value: readonly JsonObject[]): JsonObject[] { + if (!Array.isArray(value)) { + throw new AuthorityStoreProtocolError("lifecycle_grants must be an array"); + } + return value.map((grant, index) => + canonicalAuthorityObject(grant, `lifecycle_grants[${index}]`)); +} + +function requireRegisteredSuccessorAgent( + value: unknown, + label: string, + registeredAgents: readonly string[], +): string | null { + const agent = optionalAgent(value, label); + if (agent !== null && !registeredAgents.includes(agent)) { + throw new AuthorityStoreProtocolError(`${label} is not a registered agent`); + } + return agent; +} + +function validateSuccessorSemantics( + successors: readonly JsonObject[], + registeredAgents: readonly string[], +): void { + for (const role of TODO_ROLES) { + if (successors.filter((successor) => successor.role === role).length > 1) { + throw new AuthorityStoreProtocolError( + `terminal lifecycle permits at most one generated ${role} successor`, + ); + } + } + for (const successor of successors) { + const role = requireLiteral(successor.role, TODO_ROLES, "successor.role"); + const taskClass = optionalString(successor.task_class, "successor.task_class"); + const claimedBy = requireRegisteredSuccessorAgent( + successor.claimed_by, + "successor.claimed_by", + registeredAgents, + ); + const boundAgent = requireRegisteredSuccessorAgent( + successor.bound_agent, + "successor.bound_agent", + registeredAgents, + ); + const blocksAgent = requireRegisteredSuccessorAgent( + successor.blocks_agent, + "successor.blocks_agent", + registeredAgents, + ); + const goalBound = optionalBoolean(successor.goal_bound, "successor.goal_bound"); + const globalGate = optionalBoolean(successor.global_gate, "successor.global_gate"); + if (role === "agent") { + if (USER_TODO_TASK_CLASSES.has(taskClass ?? "") || boundAgent !== null || + blocksAgent !== null || goalBound === true || globalGate === true) { + throw new AuthorityStoreProtocolError( + "generated Agent successor carries user-only task or binding semantics", + ); + } + continue; + } + if (!USER_TODO_TASK_CLASSES.has(taskClass ?? "")) { + throw new AuthorityStoreProtocolError( + "generated User successor requires task_class user_action or user_gate", + ); + } + if (claimedBy !== null) { + throw new AuthorityStoreProtocolError( + "generated User successor cannot carry claimed_by ownership", + ); + } + if (boundAgent !== null && goalBound === true) { + throw new AuthorityStoreProtocolError( + "generated User successor cannot be both agent-bound and goal-bound", + ); + } + if (taskClass === "user_action" && (blocksAgent !== null || globalGate === true)) { + throw new AuthorityStoreProtocolError( + "generated user_action successor cannot carry blocking gate scope", + ); + } + if (taskClass === "user_gate") { + if (globalGate === true && + (blocksAgent !== null || boundAgent !== null || goalBound !== true)) { + throw new AuthorityStoreProtocolError( + "goal-wide User gate successor requires goal_bound and no Agent binding", + ); + } + if (blocksAgent !== null && + (goalBound === true || boundAgent !== blocksAgent)) { + throw new AuthorityStoreProtocolError( + "Agent-scoped User gate successor must bind to its blocks_agent", + ); + } + if (registeredAgents.length > 1 && blocksAgent === null && globalGate !== true) { + throw new AuthorityStoreProtocolError( + "multi-agent User gate successor requires an explicit blocking scope", + ); + } + } + if (registeredAgents.length > 1 && boundAgent === null && goalBound !== true) { + throw new AuthorityStoreProtocolError( + "multi-agent User successor requires an explicit Agent or Goal binding", + ); + } + } +} + +function normalizeTerminalInput( + raw: CoordinationTodoTerminalLifecycleInput, +): CoordinationTodoTerminalLifecycleInput { + const registeredAgents = normalizeRegisteredTodoAgents(raw.registered_agents); + if (!Array.isArray(raw.successor_intents)) { + throw new AuthorityStoreProtocolError("successor_intents must be an array"); + } + const successorIntents = raw.successor_intents.map((intent, index) => + canonicalAuthorityObject(intent, `successor_intents[${index}]`)); + return { + ...raw, + goal_id: requireAuthorityStoreId(raw.goal_id, "goal id"), + todo_id: requireAuthorityStoreId(raw.todo_id, "todo id"), + operation_id: requireAuthorityStoreId(raw.operation_id, "operation id"), + expected_role: raw.expected_role === null + ? null : requireLiteral(raw.expected_role, TODO_ROLES, "expected_role"), + command: requireLiteral(raw.command, TERMINAL_COMMANDS, "command"), + actor_agent_id: optionalAgent(raw.actor_agent_id, "actor_agent_id"), + registered_agents: registeredAgents, + lifecycle_grants: lifecycleGrants(raw.lifecycle_grants), + authority_reason: optionalString(raw.authority_reason, "authority_reason"), + decision_outcome: raw.decision_outcome === null + ? null : requireLiteral(raw.decision_outcome, DECISION_OUTCOMES, "decision_outcome"), + lease_idempotency_key: optionalString(raw.lease_idempotency_key, "lease_idempotency_key"), + lease_expected_version: optionalSafeInteger( + raw.lease_expected_version, + "lease_expected_version", + ), + allow_user_gate_auto_acquire: requireBoolean( + raw.allow_user_gate_auto_acquire, + "allow_user_gate_auto_acquire", + ), + requested_no_followup: requireBoolean(raw.requested_no_followup, "requested_no_followup"), + requested_completion_turn_key: optionalString( + raw.requested_completion_turn_key, + "requested_completion_turn_key", + ), + requested_completion_identity_source: + raw.requested_completion_identity_source === null + ? null + : requireLiteral( + raw.requested_completion_identity_source, + COMPLETION_IDENTITY_SOURCES, + "requested_completion_identity_source", + ), + linked_successor_todo_ids: uniqueIds( + raw.linked_successor_todo_ids, + "linked_successor_todo_ids", + ), + successor_intents: successorIntents, + note: optionalString(raw.note, "note"), + evidence: optionalString(raw.evidence, "evidence"), + reason: optionalString(raw.reason, "reason"), + clear_claim: requireBoolean(raw.clear_claim, "clear_claim"), + validation_declaration: raw.validation_declaration === null + ? null : canonicalAuthorityObject( + raw.validation_declaration, + "validation_declaration", + ), + validation_receipt: raw.validation_receipt === null + ? null : canonicalAuthorityObject(raw.validation_receipt, "validation_receipt"), + completion_policy_request: raw.completion_policy_request === null + ? null + : canonicalAuthorityObject(raw.completion_policy_request, "completion_policy_request"), + dry_run: requireBoolean(raw.dry_run, "dry_run"), + now: requireDate(raw.now, "now"), + }; +} + +function terminalFailure( + code: string, + reason: string, + detail: JsonObject = {}, + kind: CoordinationTodoTerminalFailureKind = "protocol_failure", +): CoordinationTodoTerminalLifecycleResult { + return { + ...detail, + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + status: "failed", + changed: false, + failure_kind: kind, + reason_code: code, + reason, + }; +} + +function archiveFailure(code: string, reason: string): CoordinationTodoArchiveResult { + return { + schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, + status: "failed", + changed: false, + reason_code: code, + reason, + }; +} + +function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): string { + const completionPolicyIdentity = input.completion_policy_request === null + ? null + : { + claimed_by: input.completion_policy_request.claimed_by ?? null, + next_claimed_by: input.completion_policy_request.next_claimed_by ?? null, + next_agent_todo: input.completion_policy_request.next_agent_todo ?? null, + next_action_kind: input.completion_policy_request.next_action_kind ?? null, + next_continuation_policy: + input.completion_policy_request.next_continuation_policy ?? null, + next_excluded_agents: + input.completion_policy_request.next_excluded_agents ?? [], + self_merged: input.completion_policy_request.self_merged ?? false, + }; + return canonicalAuthoritySha256({ + goal_id: input.goal_id, + todo_id: input.todo_id, + expected_role: input.expected_role, + command: input.command, + actor_agent_id: input.actor_agent_id, + authority_reason: input.authority_reason, + decision_outcome: input.decision_outcome, + lease_idempotency_key: input.lease_idempotency_key, + lease_expected_version: input.lease_expected_version, + allow_user_gate_auto_acquire: input.allow_user_gate_auto_acquire, + requested_no_followup: input.requested_no_followup, + requested_completion_turn_key: input.requested_completion_turn_key, + requested_completion_identity_source: input.requested_completion_identity_source, + linked_successor_todo_ids: input.linked_successor_todo_ids, + successor_intents: input.successor_intents, + clear_claim: input.clear_claim, + completion_policy_request: completionPolicyIdentity, + validation_declaration_sha256: input.validation_declaration === null + ? null : canonicalAuthoritySha256(input.validation_declaration), + dry_run: input.dry_run, + }); +} + +function replayTerminal( + receipt: AuthorityStoreReceiptResult, + input: CoordinationTodoTerminalLifecycleInput, + requestSha: string, + status: "replayed" | "applied" | "recovered", +): CoordinationTodoTerminalLifecycleResult | null { + if (receipt.status === "missing") return null; + if (receipt.status !== "found") { + return { + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + ...receipt, + changed: false, + }; + } + const original = receipt.receipts[0]; + if (receipt.receipts.length !== 1 || + original?.schema_version !== COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA || + original.operation_id !== input.operation_id || original.goal_id !== input.goal_id || + original.todo_id !== input.todo_id || original.command !== input.command || + original.request_sha256 !== requestSha) { + return terminalFailure( + "coordination_operation_identity_mismatch", + "operation id already names a different Todo terminal lifecycle request", + {}, + "decision_rejection", + ); + } + const result = canonicalAuthorityObject(original.result, "terminal lifecycle receipt result"); + return { + ...result, + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + status: status === "applied" && result.changed === false ? "no_change" : status, + changed: status !== "replayed" && result.changed === true, + provider_revision: receipt.provider_revision, + cursor: receipt.cursor, + original_receipt: original, + projection_delivery: result.changed === true ? "pending" : "not_required", + projection_source: "committed_authority_journal", + }; +} + +async function commitTerminalResult( + store: AuthorityStore, + input: CoordinationTodoTerminalLifecycleInput, + requestSha: string, + head: Extract>, {status: "loaded"}>, + result: JsonObject, + mutations: readonly CoordinationProjectionMutation[], +): Promise { + if (input.dry_run) { + return { + ...result, + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + status: result.changed === true ? "planned" : "no_change", + dry_run: true, + provider_revision: head.provider_revision, + cursor: head.cursor, + }; + } + const commit: AuthorityStoreCommit = mutations.length > 0 + ? prepareCoordinationProjectionCommit({ + goal_id: input.goal_id, + operation_id: input.operation_id, + expected_provider_revision: head.provider_revision, + projection: head.head, + mutations: [...mutations], + }) + : { + operation_id: input.operation_id, + expected_provider_revision: head.provider_revision, + next_projection: head.head, + events: [], + receipts: [], + }; + commit.receipts = [{ + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA, + operation_id: input.operation_id, + goal_id: input.goal_id, + todo_id: input.todo_id, + command: input.command, + request_sha256: requestSha, + result, + }]; + const committed = await store.commitAuthority(commit); + const readback = replayTerminal( + await store.readReceipt(input.operation_id), + input, + requestSha, + committed.status === "applied" ? "applied" : "recovered", + ); + if (readback !== null) return readback; + return committed.status === "applied" + ? terminalFailure( + "coordination_commit_readback_mismatch", + "applied terminal lifecycle mutation lacks its durable receipt", + ) + : { + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + ...committed, + changed: false, + }; +} + +function todoFact(todo: JsonObject): JsonObject { + return { + todo_id: todo.todo_id, + status: todo.status, + role: todo.role, + task_class: todo.task_class ?? null, + claimed_by: todo.claimed_by ?? null, + excluded_agents: todo.excluded_agents ?? [], + bound_agent: todo.bound_agent ?? null, + blocks_agent: todo.blocks_agent ?? null, + decision_scope: todo.decision_scope ?? null, + required_decision_scopes: todo.required_decision_scopes ?? [], + unblocks_todo_id: todo.unblocks_todo_id ?? null, + }; +} + +function activeLease(lease: JsonObject | undefined, now: Date): boolean { + if (lease === undefined || lease.status !== "active" || typeof lease.expires_at !== "string") { + return false; + } + const expiresAt = new Date(lease.expires_at); + return !Number.isNaN(expiresAt.valueOf()) && expiresAt.valueOf() > now.valueOf(); +} + +function leaseFact(lease: JsonObject | undefined, now: Date): JsonObject | null { + if (lease === undefined) return null; + return { + present: true, + active: activeLease(lease, now), + status: typeof lease.status === "string" ? lease.status : null, + owner: normalizeAgent(lease.owner), + idempotency_key: typeof lease.idempotency_key === "string" + ? lease.idempotency_key : null, + version: leaseInteger(lease, "version") ?? 0, + lease_epoch: leaseEpoch(lease), + write_scopes: normalizeWriteScopes(lease.write_scopes), + acquire_ttl_seconds: leaseInteger(lease, "acquire_ttl_seconds"), + }; +} + +function releasedLease( + current: JsonObject | undefined, + decision: CoordinationTodoTerminalDecisionResult, + input: CoordinationTodoTerminalLifecycleInput, +): JsonObject | null { + if (decision.next_lease === null) return null; + const observedAt = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); + if (current !== undefined) { + return { + ...current, + status: "released", + updated_at: observedAt, + }; + } + return { + schema_version: TASK_LEASE_SCHEMA_VERSION, + goal_id: input.goal_id, + todo_id: input.todo_id, + owner: decision.next_lease.owner, + idempotency_key: decision.next_lease.idempotency_key, + write_scopes: decision.next_lease.write_scopes, + acquire_ttl_seconds: decision.next_lease.acquire_ttl_seconds, + version: decision.next_lease.version, + lease_epoch: decision.next_lease.lease_epoch, + acquired_at: observedAt, + updated_at: observedAt, + expires_at: observedAt, + status: "released", + }; +} + +function successorCandidate( + todo: JsonObject, + actor: string | null, + now: Date, + domainReadModel: boolean, + completionPolicy: JsonObject | null, +): JsonObject { + const policyOwned: JsonObject = todo.role === "agent" && completionPolicy !== null + ? { + ...(completionPolicy.effective_next_claimed_by === null + ? {} + : {claimed_by: completionPolicy.effective_next_claimed_by}), + excluded_agents: completionPolicy.effective_next_excluded_agents, + } + : {}; + const candidate: JsonObject = { + ...todo, + ...policyOwned, + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + created_by: todo.created_by ?? actor, + last_actor_agent_id: actor, + updated_at: now.toISOString().replace(/\.\d{3}Z$/u, "Z"), + }; + if (todo.role === "agent" && completionPolicy !== null && + completionPolicy.effective_next_claimed_by === null) { + delete candidate.claimed_by; + } + const created = canonicalTodoDomainRecord(candidate, "terminal successor"); + return domainReadModel ? created : canonicalCoordinationTodoRecord({ + ...created, + schema_version: TODO_ITEM_SCHEMA, + source_section: created.role === "agent" ? "Agent Todo" : "User Todo", + }, "terminal successor compatibility record"); +} + +function generatedSuccessorId( + predecessorId: string, + successor: JsonObject, + offset: number, +): string { + const role = String(successor.role); + const section = role === "agent" ? "Agent Todo" : "User Todo"; + const identity = [ + role, + section, + `${predecessorId}:${offset}`, + compactPythonWhitespace(String(successor.text ?? "")), + ].join("|"); + return `todo_${createHash("sha1").update(identity, "utf8").digest("hex").slice(0, 12)}`; +} + +function materializeSuccessorProposals( + predecessorId: string, + proposals: readonly JsonObject[], +): JsonObject[] { + const successors = proposals.map((proposal, index) => + canonicalTodoDomainRecord({ + ...proposal, + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: generatedSuccessorId(predecessorId, proposal, index + 1), + status: "open", + done: false, + archive_state: "active", + }, `derived successors[${index}]`)); + const successorIds = successors.map((successor) => String(successor.todo_id)); + if (new Set(successorIds).size !== successorIds.length) { + throw new AuthorityStoreProtocolError("derived successors must contain unique Todo ids"); + } + return successors; +} + +function terminalTarget( + todo: JsonObject, + input: CoordinationTodoTerminalLifecycleInput, + completion: ReturnType | null, + successorIds: readonly string[], +): { todo: JsonObject; clear_fields: string[] } { + const updatedAt = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); + const next: JsonObject = { + ...todo, + status: "done", + done: true, + completed_at: todo.completed_at ?? updatedAt, + updated_at: updatedAt, + last_actor_agent_id: input.actor_agent_id, + ...(input.note === null ? {} : {note: input.note}), + ...(input.evidence === null ? {} : {evidence: input.evidence}), + ...(input.reason === null ? {} : {reason: input.reason}), + ...(input.decision_outcome === null ? {} : {decision_outcome: input.decision_outcome}), + ...(input.requested_no_followup ? {no_followup: true} : {}), + ...(successorIds.length === 0 ? {} : {successor_todo_ids: successorIds}), + }; + if (input.command === "supersede") { + next.note = input.note ?? "superseded"; + // Legacy/event projection treats supersede as a terminal completion that + // leaves the Goal itself active. Keep that durable continuation marker so + // status/quota consumers do not infer an unclassified terminal record. + next.completion_continuation = "active_goal"; + if (successorIds.length > 0) next.superseded_by = successorIds[0]; + } + if (completion?.decision === "commit") { + Object.assign(next, completion.metadata_updates); + if (completion.completion_policy?.effective_claimed_by !== null && + completion.completion_policy?.effective_claimed_by !== undefined) { + next.claimed_by = completion.completion_policy.effective_claimed_by; + } + if (completion.completion_identity_key !== null) { + next.completion_turn_key = completion.completion_identity_key; + } + } + const clearFields: string[] = []; + if (input.clear_claim && "claimed_by" in next) { + delete next.claimed_by; + clearFields.push("claimed_by"); + } + if (todo.schema_version === TODO_DOMAIN_ITEM_SCHEMA) { + canonicalTodoDomainRecord(next, "terminal Todo"); + } else { + canonicalCoordinationTodoRecord(next, "terminal Todo"); + } + return {todo: next, clear_fields: clearFields}; +} + +function semanticDuplicate( + todos: ReadonlyMap, + successor: JsonObject, +): JsonObject | undefined { + return [...todos.values()].find((todo) => + todo.role === successor.role && todo.archive_state === "active" && + todo.status !== "done" && todo.status !== "deferred" && todo.text === successor.text); +} + +/** + * Execute complete/supersede as one provider-neutral CAS transaction. + * + * Authorization, lease release, completion policy, successor creation, + * durable replay identity, and projection intent are decided before the + * provider sees a commit. Python may execute the typed validation effect and + * project the committed head; it is not a second semantic owner. + */ +export async function executeCoordinationTodoTerminalLifecycle( + store: AuthorityStore, + rawInput: CoordinationTodoTerminalLifecycleInput, +): Promise { + let input: CoordinationTodoTerminalLifecycleInput; + try { + input = normalizeTerminalInput(rawInput); + } catch (error) { + return terminalFailure( + "invalid_coordination_todo_terminal_lifecycle", + error instanceof Error ? error.message : "invalid Todo terminal lifecycle request", + ); + } + const requestSha = terminalRequestSha(input); + const replay = replayTerminal( + await store.readReceipt(input.operation_id), + input, + requestSha, + "replayed", + ); + if (replay !== null) return replay; + + const head = await store.loadAuthority(); + if (head.status !== "loaded") { + return { + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + ...head, + changed: false, + }; + } + let projection: ReturnType; + let readModel: JsonObject; + try { + projection = indexCoordinationProjection(head.head, input.goal_id); + readModel = validateCoordinationTodoReadModel(head.head, input.goal_id); + } catch (error) { + return terminalFailure( + "invalid_coordination_projection", + error instanceof Error ? error.message : "invalid coordination projection", + ); + } + const todo = projection.todos.get(input.todo_id); + if (todo === undefined) { + return terminalFailure("todo_not_found", "Todo is missing from the canonical provider head", { + todo_id: input.todo_id, + }, "decision_rejection"); + } + if (input.expected_role !== null && todo.role !== input.expected_role) { + return terminalFailure( + "todo_role_mismatch", + "Todo does not have the requested role", + {}, + "decision_rejection", + ); + } + if (todo.archive_state !== "active") { + return terminalFailure( + "todo_archived", + "Todo terminal lifecycle requires an active Todo", + {}, + "decision_rejection", + ); + } + const handoffMode = typeof head.head.handoff_mode === "string" + ? head.head.handoff_mode : "legacy"; + if (!["legacy", "soft_claim", "hard_lease"].includes(handoffMode)) { + return terminalFailure("invalid_handoff_mode", "canonical projection has an invalid handoff mode"); + } + const decisionTarget = typeof todo.unblocks_todo_id === "string" + ? projection.todos.get(todo.unblocks_todo_id) : undefined; + let authority: CoordinationTodoTerminalDecisionResult; + try { + authority = evaluateCoordinationTodoTerminalDecision({ + schema_version: "loopx_coordination_todo_terminal_decision_request_v0", + command: input.command, + handoff_mode: handoffMode, + registered_agents: input.registered_agents, + lifecycle_grants: input.lifecycle_grants, + todo: todoFact(todo), + decision_target: decisionTarget === undefined ? null : todoFact(decisionTarget), + lease: leaseFact(projection.leases.get(input.todo_id), input.now), + actor_agent_id: input.actor_agent_id, + authority_action: input.command, + authority_reason: input.authority_reason, + decision_outcome: input.decision_outcome, + lease_idempotency_key: input.lease_idempotency_key, + lease_expected_version: input.lease_expected_version, + allow_user_gate_auto_acquire: input.allow_user_gate_auto_acquire, + }); + } catch (error) { + return terminalFailure( + "invalid_coordination_todo_terminal_lifecycle", + error instanceof Error ? error.message : "invalid Todo terminal decision", + ); + } + if (authority.outcome === "rejected" || authority.outcome === "conflict") { + return terminalFailure( + authority.code, + `canonical Todo terminal lifecycle rejected the request: ${authority.code}`, + {terminal_decision: authority}, + "decision_rejection", + ); + } + + let completion: ReturnType | null = null; + if (input.command === "complete") { + const validationRequired = todo.completion_validation_required === true; + const validationSha256 = todo.completion_validation_sha256; + if (validationRequired) { + if (typeof validationSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(validationSha256)) { + return terminalFailure( + "completion_validation_identity_missing", + "canonical Todo requires validation but omits its declaration digest", + ); + } + if (input.validation_declaration === null) { + return terminalFailure( + "completion_validation_declaration_unavailable", + "private completion validation declaration is unavailable", + ); + } + if (canonicalAuthoritySha256(input.validation_declaration) !== validationSha256) { + return terminalFailure( + "completion_validation_declaration_mismatch", + "private completion validation declaration does not match canonical authority", + ); + } + } else if (input.validation_declaration !== null) { + return terminalFailure( + "completion_validation_declaration_forbidden", + "Todo without canonical validation authority cannot execute a private declaration", + ); + } + try { + completion = reduceTodoCompletionTransaction({ + schema_version: TODO_COMPLETION_TRANSACTION_REQUEST_SCHEMA, + goal_id: input.goal_id, + todo_id: input.todo_id, + projection_source: "materialized", + todo: input.validation_declaration === null + ? todo : {...todo, ...input.validation_declaration}, + requested_no_followup: input.requested_no_followup, + requested_completion_turn_key: input.requested_completion_turn_key, + requested_completion_identity_source: input.requested_completion_identity_source, + requested_has_successor: + input.successor_intents.length > 0 || input.linked_successor_todo_ids.length > 0, + dry_run: input.dry_run, + validation_receipt: input.validation_receipt, + completion_policy_request: input.completion_policy_request, + }); + } catch (error) { + return terminalFailure( + "invalid_todo_completion_transaction", + error instanceof Error ? error.message : "invalid Todo completion transaction", + ); + } + if (completion.decision === "execute_validation") { + return { + schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, + status: "execute_validation", + changed: false, + todo_id: input.todo_id, + command: input.command, + validation_effect: completion.validation_effect, + completion_identity_key: completion.completion_identity_key, + completion_identity_source: completion.completion_identity_source, + provider_revision: head.provider_revision, + cursor: head.cursor, + }; + } + if (completion.decision === "reject") { + return terminalFailure( + completion.failure.kind, + completion.failure.summary, + {validation_failure: completion.failure}, + ); + } + } else if (input.validation_declaration !== null || input.validation_receipt !== null || + input.completion_policy_request !== null) { + return terminalFailure( + "supersede_completion_payload_forbidden", + "supersede must not carry completion validation or policy payload", + ); + } + + if (authority.outcome === "no_change") { + if (input.successor_intents.length > 0) { + return terminalFailure( + "todo_terminal_successor_intent_after_completion", + "an already terminal Todo cannot accept a new generated successor intent", + {}, + "decision_rejection", + ); + } + const existingSuccessorIds = Array.isArray(todo.successor_todo_ids) + ? todo.successor_todo_ids.map((value, index) => + requireAuthorityStoreId(value, `todo.successor_todo_ids[${index}]`)) + : []; + return commitTerminalResult(store, input, requestSha, head, { + todo_id: input.todo_id, + command: input.command, + changed: false, + terminal_decision: authority, + successor_todo_ids: existingSuccessorIds, + generated_successor_todo_ids: [], + validation_receipt: null, + completion_policy: null, + completion_identity_key: + completion === null ? null : completion.completion_identity_key, + completion_identity_source: + completion === null ? null : completion.completion_identity_source, + completed_at: typeof todo.completed_at === "string" ? todo.completed_at : null, + }, []); + } + + const domainReadModel = readModel.schema_version === TODO_DOMAIN_READ_RECORD_SCHEMA; + const completionPolicy = completion?.decision === "commit" && + completion.completion_policy !== undefined + ? canonicalAuthorityObject(completion.completion_policy, "completion policy result") + : null; + if (completionPolicy !== null && + canonicalAuthoritySha256(completionPolicy.registered_agents) !== + canonicalAuthoritySha256(input.registered_agents)) { + return terminalFailure( + "completion_policy_registry_mismatch", + "completion policy registered_agents must match terminal authority facts", + ); + } + let successors: JsonObject[]; + try { + const proposals = deriveCoordinationTodoSuccessorProposals({ + schema_version: TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + command: input.command, + predecessor: todo, + registered_agents: input.registered_agents, + actor_agent_id: input.actor_agent_id, + completion_policy: completionPolicy, + successor_intents: input.successor_intents, + }); + successors = materializeSuccessorProposals(input.todo_id, proposals); + validateSuccessorSemantics(successors, input.registered_agents); + } catch (error) { + return terminalFailure( + "invalid_todo_successor_derivation", + error instanceof Error ? error.message : "invalid Todo successor derivation", + {}, + "decision_rejection", + ); + } + const generatedSuccessorIds = successors.map((successor) => String(successor.todo_id)); + const successorIds = [...new Set([ + ...input.linked_successor_todo_ids, + ...generatedSuccessorIds, + ])]; + for (const successorId of input.linked_successor_todo_ids) { + if (!projection.todos.has(successorId)) { + return terminalFailure( + "todo_successor_not_found", + "linked successor Todo is missing from the canonical provider head", + {todo_id: successorId}, + ); + } + } + for (const successorId of successorIds) { + if (successorId === input.todo_id) { + return terminalFailure("todo_successor_cycle", "Todo cannot name itself as a successor"); + } + } + if (completionPolicy !== null) { + const agentSuccessorIntents = input.successor_intents.filter( + (successor) => successor.role === "agent", + ); + if (agentSuccessorIntents.length > 1) { + return terminalFailure( + "completion_policy_successor_ambiguity", + "completion policy currently permits at most one generated Agent successor", + ); + } + const requestedNextAgentTodo = input.completion_policy_request?.next_agent_todo; + if ((requestedNextAgentTodo === null || requestedNextAgentTodo === undefined) !== + (agentSuccessorIntents.length === 0) || + (agentSuccessorIntents.length === 1 && + requestedNextAgentTodo !== agentSuccessorIntents[0]!.text)) { + return terminalFailure( + "completion_policy_successor_mismatch", + "completion policy next_agent_todo must identify the generated Agent successor", + ); + } + } + const userSuccessor = successors.find((successor) => successor.role === "user"); + if (input.command === "complete" && input.registered_agents.length > 1 && + userSuccessor !== undefined) { + const completingAgent = completionPolicy?.effective_claimed_by; + if (typeof completingAgent !== "string" || userSuccessor.bound_agent !== completingAgent || + (userSuccessor.task_class === "user_gate" && + userSuccessor.blocks_agent !== completingAgent)) { + return terminalFailure( + "completion_user_successor_binding_mismatch", + "multi-agent completion must bind its User successor to the effective completing Agent", + ); + } + } + const successorCandidates: JsonObject[] = []; + for (const successor of successors) { + const successorId = String(successor.todo_id); + if (projection.todos.has(successorId)) { + return terminalFailure("todo_already_exists", "terminal successor Todo id already exists", { + todo_id: successorId, + }); + } + const duplicate = semanticDuplicate(projection.todos, successor); + if (duplicate !== undefined) { + return terminalFailure( + "todo_semantic_duplicate_conflict", + "an active Todo with the same role/text already exists", + {todo_id: duplicate.todo_id}, + ); + } + successorCandidates.push(successorCandidate( + successor, + input.actor_agent_id, + input.now, + domainReadModel, + completionPolicy, + )); + } + + const currentLease = projection.leases.get(input.todo_id); + const released = releasedLease(currentLease, authority, input); + const target = terminalTarget(todo, input, completion, successorIds); + const changed = authority.outcome === "apply"; + const result: JsonObject = { + todo_id: input.todo_id, + command: input.command, + changed, + terminal_decision: authority, + successor_todo_ids: successorIds, + generated_successor_todo_ids: generatedSuccessorIds, + generated_successors: successorCandidates, + validation_receipt: + completion?.decision === "commit" ? completion.validation_receipt : null, + completion_policy: completionPolicy, + completion_identity_key: + completion === null ? null : completion.completion_identity_key, + completion_identity_source: + completion === null ? null : completion.completion_identity_source, + completed_at: target.todo.completed_at, + }; + const mutations: CoordinationProjectionMutation[] = changed ? [ + {kind: "todo_upsert", todo: target.todo, clear_fields: target.clear_fields}, + ...successorCandidates.map((successor) => ({kind: "todo_upsert" as const, todo: successor})), + ...(released === null ? [] : [{kind: "lease_upsert" as const, lease: released}]), + ] : []; + return commitTerminalResult(store, input, requestSha, head, result, mutations); +} + +function normalizeArchiveInput(raw: CoordinationTodoArchiveInput): CoordinationTodoArchiveInput { + if (!Number.isSafeInteger(raw.max_active_done) || raw.max_active_done < 0) { + throw new AuthorityStoreProtocolError("max_active_done must be a non-negative safe integer"); + } + return { + ...raw, + goal_id: requireAuthorityStoreId(raw.goal_id, "goal id"), + role: requireLiteral(raw.role, TODO_ROLES, "role"), + operation_id: requireAuthorityStoreId(raw.operation_id, "operation id"), + dry_run: requireBoolean(raw.dry_run, "dry_run"), + now: requireDate(raw.now, "now"), + }; +} + +function replayArchive( + receipt: AuthorityStoreReceiptResult, + input: CoordinationTodoArchiveInput, + requestSha: string, + status: "replayed" | "applied" | "recovered", +): CoordinationTodoArchiveResult | null { + if (receipt.status === "missing") return null; + if (receipt.status !== "found") { + return {schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, ...receipt, changed: false}; + } + const original = receipt.receipts[0]; + if (receipt.receipts.length !== 1 || + original?.schema_version !== COORDINATION_TODO_ARCHIVE_RECEIPT_SCHEMA || + original.operation_id !== input.operation_id || original.goal_id !== input.goal_id || + original.request_sha256 !== requestSha) { + return archiveFailure( + "coordination_operation_identity_mismatch", + "operation id already names a different Todo archive request", + ); + } + const result = canonicalAuthorityObject(original.result, "Todo archive receipt result"); + return { + ...result, + schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, + status, + changed: status !== "replayed" && result.changed === true, + provider_revision: receipt.provider_revision, + cursor: receipt.cursor, + original_receipt: original, + projection_delivery: result.changed === true ? "pending" : "not_required", + projection_source: "committed_authority_journal", + }; +} + +/** Archive the oldest canonical completed records while preserving standing decisions. */ +export async function executeCoordinationTodoArchiveCompleted( + store: AuthorityStore, + rawInput: CoordinationTodoArchiveInput, +): Promise { + let input: CoordinationTodoArchiveInput; + try { + input = normalizeArchiveInput(rawInput); + } catch (error) { + return archiveFailure( + "invalid_coordination_todo_archive", + error instanceof Error ? error.message : "invalid Todo archive request", + ); + } + const requestSha = canonicalAuthoritySha256({ + goal_id: input.goal_id, + role: input.role, + max_active_done: input.max_active_done, + dry_run: input.dry_run, + }); + const replay = replayArchive( + await store.readReceipt(input.operation_id), input, requestSha, "replayed", + ); + if (replay !== null) return replay; + const head = await store.loadAuthority(); + if (head.status !== "loaded") { + return {schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, ...head, changed: false}; + } + let projection: ReturnType; + try { + projection = indexCoordinationProjection(head.head, input.goal_id); + validateCoordinationTodoReadModel(head.head, input.goal_id); + } catch (error) { + return archiveFailure( + "invalid_coordination_projection", + error instanceof Error ? error.message : "invalid coordination projection", + ); + } + const selection = selectCoordinationTodoArchive({ + role: input.role, + max_active_done: input.max_active_done, + todos: projection.todo_ids.map((todoId) => projection.todos.get(todoId)!), + }); + const moved = selection.moved_todo_ids.map((todoId) => projection.todos.get(todoId)!); + const updatedAt = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); + const result: JsonObject = { + role: selection.role, + changed: moved.length > 0, + active_done_before: selection.active_done_before, + active_done_after: selection.active_done_after, + max_active_done: selection.max_active_done, + moved_count: selection.moved_count, + moved_todo_ids: selection.moved_todo_ids, + retained_standing_decision_count: selection.retained_standing_decision_count, + }; + if (input.dry_run) { + return { + ...result, + schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, + status: moved.length > 0 ? "planned" : "no_change", + dry_run: true, + provider_revision: head.provider_revision, + cursor: head.cursor, + }; + } + if (moved.length === 0) { + return { + ...result, + schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, + status: "no_change", + dry_run: false, + provider_revision: head.provider_revision, + cursor: head.cursor, + }; + } + const mutations: CoordinationProjectionMutation[] = moved.map((todo) => ({ + kind: "todo_upsert", + todo: {...todo, archive_state: "archive", updated_at: updatedAt}, + })); + const commit: AuthorityStoreCommit = prepareCoordinationProjectionCommit({ + goal_id: input.goal_id, + operation_id: input.operation_id, + expected_provider_revision: head.provider_revision, + projection: head.head, + mutations, + }); + commit.receipts = [{ + schema_version: COORDINATION_TODO_ARCHIVE_RECEIPT_SCHEMA, + operation_id: input.operation_id, + goal_id: input.goal_id, + request_sha256: requestSha, + result, + }]; + const committed = await store.commitAuthority(commit); + const readback = replayArchive( + await store.readReceipt(input.operation_id), + input, + requestSha, + committed.status === "applied" ? "applied" : "recovered", + ); + if (readback !== null) return readback; + return committed.status === "applied" + ? archiveFailure( + "coordination_commit_readback_mismatch", + "applied Todo archive mutation lacks its durable receipt", + ) + : {schema_version: COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA, ...committed, changed: false}; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 75ed0251e4..382c19d4f9 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -115,6 +115,7 @@ import { rollbackCoordinationRuntimeShadow, } from "./coordination/runtime_shadow.ts"; import { + archiveLocalCoordinationTodos, claimLocalCoordinationTodo, createLocalCoordinationTodo, editLocalCoordinationTodo, @@ -123,8 +124,12 @@ import { listLocalCoordinationTodos, promoteLocalCoordinationAuthority, readLocalCoordinationTodo, + terminalLifecycleLocalCoordinationTodo, } from "./coordination/local_authority_runtime.ts"; import { evaluateCoordinationTodoClaimDecision } from "./coordination/todo_claim.ts"; +import { evaluateCoordinationTodoTerminalDecision } from "./coordination/todo_terminal_decision.ts"; +import { evaluateCoordinationTodoArchiveSelection } from "./coordination/todo_archive_selection.ts"; +import { evaluateCoordinationTodoSuccessorDerivation } from "./coordination/todo_successor_derivation.ts"; import { checkLegacyCoordinationWriteAllowed, engageLegacyCoordinationWriterFence, @@ -374,6 +379,9 @@ export function createEffectRuntimeHandlers( }, ), ], + ["todo.terminal.decide", evaluateCoordinationTodoTerminalDecision], + ["todo.archive.select", evaluateCoordinationTodoArchiveSelection], + ["todo.successor.derive", evaluateCoordinationTodoSuccessorDerivation], ["todo.completion.reduce", reduceTodoCompletionTransaction], ["todo.completion_policy.resolve", resolveTodoCompletionPolicy], ["todo.next_action.transition", transitionTodoNextAction], @@ -420,6 +428,8 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.todo_claim", claimLocalCoordinationTodo], ["coordination.local_authority.todo_create", createLocalCoordinationTodo], ["coordination.local_authority.todo_update", updateLocalCoordinationTodo], + ["coordination.local_authority.todo_terminal", terminalLifecycleLocalCoordinationTodo], + ["coordination.local_authority.todo_archive", archiveLocalCoordinationTodos], ["coordination.local_authority.todo_compatibility_edit", editLocalCoordinationTodo], ["coordination.local_authority.mutate", mutateLocalCoordinationAuthority], ["coordination.local_authority.todo_read", readLocalCoordinationTodo], diff --git a/loopx/control_plane/todos/completed_archive.py b/loopx/control_plane/todos/completed_archive.py index 2f08bf8a11..b0bcfc741b 100644 --- a/loopx/control_plane/todos/completed_archive.py +++ b/loopx/control_plane/todos/completed_archive.py @@ -2,6 +2,7 @@ from typing import Any +from ..effect_runtime import effect_runtime_result from .active_state_editing import ( COMPLETED_WORK_ARCHIVE_HEADING, TODO_SECTION_HEADINGS, @@ -9,8 +10,6 @@ section_bounds, todo_blocks, ) -from .contract import TODO_STATUS_DONE, normalize_todo_status -from .decision_scope import is_standing_decision_receipt_item DEFAULT_MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE = 12 DEFAULT_COMPLETED_TODO_ARCHIVE_HEADROOM = 2 @@ -43,10 +42,6 @@ def completed_todo_count(todo_summary: dict[str, Any] | None) -> int: return max(0, terminal_count - deferred_count) -def _is_explicitly_completed_todo(block: dict[str, Any]) -> bool: - return bool(normalize_todo_status(block.get("status")) == TODO_STATUS_DONE) - - def completed_todo_archive_warning( agent_todos: dict[str, Any] | None, *, @@ -103,28 +98,41 @@ def archive_completed_todo_lines( if bounds: blocks = todo_blocks(updated_lines, bounds[0], bounds[1], role=role, source_section=section) - done_blocks = [ - block - for block in blocks - if _is_explicitly_completed_todo(block) - ] - active_done_count = len(done_blocks) - standing_receipts = ( - [block for block in done_blocks if is_standing_decision_receipt_item(block)] - if role == "user" - else [] + selection = effect_runtime_result( + "todo.archive.select", + { + "role": role, + "max_active_done": max_active_done, + "todos": [ + {**block, "role": role, "archive_state": "active"} + for block in blocks + ], + }, ) - retained_standing_decision_count = len(standing_receipts) - movable_done_blocks = [ - block for block in done_blocks if block not in standing_receipts - ] - move_count = min( - len(movable_done_blocks), - max(0, active_done_count - max_active_done), + if not isinstance(selection, dict) or selection.get("schema_version") != ( + "loopx_coordination_todo_archive_selection_v0" + ): + raise RuntimeError("typed Todo archive selector returned an invalid result") + moved_todo_ids = selection.get("moved_todo_ids") + if not isinstance(moved_todo_ids, list) or not all( + isinstance(todo_id, str) and todo_id for todo_id in moved_todo_ids + ): + raise RuntimeError("typed Todo archive selector returned invalid Todo ids") + blocks_by_id = {str(block["todo_id"]): block for block in blocks} + if len(blocks_by_id) != len(blocks) or any( + todo_id not in blocks_by_id for todo_id in moved_todo_ids + ): + raise RuntimeError("typed Todo archive selection does not match the parsed batch") + blocks_to_move = [blocks_by_id[todo_id] for todo_id in moved_todo_ids] + active_done_count = int(selection["active_done_before"]) + kept_done_count = int(selection["active_done_after"]) + move_count = int(selection["moved_count"]) + retained_standing_decision_count = int( + selection["retained_standing_decision_count"] ) - blocks_to_move = movable_done_blocks[:move_count] + if move_count != len(blocks_to_move): + raise RuntimeError("typed Todo archive selector returned inconsistent counts") move_starts = {int(block["start"]) for block in blocks_to_move} - kept_done_count = active_done_count - move_count for block in blocks_to_move: moved_blocks.append(updated_lines[int(block["start"]) : int(block["end"])]) if move_starts: diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index 66e7e7901e..b3ea8a59f7 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -1,9 +1,10 @@ from __future__ import annotations import subprocess +from collections.abc import Mapping from json import loads as json_loads from pathlib import Path -from typing import Any, Mapping +from typing import Any, cast from ...history import load_registry from ...materials import find_registry_goal, goal_repo @@ -12,17 +13,27 @@ run_caller_validation, ) from .active_state_editing import find_todo_block -from .contract import TODO_STATUS_DONE, normalize_todo_status -from .event_writeback import event_projection_source_authority, event_projection_todo_context +from .completion_policy import ( + build_completion_policy_request, + linked_successors_from_state, +) from .completion_transaction import ( reduce_todo_completion_transaction, todo_completion_source_snapshot, ) -from .completion_policy import ( - build_completion_policy_request, - linked_successors_from_state, +from .completion_validation_projection import ( + completion_validation_declaration, + completion_validation_declaration_sha256, +) +from .completion_validation_store import ( + persist_completion_validation_declaration, + read_completion_validation_declaration, +) +from .contract import TODO_STATUS_DONE, normalize_todo_status +from .event_writeback import ( + event_projection_source_authority, + event_projection_todo_context, ) - # Kept safely under the 30s outer CLI/MCP subprocess budget so a timed-out # validation still produces a typed receipt before the outer call is killed. @@ -58,7 +69,7 @@ def _resolve_goal_repo_workspace(registry_path: Path, goal_id: str) -> Path | No repo = goal_repo(goal) if repo is None or not repo.is_dir(): return None - return repo + return cast(Path, repo) def _materialized_todo_item( @@ -125,17 +136,23 @@ def _run_declared_completion_validation( } try: if validation_argv is not None: - return run_caller_validation( + return cast( + dict[str, Any], + run_caller_validation( + workspace, + validation_argv=validation_argv, + validation_label=label, + timeout_seconds=timeout_seconds, + ), + ) + return cast( + dict[str, Any], + run_caller_validation( workspace, - validation_argv=validation_argv, + validation_command=str(validation_command), validation_label=label, timeout_seconds=timeout_seconds, - ) - return run_caller_validation( - workspace, - validation_command=str(validation_command), - validation_label=label, - timeout_seconds=timeout_seconds, + ), ) except subprocess.TimeoutExpired: return { @@ -180,6 +197,128 @@ def _run_declared_completion_validation( } +def run_declared_completion_validation_effect( + *, + effect: Mapping[str, Any], + registry_path: Path, + goal_id: str, +) -> dict[str, Any]: + """Execute exactly one TypeScript-authorized validation effect. + + This is an effect adapter only: the TS completion transaction owns whether + validation is required and validates the returned privacy-safe receipt on + re-entry. No Todo or authority state is read or changed here. + """ + + if effect.get("kind") != "caller_validation": + raise ValueError("unsupported Todo completion validation effect") + raw_argv = effect.get("validation_argv") + if raw_argv is not None and not ( + isinstance(raw_argv, list) + and raw_argv + and all(isinstance(item, str) and item for item in raw_argv) + ): + raise ValueError("validation_effect.validation_argv must be a string array") + receipt = _run_declared_completion_validation( + validation_command=( + str(effect["validation_command"]) + if effect.get("validation_command") is not None + else None + ), + validation_argv=list(raw_argv) if isinstance(raw_argv, list) else None, + validation_label=( + str(effect["validation_label"]) + if effect.get("validation_label") is not None + else None + ), + validation_timeout_seconds=( + int(effect["validation_timeout_seconds"]) + if effect.get("validation_timeout_seconds") is not None + else None + ), + registry_path=registry_path, + goal_id=goal_id, + ) + if receipt is None: + raise RuntimeError("authorized validation effect produced no receipt") + return receipt + + +def resolve_private_completion_validation_declaration( + *, + canonical_todo: Mapping[str, Any], + state_file: Path, + runtime_root: Path, + registry_path: Path, + goal_id: str, + todo_id: str, + role: str | None, + persist_if_resolved: bool, +) -> dict[str, Any] | None: + """Resolve private effect detail and bind it to the canonical public digest.""" + + required = canonical_todo.get("completion_validation_required") is True + expected = canonical_todo.get("completion_validation_sha256") + if not required: + if expected is not None: + raise ValueError( + "canonical Todo has a completion validation digest without authority" + ) + return None + if not isinstance(expected, str) or len(expected) != 64: + raise ValueError( + "canonical Todo requires completion validation but omits its digest" + ) + # A missing sidecar is an availability case: the digest-bound Markdown or + # event projection may rehydrate it below. A present sidecar that fails its + # identity or digest checks is corruption/tamper evidence and deliberately + # raises instead of falling back, so a second source cannot mask the fault. + declaration = read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=todo_id, + ) + if declaration is None: + source = _materialized_todo_item( + state_file=state_file, + todo_id=todo_id, + role=role, + ) + if source is None: + event_context = event_projection_todo_context( + registry_path=registry_path, + goal_id=goal_id, + state_path=state_file, + todo_id=todo_id, + role=role, + ) + if event_context is not None: + source = dict( + event_context.get("raw_item") or event_context.get("item") or {} + ) + declaration = ( + completion_validation_declaration(source) + if isinstance(source, dict) + else None + ) + if declaration is None: + raise ValueError( + "private completion validation declaration is unavailable for canonical Todo" + ) + if completion_validation_declaration_sha256(declaration) != expected: + raise ValueError( + "private completion validation declaration does not match canonical Todo digest" + ) + if persist_if_resolved: + persist_completion_validation_declaration( + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=todo_id, + declaration=declaration, + ) + return declaration + + def run_completion_validation_gate_with_source( *, state_file: Path, @@ -344,23 +483,28 @@ def completion_policy_source_from_state( ) -> dict[str, Any]: """Project lock-comparable facts for the TS completion policy.""" - return build_completion_policy_request( - registry_path=registry_path, - goal_id=goal_id, - claimed_by=facts.get("claimed_by"), - next_claimed_by=facts.get("next_claimed_by"), - next_agent_todo=facts.get("next_agent_todo"), - next_action_kind=facts.get("next_action_kind"), - next_continuation_policy=facts.get("next_continuation_policy"), - next_excluded_agents=facts.get("next_excluded_agents") or [], - self_merged=bool(facts.get("self_merged")), - evidence=facts.get("evidence"), - linked_successors=linked_successors_from_state( - lines=lines, - successor_todo_ids=successor_todo_ids, - event_fields=event_fields, + return cast( + dict[str, Any], + build_completion_policy_request( + registry_path=registry_path, + goal_id=goal_id, + claimed_by=facts.get("claimed_by"), + next_claimed_by=facts.get("next_claimed_by"), + next_agent_todo=facts.get("next_agent_todo"), + next_action_kind=facts.get("next_action_kind"), + next_continuation_policy=facts.get("next_continuation_policy"), + next_excluded_agents=facts.get("next_excluded_agents") or [], + self_merged=bool(facts.get("self_merged")), + evidence=facts.get("evidence"), + linked_successors=linked_successors_from_state( + lines=lines, + successor_todo_ids=successor_todo_ids, + event_fields=event_fields, + ), ), ) + + def prepare_user_todo_update_completion( *, status: str | None, diff --git a/loopx/control_plane/todos/completion_validation_projection.py b/loopx/control_plane/todos/completion_validation_projection.py index d41e831dc8..ff2e2f76f9 100644 --- a/loopx/control_plane/todos/completion_validation_projection.py +++ b/loopx/control_plane/todos/completion_validation_projection.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import json from typing import Any from .contract import ( @@ -11,17 +13,79 @@ ) +_DECLARATION_FIELDS = ( + "validation_command", + "validation_command_argv", + "validation_label", + "validation_timeout_seconds", +) + + +def completion_validation_declaration(item: dict[str, Any]) -> dict[str, Any] | None: + """Normalize private execution detail for local effect resolution and hashing.""" + + command_value = item.get("validation_command") + command = command_value.strip() if isinstance(command_value, str) else command_value + if command == "": + command = None + argv: Any = item.get("validation_command_argv") + if isinstance(argv, str): + compact = argv.strip() + if not compact: + argv = None + else: + try: + argv = json.loads(compact) + except ValueError: + argv = compact + elif isinstance(argv, tuple): + argv = list(argv) + label = item.get("validation_label") + if label == "": + label = None + timeout: Any = item.get("validation_timeout_seconds") + if isinstance(timeout, str) and timeout.strip().isdigit(): + timeout = int(timeout.strip()) + elif timeout == "": + timeout = None + declaration = { + "validation_command": command, + "validation_command_argv": argv, + "validation_label": label, + "validation_timeout_seconds": timeout, + } + return ( + declaration + if any(item.get(field) not in (None, "") for field in _DECLARATION_FIELDS) + else None + ) + + +def completion_validation_declaration_sha256( + declaration: dict[str, Any], +) -> str: + payload = json.dumps( + declaration, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + def project_completion_validation_authority(item: dict[str, Any]) -> dict[str, Any]: """Replace private validation execution details with one authority marker.""" projected = dict(item) - command = str(projected.pop("validation_command", "") or "").strip() - argv = projected.pop("validation_command_argv", None) - projected.pop("validation_label", None) - projected.pop("validation_timeout_seconds", None) - argv_declared = argv is not None and str(argv).strip() != "" - if command or argv_declared: + declaration = completion_validation_declaration(projected) + for field in _DECLARATION_FIELDS: + projected.pop(field, None) + if declaration is not None: projected["completion_validation_required"] = True + projected["completion_validation_sha256"] = ( + completion_validation_declaration_sha256(declaration) + ) return projected diff --git a/loopx/control_plane/todos/completion_validation_store.py b/loopx/control_plane/todos/completion_validation_store.py new file mode 100644 index 0000000000..6d5c18b4e2 --- /dev/null +++ b/loopx/control_plane/todos/completion_validation_store.py @@ -0,0 +1,129 @@ +"""Private local store for provider-first Todo validation declarations.""" + +from __future__ import annotations + +import os +import re +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ...registry import atomic_write_json, read_json +from .completion_validation_projection import ( + completion_validation_declaration, + completion_validation_declaration_sha256, +) + + +DECLARATION_SCHEMA_VERSION = "loopx_todo_completion_validation_declaration_v0" +_PUBLIC_ID = re.compile(r"[A-Za-z0-9_.:-]+") + + +def _require_public_id(value: str, label: str) -> str: + if not _PUBLIC_ID.fullmatch(value): + raise ValueError(f"{label} must be a public-safe token") + return value + + +def completion_validation_declaration_path( + *, runtime_root: Path, goal_id: str, todo_id: str +) -> Path: + return ( + runtime_root.expanduser().resolve(strict=False) + / "goals" + / _require_public_id(goal_id, "goal_id") + / "todo-validation-declarations" + / f"{_require_public_id(todo_id, 'todo_id')}.json" + ) + + +def persist_completion_validation_declaration( + *, + runtime_root: Path, + goal_id: str, + todo_id: str, + declaration: Mapping[str, Any], +) -> str: + normalized = completion_validation_declaration(dict(declaration)) + if normalized is None: + raise ValueError("completion validation declaration is empty") + digest = completion_validation_declaration_sha256(normalized) + path = completion_validation_declaration_path( + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=todo_id, + ) + atomic_write_json( + path, + { + "schema_version": DECLARATION_SCHEMA_VERSION, + "goal_id": goal_id, + "todo_id": todo_id, + "declaration_sha256": digest, + "declaration": normalized, + }, + preserve_mode=True, + ) + os.chmod(path, 0o600) + return digest + + +def read_completion_validation_declaration( + *, runtime_root: Path, goal_id: str, todo_id: str +) -> dict[str, Any] | None: + path = completion_validation_declaration_path( + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=todo_id, + ) + try: + value = read_json(path) + except FileNotFoundError: + return None + if not isinstance(value, Mapping): + raise ValueError("completion validation declaration store is not an object") + declaration = value.get("declaration") + if ( + value.get("schema_version") != DECLARATION_SCHEMA_VERSION + or value.get("goal_id") != goal_id + or value.get("todo_id") != todo_id + or not isinstance(declaration, Mapping) + ): + raise ValueError("completion validation declaration store identity mismatch") + normalized = completion_validation_declaration(dict(declaration)) + if normalized is None: + raise ValueError("completion validation declaration store is empty") + digest = completion_validation_declaration_sha256(normalized) + if value.get("declaration_sha256") != digest: + raise ValueError("completion validation declaration store digest mismatch") + return normalized + + +def load_completion_validation_declarations( + *, + runtime_root: Path, + goal_id: str, + todos: Iterable[Mapping[str, Any]], +) -> dict[str, dict[str, Any]]: + loaded: dict[str, dict[str, Any]] = {} + for todo in todos: + if todo.get("completion_validation_required") is not True: + continue + todo_id = str(todo.get("todo_id") or "") + declaration = read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=todo_id, + ) + if declaration is not None: + loaded[todo_id] = declaration + return loaded + + +__all__ = [ + "DECLARATION_SCHEMA_VERSION", + "completion_validation_declaration_path", + "load_completion_validation_declarations", + "persist_completion_validation_declaration", + "read_completion_validation_declaration", +] diff --git a/loopx/control_plane/todos/durable_completion.py b/loopx/control_plane/todos/durable_completion.py index 50f090d8b4..b09b695a8c 100644 --- a/loopx/control_plane/todos/durable_completion.py +++ b/loopx/control_plane/todos/durable_completion.py @@ -48,6 +48,7 @@ def read_persisted_todo_record( todo_id: str, registry_path: Path | None = None, goal_id: str | None = None, + runtime_root: Path | None = None, ) -> tuple[dict[str, Any], set[str]]: """Read one durable Todo record and the goal-state Todo id universe. @@ -65,6 +66,7 @@ def read_persisted_todo_record( todo_id=todo_id, registry_path=registry_path, goal_id=goal_id, + runtime_root=runtime_root, ) ) return todo, existing_todo_ids @@ -76,8 +78,43 @@ def read_persisted_todo_record_with_source( todo_id: str, registry_path: Path | None = None, goal_id: str | None = None, + runtime_root: Path | None = None, ) -> tuple[dict[str, Any], set[str], TodoCompletionProjectionSource]: - """Read one durable Todo together with its authoritative projection source.""" + """Read one durable Todo together with its authoritative projection source. + + A promoted canonical provider is the primary persisted lifecycle. Markdown + and event projection remain the pre-promotion sources only; a stale + Markdown projection must never hide a canonical commit during Turn retry. + """ + + if runtime_root is not None and goal_id is not None: + from ..coordination.local_authority import ( + read_canonical_todos_if_promoted, + ) + + canonical = read_canonical_todos_if_promoted( + runtime_root=runtime_root, + goal_id=goal_id, + ) + if canonical is not None: + records = [ + dict(item) + for item in canonical.get("todos", []) + if isinstance(item, Mapping) + ] + canonical_todo_ids = { + normalized + for item in records + if (normalized := normalize_todo_id(item.get("todo_id"))) + } + normalized_todo_id = normalize_todo_id(todo_id) or todo_id + for item in records: + if normalize_todo_id(item.get("todo_id")) == normalized_todo_id: + return item, canonical_todo_ids, "materialized" + raise ValueError( + f"durable completion todo_id {normalized_todo_id!r} was not found " + "in canonical provider state" + ) lines = state_file.read_text(encoding="utf-8").splitlines() block: dict[str, Any] | None = None diff --git a/loopx/control_plane/todos/event_writeback.py b/loopx/control_plane/todos/event_writeback.py index 875132823a..cf14c00487 100644 --- a/loopx/control_plane/todos/event_writeback.py +++ b/loopx/control_plane/todos/event_writeback.py @@ -30,7 +30,6 @@ from .contract import ( TODO_CONTINUATION_POLICY_VALUES, TODO_STATUS_DONE, - TODO_TASK_CLASS_USER_GATE, build_todo_id, merge_todo_id_lists, normalize_required_capabilities, @@ -44,9 +43,12 @@ normalize_todo_task_repository, ) from .completion_transaction import reduce_todo_completion_transaction +from .successor_derivation import ( + build_successor_intents, + derive_successor_proposals, +) from .text import ( TODO_PRIORITY_PREFIX_PATTERN, - inherit_todo_priority, normalize_new_todo, todo_priority_prefix, ) @@ -563,67 +565,54 @@ def complete_event_projected_goal_todo( "updated_at": item.get("updated_at"), "source": "event_log", } - next_unblocks_todo_id = todo_id if next_agent_todo else None - next_user_bound_agent = effective_claimed_by - if next_user_todo and len(registered_agents) > 1: - if not next_user_bound_agent: - raise ValueError( - "multi-agent --next-user-todo requires a completing --claimed-by " - "agent so the user todo can be bound" - ) - - next_results: list[dict[str, Any]] = [] - if next_agent_todo: - next_results.append( - _append_event_projected_successor( - store=store, - goal_id=goal_id, - role="agent", - text=inherit_todo_priority(next_agent_todo, str(item.get("text") or "")), - updated_at=updated_at, - fields=context["fields"], - task_class=next_task_class or "advancement_task", - action_kind=next_action_kind, - capability_binding_ref=item.get("capability_binding_ref"), - task_repository=next_task_repository, - required_capabilities=next_required_capabilities, - continuation_policy=next_continuation_policy, - claimed_by=next_claimed_by, - excluded_agents=next_excluded_agents, - unblocks_todo_id=next_unblocks_todo_id, - dry_run=dry_run, - actor_agent_id=actor_agent_id, - ) - ) - if next_user_todo: - next_results.append( - _append_event_projected_successor( - store=store, - goal_id=goal_id, - role="user", - text=inherit_todo_priority(next_user_todo, str(item.get("text") or "")), - updated_at=updated_at, - fields=context["fields"], - task_class=next_user_task_class, - action_kind=( - "gate" if next_user_task_class == TODO_TASK_CLASS_USER_GATE else None - ), - capability_binding_ref=None, - task_repository=None, - required_capabilities=None, - continuation_policy=None, - claimed_by=None, - bound_agent=next_user_bound_agent, - blocks_agent=( - next_user_bound_agent - if next_user_task_class == TODO_TASK_CLASS_USER_GATE - else None - ), - unblocks_todo_id=None, - dry_run=dry_run, - actor_agent_id=actor_agent_id, - ) + successor_intents = build_successor_intents( + next_agent_todo=next_agent_todo, + next_user_todo=next_user_todo, + next_user_task_class=next_user_task_class, + next_claimed_by=next_claimed_by, + next_task_class=next_task_class, + next_action_kind=next_action_kind, + next_task_repository=next_task_repository, + next_required_capabilities=next_required_capabilities, + next_continuation_policy=next_continuation_policy, + next_excluded_agents=next_excluded_agents, + ) + successor_proposals = derive_successor_proposals( + command="complete", + predecessor=item, + registered_agents=registered_agents, + actor_agent_id=actor_agent_id, + completion_policy={ + "effective_claimed_by": effective_claimed_by, + "effective_next_claimed_by": next_claimed_by, + "effective_next_excluded_agents": next_excluded_agents, + }, + successor_intents=successor_intents, + ) + next_results = [ + _append_event_projected_successor( + store=store, + goal_id=goal_id, + role=str(proposal["role"]), + text=str(proposal["text"]), + updated_at=updated_at, + fields=context["fields"], + task_class=proposal.get("task_class"), + action_kind=proposal.get("action_kind"), + capability_binding_ref=proposal.get("capability_binding_ref"), + task_repository=proposal.get("task_repository"), + required_capabilities=proposal.get("required_capabilities"), + continuation_policy=proposal.get("continuation_policy"), + claimed_by=proposal.get("claimed_by"), + bound_agent=proposal.get("bound_agent"), + blocks_agent=proposal.get("blocks_agent"), + excluded_agents=proposal.get("excluded_agents"), + unblocks_todo_id=proposal.get("unblocks_todo_id"), + dry_run=dry_run, + actor_agent_id=actor_agent_id, ) + for proposal in successor_proposals + ] normalized_successor_todo_ids = merge_todo_id_lists( successor_todo_ids, diff --git a/loopx/control_plane/todos/machine_section_projection.py b/loopx/control_plane/todos/machine_section_projection.py index fc3cbd1791..e834c935f2 100644 --- a/loopx/control_plane/todos/machine_section_projection.py +++ b/loopx/control_plane/todos/machine_section_projection.py @@ -31,6 +31,11 @@ todo_blocks, ) from .machine_region import find_todo_regions, todo_region_marker +from .completion_validation_projection import ( + completion_validation_declaration, + completion_validation_declaration_sha256, + project_completion_validation_authority, +) from .active_state_todo_parser import parse_active_state_todos from .contract import ( TODO_METADATA_FIELDS, @@ -168,6 +173,7 @@ def _render_record( record: Mapping[str, object], *, include_role: bool = False, + private_validation: Mapping[str, object] | None = None, ) -> list[str]: status = normalize_todo_status(record.get("status")) or TODO_STATUS_OPEN text = " ".join(str(record.get("text") or "").strip().split()) @@ -180,6 +186,8 @@ def _render_record( for field in TODO_METADATA_FIELDS if field in record and record[field] is not None } + if private_validation is not None: + metadata_values.update(private_validation) if not include_role: metadata_values.pop("role", None) metadata = format_todo_metadata_line(**metadata_values) @@ -195,6 +203,7 @@ def _render_section( records: list[dict[str, object]], provider_revision: str, newline: str, + private_validation: Mapping[str, Mapping[str, object]], ) -> tuple[str, str]: digest = _sha256_text(_canonical_json(records)) heading = ( @@ -210,7 +219,11 @@ def _render_section( "", ] for record in records: - lines.extend(_render_record(record, include_role=role == "archive")) + lines.extend(_render_record( + record, + include_role=role == "archive", + private_validation=private_validation.get(str(record.get("todo_id") or "")), + )) lines.append(todo_region_marker(role, "end")) lines.append("") return newline.join(lines), digest @@ -221,11 +234,31 @@ def _replace_existing_sections( *, rendered_sections: Mapping[str, str], ) -> str: + spans = sorted(_section_spans(markdown).values(), key=lambda value: value.start) + if not spans: + raise TodoSectionProjectionError( + "active Markdown omits required Todo sections: no projection anchor" + ) + replacements = {span.role: rendered_sections[span.role] for span in spans} + source_roles = set(replacements) + first_role = spans[0].role + last_role = spans[-1].role + prefix: list[str] = [] + if "user" not in source_roles: + prefix.append(rendered_sections["user"]) + if "agent" not in source_roles: + if "user" in source_roles: + replacements["user"] += rendered_sections["agent"] + else: + prefix.append(rendered_sections["agent"]) + if "archive" in rendered_sections and "archive" not in source_roles: + replacements[last_role] += rendered_sections["archive"] + if prefix: + replacements[first_role] = "".join(prefix) + replacements[first_role] + result = markdown - for span in sorted( - _section_spans(markdown).values(), key=lambda value: value.start, reverse=True - ): - result = result[: span.start] + rendered_sections[span.role] + result[span.end :] + for span in reversed(spans): + result = result[: span.start] + replacements[span.role] + result[span.end :] return result @@ -235,7 +268,7 @@ def _parsed_active_records(markdown: str) -> list[dict[str, Any]]: for role in TODO_SECTION_HEADINGS: summary = fields.get(f"{role}_todos") items = summary.get("items") if isinstance(summary, dict) else [] - for item in items or []: + for item in sorted(items or [], key=_record_sort_key): if isinstance(item, dict) and item.get("archive_state") == "active": records.append(canonical_todo_read_record(item, reject_unknown=False)) return records @@ -259,12 +292,12 @@ def _parsed_archive_records(markdown: str) -> list[dict[str, Any]]: ) records.append( canonical_todo_read_record( - { + project_completion_validation_authority({ **item, "schema_version": TODO_ITEM_SCHEMA_VERSION, "archive_state": "archive", "source_section": COMPLETED_WORK_ARCHIVE_HEADING, - }, + }), reject_unknown=False, ) ) @@ -296,17 +329,58 @@ def _projection_record( "last_actor_agent_id", "resume_condition", "resume_ready", - "completion_validation_required", "handoff_note", } +def _private_validation_metadata( + markdown: str, +) -> dict[str, tuple[dict[str, Any], dict[str, object]]]: + lines = markdown.splitlines() + private: dict[str, tuple[dict[str, Any], dict[str, object]]] = {} + for region in find_todo_regions(lines): + for item in todo_blocks( + lines, + region.start, + region.body_end, + role=region.role if region.role in TODO_SECTION_HEADINGS else None, + source_section=region.heading, + ): + todo_id = str(item.get("todo_id") or "") + declaration = completion_validation_declaration(item) + if not todo_id or declaration is None: + continue + private[todo_id] = _private_validation_entry(declaration) + return private + + +def _private_validation_entry( + declaration: Mapping[str, Any], +) -> tuple[dict[str, Any], dict[str, object]]: + normalized = completion_validation_declaration(dict(declaration)) + if normalized is None: + raise TodoSectionProjectionError("private validation declaration is empty") + metadata: dict[str, object] = { + key: value for key, value in normalized.items() if value is not None + } + argv = metadata.get("validation_command_argv") + if isinstance(argv, list): + metadata["validation_command_argv"] = json.dumps( + argv, + ensure_ascii=False, + separators=(",", ":"), + ) + return normalized, metadata + + def _parity_records(records: Sequence[Mapping[str, object]]) -> list[dict[str, object]]: return [ { field: record[field] for field in TODO_CANONICAL_READ_RECORD_FIELDS - if field in record and field not in _DERIVED_READ_MODEL_FIELDS + if field in record + and field not in _DERIVED_READ_MODEL_FIELDS + and record[field] != [] } for record in records ] @@ -317,18 +391,50 @@ def render_canonical_todo_sections( records: Sequence[Mapping[str, object]], *, provider_revision: str, + private_validation_declarations: Mapping[str, Mapping[str, Any]] | None = None, ) -> TodoSectionProjectionResult: """Replace only Todo sections and verify deterministic parse/render parity.""" if not re.fullmatch(r"[A-Za-z0-9_.:-]+", provider_revision): raise TodoSectionProjectionError("provider_revision must be a public-safe token") canonical = _canonical_records(records) + private_source = _private_validation_metadata(markdown) + for todo_id, declaration in (private_validation_declarations or {}).items(): + external = _private_validation_entry(declaration) + existing = private_source.get(todo_id) + if existing is not None and ( + completion_validation_declaration_sha256(existing[0]) + != completion_validation_declaration_sha256(external[0]) + ): + raise TodoSectionProjectionError( + f"Todo {todo_id!r} has divergent private validation declarations" + ) + private_source[todo_id] = external + private_validation: dict[str, Mapping[str, object]] = {} + for record in canonical: + todo_id = str(record.get("todo_id") or "") + required = record.get("completion_validation_required") is True + digest = record.get("completion_validation_sha256") + if not required: + if digest is not None: + raise TodoSectionProjectionError( + f"Todo {todo_id!r} has a validation digest without authority" + ) + continue + if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest): + raise TodoSectionProjectionError( + f"Todo {todo_id!r} requires validation but omits its declaration digest" + ) + source = private_source.get(todo_id) + if ( + source is None + or completion_validation_declaration_sha256(source[0]) != digest + ): + raise TodoSectionProjectionError( + f"Todo {todo_id!r} private validation declaration does not match authority" + ) + private_validation[todo_id] = source[1] source_spans = _section_spans(markdown) - missing_roles = sorted(set(TODO_SECTION_HEADINGS).difference(source_spans)) - if missing_roles: - raise TodoSectionProjectionError( - "active Markdown omits required Todo sections: " + ", ".join(missing_roles) - ) by_role = { role: sorted( [ @@ -345,10 +451,6 @@ def render_canonical_todo_sections( [record for record in canonical if record.get("archive_state") == "archive"], key=_record_sort_key, ) - if archived and "archive" not in source_spans: - raise TodoSectionProjectionError( - "active Markdown omits required Completed Work Archive section" - ) newline = "\r\n" if "\r\n" in markdown else "\n" rendered_sections: dict[str, str] = {} section_digests: dict[str, str] = {} @@ -358,21 +460,23 @@ def render_canonical_todo_sections( records=by_role[role], provider_revision=provider_revision, newline=newline, + private_validation=private_validation, ) - if "archive" in source_spans: + if archived or "archive" in source_spans: rendered_sections["archive"], section_digests["archive"] = _render_section( role="archive", records=archived, provider_revision=provider_revision, newline=newline, + private_validation=private_validation, ) rendered = _replace_existing_sections( markdown, rendered_sections=rendered_sections, ) - before_narrative = _narrative_segments(markdown) - after_narrative = _narrative_segments(rendered) + before_narrative = "".join(_narrative_segments(markdown)) + after_narrative = "".join(_narrative_segments(rendered)) if before_narrative != after_narrative: raise TodoSectionProjectionError("render changed Markdown outside Todo sections") @@ -401,7 +505,7 @@ def render_canonical_todo_sections( changed=rendered != markdown, source_sha256=_sha256_text(markdown), rendered_sha256=_sha256_text(rendered), - narrative_sha256=_sha256_text(_canonical_json(after_narrative)), + narrative_sha256=_sha256_text(after_narrative), provider_revision=provider_revision, todo_count=len(canonical), section_record_sha256=section_digests, diff --git a/loopx/control_plane/todos/path_resolution.py b/loopx/control_plane/todos/path_resolution.py new file mode 100644 index 0000000000..f7e868d985 --- /dev/null +++ b/loopx/control_plane/todos/path_resolution.py @@ -0,0 +1,29 @@ +"""Canonical active-state path resolution shared by Todo facade callers.""" + +from __future__ import annotations + +from pathlib import Path + +from ...history import load_registry +from ...state_refresh import resolve_goal_state + + +def resolve_todo_state_path( + *, + registry_path: Path, + goal_id: str, + project: Path | None = None, + state_file: Path | None = None, +) -> tuple[Path | None, Path]: + registry = load_registry(registry_path) + goal, resolved_project, resolved_state_file = resolve_goal_state( + registry=registry, + goal_id=goal_id, + project_override=project, + state_file_override=state_file, + ) + if goal is None: + raise ValueError(f"goal {goal_id!r} is not present in the registry") + if not resolved_state_file.exists(): + raise ValueError(f"active state file does not exist: {resolved_state_file}") + return resolved_project, resolved_state_file diff --git a/loopx/control_plane/todos/provider_create.py b/loopx/control_plane/todos/provider_create.py index af69277f7d..0e3a9c5093 100644 --- a/loopx/control_plane/todos/provider_create.py +++ b/loopx/control_plane/todos/provider_create.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, cast from uuid import uuid4 from ...agent_registry import registered_agent_ids_from_registry @@ -19,6 +19,15 @@ normalize_todo_task_class, todo_done_for_status, ) +from .completion_validation_projection import ( + completion_validation_declaration, + completion_validation_declaration_sha256, + project_completion_validation_authority, +) +from .completion_validation_store import ( + persist_completion_validation_declaration, + read_completion_validation_declaration, +) from .provider_projection import settle_canonical_todo_projection @@ -39,6 +48,21 @@ def create_canonical_todo_if_promoted( role=role, source_section=section, index=same_role_count + 1, text=text ) normalized_metadata = normalize_todo_metadata_for_write(metadata) + validation_source = { + **normalized_metadata, + **{ + field: metadata[field] + for field in ( + "validation_command", + "validation_command_argv", + "validation_label", + "validation_timeout_seconds", + ) + if field in metadata + }, + } + validation_declaration = completion_validation_declaration(validation_source) + provider_metadata = project_completion_validation_authority(validation_source) todo = { "schema_version": "todo_domain_record_v0", "todo_id": todo_id, @@ -48,10 +72,10 @@ def create_canonical_todo_if_promoted( "text": text, "archive_state": "active", "task_class": normalize_todo_task_class( - normalized_metadata.get("task_class"), text=text, - action_kind=normalized_metadata.get("action_kind"), + provider_metadata.get("task_class"), text=text, + action_kind=provider_metadata.get("action_kind"), ), - **normalized_metadata, + **provider_metadata, **({"claimed_by": claimed_by} if claimed_by else {}), } result = effect_runtime_result( @@ -82,15 +106,60 @@ def create_canonical_todo_if_promoted( code=str(payload.get("reason_code") or payload.get("conflict_kind") or "todo_create_failed"), payload=payload, ) - return settle_canonical_todo_projection({ + canonical_todo_id = str(result.get("todo_id") or "") + canonical_todo = result.get("todo") + if validation_declaration is not None and not dry_run: + expected_digest = completion_validation_declaration_sha256( + validation_declaration + ) + if ( + not canonical_todo_id + or not isinstance(canonical_todo, dict) + or canonical_todo.get("todo_id") != canonical_todo_id + or canonical_todo.get("completion_validation_required") is not True + or canonical_todo.get("completion_validation_sha256") != expected_digest + ): + raise LocalCoordinationAuthorityUnavailable( + "accepted canonical Todo does not match its private validation declaration", + code="todo_create_validation_publication_mismatch", + payload={ + "source_authority": "file_v0", + "goal_id": goal_id, + "todo_id": canonical_todo_id or None, + "provider_status": result.get("status"), + }, + ) + persist_completion_validation_declaration( + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=canonical_todo_id, + declaration=validation_declaration, + ) + if read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=canonical_todo_id, + ) != validation_declaration: + raise LocalCoordinationAuthorityUnavailable( + "accepted Todo validation declaration failed private-store readback", + code="todo_create_validation_publication_readback_mismatch", + payload={ + "source_authority": "file_v0", + "goal_id": goal_id, + "todo_id": canonical_todo_id, + "provider_status": result.get("status"), + }, + ) + settled = settle_canonical_todo_projection({ "ok": True, "goal_id": goal_id, "role": role, - "todo_id": todo_id, + "todo_id": canonical_todo_id or todo_id, "todo": text, "dry_run": dry_run, - "added": result.get("status") not in {"replayed", "no_change"}, - "already_exists": result.get("status") in {"replayed", "no_change"}, + "added": result.get("changed") is True, + "already_exists": result.get("changed") is not True, **result, }, registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, project=project, state_file=state_file) + return cast(dict[str, Any], settled) diff --git a/loopx/control_plane/todos/provider_projection.py b/loopx/control_plane/todos/provider_projection.py index c3606ad275..fd0c7b10c7 100644 --- a/loopx/control_plane/todos/provider_projection.py +++ b/loopx/control_plane/todos/provider_projection.py @@ -28,6 +28,7 @@ TodoSectionProjectionError, render_canonical_todo_sections, ) +from .completion_validation_store import load_completion_validation_declarations TODO_PROJECTION_DELIVERY_SCHEMA = "loopx_todo_projection_delivery_v0" @@ -120,6 +121,11 @@ def project_current_canonical_todos( source, authority_read["todos"], provider_revision=provider_revision, + private_validation_declarations=load_completion_validation_declarations( + runtime_root=runtime_root, + goal_id=goal_id, + todos=authority_read["todos"], + ), ) if execute and projection.changed: # The projection is a primary-state write: it must respect the diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py new file mode 100644 index 0000000000..6126ad7f56 --- /dev/null +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -0,0 +1,564 @@ +"""Provider-first complete, supersede, and archive adapters. + +Python projects registry facts, serializes caller intent, executes a typed +validation effect, and drains the committed Markdown projection outbox. The +TypeScript transaction is the sole owner of lifecycle admission and writes. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Iterable, Mapping +from functools import wraps +from inspect import signature +from pathlib import Path +from typing import Any + +from ...agent_registry import load_goal_from_registry, registered_agent_ids_for_goal +from ...state_refresh import now_local +from ..coordination.local_authority import ( + LocalCoordinationAuthorityRejection, + LocalCoordinationAuthorityUnavailable, + read_canonical_todos_if_promoted, +) +from ..coordination.local_authority_shadow_adapter import effective_runtime_root +from ..effect_runtime import effect_runtime_result +from .completion_policy import ( + build_completion_policy_request, + linked_successor_from_todo, +) +from .completion_transaction import require_completion_successor_todo_ids +from .completion_validation import ( + resolve_private_completion_validation_declaration, + run_declared_completion_validation_effect, +) +from .contract import resolve_next_user_task_class +from .mutation_authority import normalize_todo_lifecycle_authority +from .path_resolution import resolve_todo_state_path +from .provider_projection import settle_canonical_todo_projection +from .successor_derivation import build_successor_intents + +_TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v0" +_ARCHIVE_REQUEST_SCHEMA = "loopx_local_coordination_todo_archive_request_v0" +_ACCEPTED = {"applied", "recovered", "replayed", "no_change", "planned"} + + +TodoMutation = Callable[..., dict[str, Any]] + + +def _non_negative_integer(value: Any, label: str, *, optional: bool) -> int | None: + if value is None and optional: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + suffix = " or None" if optional else "" + raise ValueError(f"{label} must be a non-negative integer{suffix}") + result: int = value + return result + + +def _route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any] | None: + registry_path = Path(call["registry_path"]) + goal_id = str(call["goal_id"]) + runtime_root = effective_runtime_root(registry_path, call.get("runtime_root_arg")) + if command == "archive": + role = str(call["role"]) + max_active_done = _non_negative_integer( + call["max_active_done"], "max_active_done", optional=False + ) + assert max_active_done is not None + if role not in {"user", "agent"}: + raise ValueError("todo role must be one of: user, agent") + project, state_file = resolve_todo_state_path( + registry_path=registry_path, + goal_id=goal_id, + project=call.get("project"), + state_file=call.get("state_file"), + ) + return archive_canonical_todos_if_promoted( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + role=role, + max_active_done=max_active_done, + dry_run=bool(call["dry_run"]), + project=project, + state_file=state_file, + ) + + next_agent_todo = call.get("next_agent_todo") + if call.get("next_task_repository") and not next_agent_todo: + raise ValueError("--next-task-repository requires --next-agent-todo") + if call.get("next_required_capabilities") and not next_agent_todo: + raise ValueError("--next-required-capability requires --next-agent-todo") + project, state_file = resolve_todo_state_path( + registry_path=registry_path, + goal_id=goal_id, + project=call.get("project"), + state_file=call.get("state_file"), + ) + complete = command == "complete" + return terminal_canonical_todo_if_promoted( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + command=command, + todo_id=str(call["todo_id"]), + role=call.get("role"), + actor_agent_id=call.get("agent_id"), + authority_reason=call.get("authority_reason"), + decision_outcome=call.get("decision_outcome") if complete else None, + evidence=call.get("evidence") if complete else None, + note=call.get("note") if complete else "superseded", + reason=None if complete else call.get("reason"), + completion_turn_key=call.get("completion_turn_key") if complete else None, + completion_identity_source=( + call.get("completion_identity_source") if complete else None + ), + task_lease_idempotency_key=call.get("task_lease_idempotency_key"), + task_lease_expected_version=_non_negative_integer( + call.get("task_lease_expected_version"), + "task_lease_expected_version", + optional=True, + ), + no_followup=bool(call.get("no_followup")) if complete else False, + successor_todo_ids=( + require_completion_successor_todo_ids(call.get("successor_todo_ids")) + if complete + else [] + ), + claimed_by=call.get("claimed_by") if complete else None, + clear_claim=bool(call.get("clear_claim")) if complete else False, + next_agent_todo=next_agent_todo, + next_user_todo=call.get("next_user_todo"), + next_user_task_class=resolve_next_user_task_class( + call.get("next_user_todo"), call.get("next_user_task_class") + ), + next_claimed_by=call.get("next_claimed_by"), + next_task_class=call.get("next_task_class"), + next_action_kind=call.get("next_action_kind"), + next_task_repository=call.get("next_task_repository"), + next_required_capabilities=call.get("next_required_capabilities"), + next_continuation_policy=call.get("next_continuation_policy"), + next_excluded_agents=call.get("next_excluded_agents"), + self_merged=bool(call.get("self_merged")) if complete else False, + dry_run=bool(call["dry_run"]), + project=project, + state_file=state_file, + ) + + +def provider_first_terminal_lifecycle(command: str) -> Callable[[TodoMutation], TodoMutation]: + """Route the public facade through canonical authority before legacy fallback.""" + if command not in {"complete", "supersede", "archive"}: + raise ValueError(f"unsupported terminal lifecycle command: {command}") + + def decorate(legacy: TodoMutation) -> TodoMutation: + call_signature = signature(legacy) + + @wraps(legacy) + def routed(*args: Any, **kwargs: Any) -> dict[str, Any]: + bound = call_signature.bind(*args, **kwargs) + bound.apply_defaults() + result = _route_terminal_call(command, bound.arguments) + return result if result is not None else legacy(*args, **kwargs) + + return routed + + return decorate + + +def _goal_facts( + registry_path: Path, goal_id: str +) -> tuple[list[str], list[dict[str, Any]]]: + goal = load_goal_from_registry(registry_path, goal_id) + registered = registered_agent_ids_for_goal(goal) + coordination = goal.get("coordination") if isinstance(goal, Mapping) else None + grants = normalize_todo_lifecycle_authority( + coordination.get("todo_lifecycle_authority") + if isinstance(coordination, Mapping) + else None, + registered_agents=registered, + ) + return registered, grants + + +def _todo_by_id( + todos: Iterable[Mapping[str, Any]], todo_id: str +) -> dict[str, Any] | None: + return next( + (dict(todo) for todo in todos if str(todo.get("todo_id") or "") == todo_id), + None, + ) + + +def _terminal_failure_payload( + result: Mapping[str, Any], *, goal_id: str, todo_id: str, dry_run: bool +) -> dict[str, Any] | None: + if result.get("status") != "failed": + return None + if result.get("reason_code") not in { + "validation_declaration_invalid", + "validation_failed", + }: + return None + return { + "ok": False, + "dry_run": dry_run, + "completed": False, + "changed": False, + "goal_id": goal_id, + "todo_id": todo_id, + "validation_blocked_completion": True, + "reason": result.get("reason"), + "validation_failure": result.get("validation_failure"), + **dict(result), + } + + +def _projection_payload(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise LocalCoordinationAuthorityUnavailable( + "canonical Todo projection returned an invalid result", + code="local_authority_todo_projection_invalid_result", + payload={"source_authority": "file_v0"}, + ) + return dict(value) + + +def _terminal_operation_id( + *, + command: str, + goal_id: str, + todo_id: str, + completion_turn_key: str | None, +) -> str: + """Name one logical terminal operation independently of retry prose.""" + + operation_identity = completion_turn_key or "unscoped" + digest = hashlib.sha256( + ( + "loopx-provider-terminal-operation-v0\0" + f"{command}\0{goal_id}\0{todo_id}\0{operation_identity}" + ).encode("utf-8") + ).hexdigest() + return f"todo-terminal:{digest[:32]}" + + +def _archive_operation_id( + *, + goal_id: str, + role: str, + max_active_done: int, + provider_revision: str, +) -> str: + """Bind one archive attempt to the canonical snapshot it selected from.""" + + digest = hashlib.sha256( + f"{goal_id}\0{role}\0{max_active_done}\0{provider_revision}".encode("utf-8") + ).hexdigest() + return f"todo-archive:{digest[:32]}" + + +def terminal_canonical_todo_if_promoted( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + command: str, + todo_id: str, + role: str | None, + actor_agent_id: str | None, + authority_reason: str | None, + decision_outcome: str | None, + evidence: str | None, + note: str | None, + reason: str | None, + completion_turn_key: str | None, + completion_identity_source: str | None, + task_lease_idempotency_key: str | None, + task_lease_expected_version: int | None, + no_followup: bool, + successor_todo_ids: list[str], + claimed_by: str | None, + clear_claim: bool, + next_agent_todo: str | None, + next_user_todo: str | None, + next_user_task_class: str | None, + next_claimed_by: str | None, + next_task_class: str | None, + next_action_kind: str | None, + next_task_repository: str | None, + next_required_capabilities: list[str] | None, + next_continuation_policy: str | None, + next_excluded_agents: list[str] | None, + self_merged: bool, + dry_run: bool, + project: Path | None = None, + state_file: Path | None = None, +) -> dict[str, Any] | None: + try: + canonical = read_canonical_todos_if_promoted( + runtime_root=runtime_root, goal_id=goal_id + ) + except LocalCoordinationAuthorityUnavailable as exc: + payload = dict(exc.payload) + if exc.code == "local_authority_todo_list_unavailable" and payload.get( + "status" + ) == "missing": + payload["recovery"] = { + "action": "restore_canonical_authority", + "runtime_root": str(runtime_root.expanduser().resolve(strict=False)), + "goal_id": goal_id, + "legacy_markdown_fallback_allowed": False, + "retry_after": "canonical_provider_readback_loaded", + } + raise LocalCoordinationAuthorityUnavailable( + str(exc), code=exc.code, payload=payload + ) from exc + if canonical is None: + return None + todos = [dict(todo) for todo in canonical["todos"]] + # The canonical transaction owns missing/role/archive lifecycle decisions. + # Keep only the optional local validation facts needed by the host adapter. + target = _todo_by_id(todos, todo_id) or {} + registered, grants = _goal_facts(registry_path, goal_id) + successor_intents = build_successor_intents( + next_agent_todo=next_agent_todo, + next_user_todo=next_user_todo, + next_user_task_class=next_user_task_class, + next_claimed_by=next_claimed_by, + next_task_class=next_task_class, + next_action_kind=next_action_kind, + next_task_repository=next_task_repository, + next_required_capabilities=next_required_capabilities, + next_continuation_policy=next_continuation_policy, + next_excluded_agents=next_excluded_agents, + ) + linked = [ + linked_successor_from_todo(todo) + for linked_id in successor_todo_ids + if (todo := _todo_by_id(todos, linked_id)) is not None + ] + completion_policy_request = ( + build_completion_policy_request( + registry_path=registry_path, + goal_id=goal_id, + claimed_by=claimed_by, + next_claimed_by=next_claimed_by, + next_agent_todo=next_agent_todo, + next_action_kind=next_action_kind, + next_continuation_policy=next_continuation_policy, + next_excluded_agents=next_excluded_agents or [], + self_merged=self_merged, + evidence=evidence, + linked_successors=linked, + ) + if command == "complete" + else None + ) + validation_declaration = None + if command == "complete" and target.get("completion_validation_required") is True: + if state_file is None: + raise ValueError( + "canonical Todo completion validation requires its private state projection" + ) + validation_declaration = resolve_private_completion_validation_declaration( + canonical_todo=target, + state_file=state_file, + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=goal_id, + todo_id=todo_id, + role=role, + persist_if_resolved=not dry_run, + ) + request = { + "schema_version": _TERMINAL_REQUEST_SCHEMA, + "runtime_root": str(runtime_root.expanduser().resolve(strict=False)), + "goal_id": goal_id, + "todo_id": todo_id, + "role": role, + "command": command, + "actor_agent_id": actor_agent_id, + "registered_agents": registered, + "lifecycle_grants": grants, + "authority_reason": authority_reason, + "decision_outcome": decision_outcome, + "operation_id": None, + "lease_idempotency_key": task_lease_idempotency_key, + "lease_expected_version": task_lease_expected_version, + "allow_user_gate_auto_acquire": command == "complete", + "requested_no_followup": no_followup, + "requested_completion_turn_key": completion_turn_key, + "requested_completion_identity_source": completion_identity_source, + "linked_successor_todo_ids": successor_todo_ids, + "successor_intents": successor_intents, + "note": note, + "evidence": evidence, + "reason": reason, + "clear_claim": clear_claim, + "validation_declaration": validation_declaration, + "validation_receipt": None, + "completion_policy_request": completion_policy_request, + "dry_run": dry_run, + "observed_at": now_local(), + } + request["operation_id"] = _terminal_operation_id( + command=command, + goal_id=goal_id, + todo_id=todo_id, + completion_turn_key=completion_turn_key, + ) + result = effect_runtime_result( + "coordination.local_authority.todo_terminal", request + ) + if isinstance(result, Mapping) and result.get("status") == "execute_validation": + effect = result.get("validation_effect") + if not isinstance(effect, Mapping): + raise RuntimeError("Todo terminal validation effect shape mismatch") + request["validation_receipt"] = run_declared_completion_validation_effect( + effect=effect, + registry_path=registry_path, + goal_id=goal_id, + ) + result = effect_runtime_result( + "coordination.local_authority.todo_terminal", request + ) + if not isinstance(result, Mapping): + raise LocalCoordinationAuthorityUnavailable( + "canonical Todo terminal transaction returned an invalid result", + code="local_authority_todo_terminal_invalid_result", + payload={"source_authority": "file_v0"}, + ) + validation_failure = _terminal_failure_payload( + result, goal_id=goal_id, todo_id=todo_id, dry_run=dry_run + ) + if validation_failure is not None: + return validation_failure + payload = dict(result) + if ( + payload.get("status") == "failed" + and payload.get("failure_kind") == "decision_rejection" + ): + raise LocalCoordinationAuthorityRejection( + str(payload.get("reason") or "canonical Todo terminal request was rejected"), + code=str(payload.get("reason_code") or "todo_terminal_rejected"), + payload=payload, + ) + if ( + payload.get("status") not in _ACCEPTED + or payload.get("source_authority") != "file_v0" + or payload.get("decision_read_from_provider") is not True + or payload.get("legacy_fallback_used") is not False + ): + raise LocalCoordinationAuthorityUnavailable( + str(payload.get("reason") or "canonical Todo terminal transaction failed"), + code=str( + payload.get("reason_code") or "local_authority_todo_terminal_failed" + ), + payload=payload, + ) + provider_status = str(payload.get("status") or "") + terminal_decision = payload.get("terminal_decision") + idempotent_replay = provider_status in {"replayed", "no_change"} or ( + isinstance(terminal_decision, Mapping) + and terminal_decision.get("idempotent") is True + ) + response = { + **payload, + "ok": True, + "dry_run": dry_run, + "completed": command == "complete", + "superseded": command == "supersede", + "goal_id": goal_id, + "role": target.get("role") or role, + "todo_id": todo_id, + "status": "planned" if dry_run else "done", + "provider_status": provider_status, + "idempotent_replay": idempotent_replay, + "state_file": str(state_file) if state_file is not None else None, + "project": str(project) if project is not None else None, + "updated_at": payload.get("completed_at") if payload.get("changed") else None, + "next_todos": payload.get("generated_successors") or [], + "mutation_authority": terminal_decision, + "task_lease_fence": terminal_decision, + } + return _projection_payload( + settle_canonical_todo_projection( + response, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + project=project, + state_file=state_file, + ) + ) + + +def archive_canonical_todos_if_promoted( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + role: str, + max_active_done: int, + dry_run: bool, + project: Path | None = None, + state_file: Path | None = None, +) -> dict[str, Any] | None: + authority_read = read_canonical_todos_if_promoted( + runtime_root=runtime_root, goal_id=goal_id + ) + if authority_read is None: + return None + provider_revision = authority_read.get("provider_revision") + if not isinstance(provider_revision, str) or not provider_revision: + raise LocalCoordinationAuthorityUnavailable( + "canonical Todo authority omitted provider revision", + code="local_authority_todo_archive_revision_missing", + payload=dict(authority_read), + ) + result = effect_runtime_result( + "coordination.local_authority.todo_archive", + { + "schema_version": _ARCHIVE_REQUEST_SCHEMA, + "runtime_root": str(runtime_root.expanduser().resolve(strict=False)), + "goal_id": goal_id, + "role": role, + "max_active_done": max_active_done, + "operation_id": _archive_operation_id( + goal_id=goal_id, + role=role, + max_active_done=max_active_done, + provider_revision=provider_revision, + ), + "dry_run": dry_run, + "observed_at": now_local(), + }, + ) + if not isinstance(result, Mapping) or result.get("status") not in _ACCEPTED: + payload = dict(result) if isinstance(result, Mapping) else {} + raise LocalCoordinationAuthorityUnavailable( + str(payload.get("reason") or "canonical Todo archive transaction failed"), + code=str( + payload.get("reason_code") or "local_authority_todo_archive_failed" + ), + payload=payload, + ) + return _projection_payload( + settle_canonical_todo_projection( + {"ok": True, "dry_run": dry_run, "goal_id": goal_id, **dict(result)}, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + project=project, + state_file=state_file, + ) + ) + + +__all__ = [ + "archive_canonical_todos_if_promoted", + "provider_first_terminal_lifecycle", + "terminal_canonical_todo_if_promoted", +] diff --git a/loopx/control_plane/todos/successor_derivation.py b/loopx/control_plane/todos/successor_derivation.py new file mode 100644 index 0000000000..65352a4b29 --- /dev/null +++ b/loopx/control_plane/todos/successor_derivation.py @@ -0,0 +1,135 @@ +"""Typed successor-intent adapter for terminal Todo lifecycle callers.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ..effect_runtime import effect_runtime_result +from .contract import normalize_todo_metadata_for_write + +TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA = "loopx_todo_successor_derivation_request_v0" +TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA = "loopx_todo_successor_derivation_result_v0" + +_SUCCESSOR_ADD_FIELDS = ( + "role", + "text", + "task_class", + "action_kind", + "capability_binding_ref", + "task_repository", + "continuation_policy", + "required_capabilities", + "claimed_by", + "bound_agent", + "blocks_agent", + "excluded_agents", + "unblocks_todo_id", +) + + +def build_successor_intents( + *, + next_agent_todo: str | None, + next_user_todo: str | None, + next_user_task_class: str | None, + next_claimed_by: str | None, + next_task_class: str | None, + next_action_kind: str | None, + next_task_repository: str | None, + next_required_capabilities: list[str] | None, + next_continuation_policy: str | None, + next_excluded_agents: list[str] | None, +) -> list[dict[str, Any]]: + """Serialize caller intent; defaults and inheritance remain TypeScript-owned.""" + + intents: list[dict[str, Any]] = [] + agent_options = ( + ("task_class", next_task_class), + ("action_kind", next_action_kind), + ("task_repository", next_task_repository), + ("required_capabilities", next_required_capabilities), + ("continuation_policy", next_continuation_policy), + ("claimed_by", next_claimed_by), + ("excluded_agents", next_excluded_agents), + ) + if next_agent_todo or any( + value is not None and value != "" and value != [] + for _key, value in agent_options + ): + intent: dict[str, Any] = { + "role": "agent", + "text": next_agent_todo or "", + } + intent.update( + normalize_todo_metadata_for_write( + {key: value for key, value in agent_options if value is not None} + ) + ) + intents.append(intent) + if next_user_todo: + intent = {"role": "user", "text": next_user_todo} + if next_user_task_class is not None: + intent["task_class"] = next_user_task_class + intents.append(intent) + return intents + + +def derive_successor_proposals( + *, + command: str, + predecessor: Mapping[str, Any], + registered_agents: list[str], + actor_agent_id: str | None, + completion_policy: Mapping[str, Any] | None, + successor_intents: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Call the TypeScript semantic owner and return provider-neutral proposals.""" + + result = effect_runtime_result( + "todo.successor.derive", + { + "schema_version": TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + "command": command, + "predecessor": dict(predecessor), + "registered_agents": registered_agents, + "actor_agent_id": actor_agent_id, + "completion_policy": ( + dict(completion_policy) if completion_policy is not None else None + ), + "successor_intents": successor_intents, + }, + ) + if ( + not isinstance(result, Mapping) + or result.get("schema_version") != TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA + or result.get("status") != "derived" + or not isinstance(result.get("successors"), list) + ): + payload = dict(result) if isinstance(result, Mapping) else {} + raise ValueError( + str(payload.get("reason") or "Todo successor derivation failed") + ) + proposals: list[dict[str, Any]] = [] + for index, value in enumerate(result["successors"]): + if not isinstance(value, Mapping): + raise ValueError(f"derived successor {index} must be an object") + proposals.append(dict(value)) + return proposals + + +def successor_add_kwargs(proposal: Mapping[str, Any]) -> dict[str, Any]: + """Adapt one typed proposal to the legacy Markdown writer arguments.""" + + return { + field: proposal[field] + for field in _SUCCESSOR_ADD_FIELDS + if field in proposal + } + + +__all__ = [ + "build_successor_intents", + "derive_successor_proposals", + "successor_add_kwargs", +] diff --git a/loopx/todos.py b/loopx/todos.py index 546874e530..9acbfd7bdf 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -12,7 +12,6 @@ from .state_refresh import now_local, resolve_goal_state from .status import MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE from .control_plane.todos.contract import ( - TodoContinuationPolicy, TODO_STATUS_DEFERRED, TODO_STATUS_DONE, TODO_STATUS_OPEN, @@ -45,7 +44,6 @@ parse_todo_metadata_line, require_todo_excluded_agents, resolve_next_user_task_class, - resolve_todo_continuation_policy, require_supported_todo_resume_when, todo_marker_for_status, ) @@ -99,11 +97,13 @@ from .control_plane.todos.external_wait_writeback import plan_todo_external_wait_update from .control_plane.todos.mutation_authority import authorize_todo_lifecycle_mutation, todo_update_authority_action from .control_plane.todos.succession_warning import build_open_parent_successor_advisory -from .control_plane.todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL -from .control_plane.todos.text import ( - inherit_todo_priority, - normalize_new_todo, +from .control_plane.todos.successor_derivation import ( + build_successor_intents, + derive_successor_proposals, + successor_add_kwargs, ) +from .control_plane.todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL +from .control_plane.todos.text import normalize_new_todo from .control_plane.todos.unblock_resume import ( apply_completed_user_todo_lifecycle, completion_decision_target, @@ -127,6 +127,8 @@ ) from .control_plane.todos.provider_compatibility_edit import edit_canonical_todo_if_promoted from .control_plane.todos.provider_create import create_canonical_todo_if_promoted +from .control_plane.todos.path_resolution import resolve_todo_state_path +from .control_plane.todos.provider_terminal_lifecycle import provider_first_terminal_lifecycle from .control_plane.todos.handoff_mode import ( enter_added_todo_ownership_handoff_gate, enter_todo_ownership_handoff_gate, @@ -166,27 +168,6 @@ def require_registered_todo_excluded_agents( ) -def resolve_todo_state_path( - *, - registry_path: Path, - goal_id: str, - project: Path | None = None, - state_file: Path | None = None, -) -> tuple[Path | None, Path]: - registry = load_registry(registry_path) - goal, resolved_project, resolved_state_file = resolve_goal_state( - registry=registry, - goal_id=goal_id, - project_override=project, - state_file_override=state_file, - ) - if goal is None: - raise ValueError(f"goal {goal_id!r} is not present in the registry") - if not resolved_state_file.exists(): - raise ValueError(f"active state file does not exist: {resolved_state_file}") - return resolved_project, resolved_state_file - - def list_goal_todos( *, registry_path: Path, @@ -1582,6 +1563,7 @@ def update_goal_todo( ) +@provider_first_terminal_lifecycle("complete") def complete_goal_todo( *, registry_path: Path, @@ -1872,67 +1854,38 @@ def complete_goal_todo( apply_update=apply_todo_update_to_lines, ) ) - next_unblocks_todo_id = ( - normalize_todo_id(str(update_result.get("todo_id") or todo_id)) - if next_agent_todo - else None - ) - next_user_bound_agent = None - if next_user_todo and len(registered_agents) > 1: - next_user_bound_agent = effective_claimed_by - if not next_user_bound_agent: - raise ValueError( - "multi-agent --next-user-todo requires a completing --claimed-by " - "agent so the user todo can be bound" - ) - next_results: list[dict[str, Any]] = [] - if next_agent_todo: - next_results.append( - add_todo_to_lines( - lines, - role="agent", - text=inherit_todo_priority( - next_agent_todo, - str(update_result.get("todo") or ""), - ), - task_class=next_task_class or "advancement_task", - action_kind=next_action_kind, - capability_binding_ref=completion_todo.get( - "capability_binding_ref" - ), - task_repository=next_task_repository, - required_capabilities=next_required_capabilities, - continuation_policy=next_continuation_policy, - claimed_by=effective_next_claimed_by, - excluded_agents=effective_next_excluded_agents, - unblocks_todo_id=next_unblocks_todo_id, - updated_at=updated_at, - ) - ) - if next_user_todo: - next_results.append( - add_todo_to_lines( - lines, - role="user", - text=inherit_todo_priority( - next_user_todo, - str(update_result.get("todo") or ""), - ), - task_class=effective_next_user_task_class, - action_kind=( - "gate" - if effective_next_user_task_class == TODO_TASK_CLASS_USER_GATE - else None - ), - bound_agent=next_user_bound_agent, - blocks_agent=( - next_user_bound_agent - if effective_next_user_task_class == TODO_TASK_CLASS_USER_GATE - else None - ), - updated_at=updated_at, - ) + successor_intents = build_successor_intents( + next_agent_todo=next_agent_todo, + next_user_todo=next_user_todo, + next_user_task_class=effective_next_user_task_class, + next_claimed_by=next_claimed_by, + next_task_class=next_task_class, + next_action_kind=next_action_kind, + next_task_repository=next_task_repository, + next_required_capabilities=next_required_capabilities, + next_continuation_policy=next_continuation_policy, + next_excluded_agents=next_excluded_agents, + ) + successor_proposals = derive_successor_proposals( + command="complete", + predecessor=completion_todo, + registered_agents=registered_agents, + actor_agent_id=mutation_authority.get("actor_agent_id"), + completion_policy={ + "effective_claimed_by": effective_claimed_by, + "effective_next_claimed_by": effective_next_claimed_by, + "effective_next_excluded_agents": effective_next_excluded_agents, + }, + successor_intents=successor_intents, + ) + next_results = [ + add_todo_to_lines( + lines, + **successor_add_kwargs(proposal), + updated_at=updated_at, ) + for proposal in successor_proposals + ] generated_successor_todo_ids = [ todo_id for todo_id in normalize_todo_id_list([item.get("todo_id") for item in next_results]) @@ -1996,6 +1949,7 @@ def complete_goal_todo( goal_id=goal_id, write_class="todo_complete", capture=shadow_capture, ) +@provider_first_terminal_lifecycle("supersede") def supersede_goal_todo( *, registry_path: Path, @@ -2069,17 +2023,6 @@ def supersede_goal_todo( idempotency_key=task_lease_idempotency_key, expected_version=task_lease_expected_version, runtime_root=shadow_runtime_root, ) - effective_next_claimed_by = ( - require_registered_agent_id(registry_path=registry_path, goal_id=goal_id, agent_id=next_claimed_by, field="next_claimed_by") - if next_claimed_by else None - ) - effective_next_excluded_agents = require_registered_todo_excluded_agents( - registry_path=registry_path, goal_id=goal_id, excluded_agents=next_excluded_agents, field="next_excluded_agents", - ) - if effective_next_claimed_by and not next_agent_todo: - raise ValueError("--next-claimed-by requires --next-agent-todo") - if effective_next_excluded_agents and not next_agent_todo: - raise ValueError("--next-excluded-agent requires --next-agent-todo") update_result = apply_todo_update_to_lines( lines, todo_id=todo_id, @@ -2089,87 +2032,35 @@ def supersede_goal_todo( note="superseded", updated_at=updated_at, ) - current_claimed_by = normalize_todo_claimed_by(update_result.get("claimed_by")) - next_policy = resolve_todo_continuation_policy( - next_continuation_policy, - action_kind=next_action_kind, - ) - if ( - next_agent_todo - and not effective_next_claimed_by - and next_policy == TodoContinuationPolicy.SAME_AGENT_NON_DELIVERY - ): - effective_next_claimed_by = current_claimed_by - if effective_next_claimed_by in effective_next_excluded_agents: - raise ValueError( - f"next_claimed_by={effective_next_claimed_by!r} cannot also appear in " - "next_excluded_agents" - ) - next_unblocks_todo_id = normalize_todo_id(update_result.get("unblocks_todo_id")) registered_agents = registered_agent_ids_from_registry(registry_path, goal_id) - next_user_bound_agent = ( - normalize_todo_bound_agent(update_result.get("bound_agent")) - or normalize_todo_blocks_agent(update_result.get("blocks_agent")) - ) - if next_user_todo and len(registered_agents) > 1 and not next_user_bound_agent: - next_user_bound_agent = ( - normalize_todo_claimed_by(update_result.get("claimed_by")) - or effective_next_claimed_by - ) - if not next_user_bound_agent: - raise ValueError( - "multi-agent supersede --next-user-todo requires inherited " - "blocks_agent, current claimed_by, or next_claimed_by " - "so the user todo can be bound" - ) - next_results: list[dict[str, Any]] = [] - if next_agent_todo: - next_results.append( - add_todo_to_lines( - lines, - role="agent", - text=inherit_todo_priority( - next_agent_todo, - str(update_result.get("todo") or ""), - ), - task_class=next_task_class or "advancement_task", - action_kind=next_action_kind, - capability_binding_ref=current_block.get( - "capability_binding_ref" - ), - task_repository=next_task_repository, - required_capabilities=next_required_capabilities, - continuation_policy=next_continuation_policy, - claimed_by=effective_next_claimed_by, - excluded_agents=effective_next_excluded_agents, - unblocks_todo_id=next_unblocks_todo_id, - updated_at=updated_at, - ) - ) - if next_user_todo: - next_results.append( - add_todo_to_lines( - lines, - role="user", - text=inherit_todo_priority( - next_user_todo, - str(update_result.get("todo") or ""), - ), - task_class=effective_next_user_task_class, - action_kind=( - "gate" - if effective_next_user_task_class == TODO_TASK_CLASS_USER_GATE - else None - ), - bound_agent=next_user_bound_agent, - blocks_agent=( - next_user_bound_agent - if effective_next_user_task_class == TODO_TASK_CLASS_USER_GATE - else None - ), - updated_at=updated_at, - ) + successor_intents = build_successor_intents( + next_agent_todo=next_agent_todo, + next_user_todo=next_user_todo, + next_user_task_class=effective_next_user_task_class, + next_claimed_by=next_claimed_by, + next_task_class=next_task_class, + next_action_kind=next_action_kind, + next_task_repository=next_task_repository, + next_required_capabilities=next_required_capabilities, + next_continuation_policy=next_continuation_policy, + next_excluded_agents=next_excluded_agents, + ) + successor_proposals = derive_successor_proposals( + command="supersede", + predecessor=authority_todo, + registered_agents=registered_agents, + actor_agent_id=mutation_authority.get("actor_agent_id"), + completion_policy=None, + successor_intents=successor_intents, + ) + next_results = [ + add_todo_to_lines( + lines, + **successor_add_kwargs(proposal), + updated_at=updated_at, ) + for proposal in successor_proposals + ] generated_successor_todo_ids = [ todo_id for todo_id in normalize_todo_id_list([item.get("todo_id") for item in next_results]) @@ -2219,6 +2110,7 @@ def supersede_goal_todo( ) +@provider_first_terminal_lifecycle("archive") def archive_completed_todos( *, registry_path: Path, diff --git a/package-lock.json b/package-lock.json index 51b2a9a1d6..2af980de4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@types/node": "^24.3.0", "@types/pg": "^8.23.1", + "c8": "^11.0.0", "pg": "^8.23.0", "typescript": "^6.0.3" }, @@ -18,6 +19,61 @@ "node": ">=22.6" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -40,6 +96,435 @@ "pg-types": "^2.2.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/c8": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -180,6 +665,65 @@ "node": ">=0.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -190,6 +734,62 @@ "node": ">= 10.x" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -211,6 +811,55 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -220,6 +869,58 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index d9d188dbf6..a5079c8381 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ }, "scripts": { "test:control-plane": "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/*.test.ts", + "test:control-plane:coverage": "c8 --all --include=loopx/control_plane/**/*.ts --exclude=loopx/control_plane/**/*.generated.ts --reporter=lcov --reporter=text --reports-dir=coverage/control-plane node --no-warnings --experimental-strip-types --test tests/control_plane_ts/*.test.ts", "test:postgresql-authority-store": "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/postgresql_authority_store.integration.test.ts", "typecheck:control-plane": "tsc --project tsconfig.control-plane.json --noEmit" }, "devDependencies": { "@types/node": "^24.3.0", "@types/pg": "^8.23.1", + "c8": "^11.0.0", "pg": "^8.23.0", "typescript": "^6.0.3" } diff --git a/sonar-project.properties b/sonar-project.properties index 5def3d293b..1b675f5880 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -19,5 +19,8 @@ sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/src-tauri/**,loopx sonar.python.version=3.11 sonar.python.coverage.reportPaths=coverage.xml +# TypeScript control plane +sonar.javascript.lcov.reportPaths=coverage/control-plane/lcov.info + # Non-blocking by default: report findings, never gate the workflow. sonar.qualitygate.wait=false diff --git a/tests/control_plane/test_completed_archive.py b/tests/control_plane/test_completed_archive.py index 5c3bd35a08..f5ec66478f 100644 --- a/tests/control_plane/test_completed_archive.py +++ b/tests/control_plane/test_completed_archive.py @@ -1,5 +1,8 @@ from __future__ import annotations +import pytest + +from loopx.control_plane.todos import completed_archive from loopx.control_plane.todos.completed_archive import ( archive_completed_todo_lines, completed_todo_archive_warning, @@ -51,3 +54,48 @@ def test_completed_todo_count_fails_closed_for_invalid_summary_counts() -> None: assert completed_todo_count({"done_count": 1}) == 0 assert completed_todo_count({"done_count": "invalid", "deferred_count": 1}) == 0 assert completed_todo_count({"done_count": 1, "deferred_count": 2}) == 0 + + +def test_legacy_archive_calls_typed_selector_once_per_batch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, dict]] = [] + + def select(method: str, params: dict) -> dict: + calls.append((method, params)) + return { + "schema_version": "loopx_coordination_todo_archive_selection_v0", + "role": "agent", + "active_done_before": 2, + "active_done_after": 1, + "max_active_done": 1, + "moved_count": 1, + "moved_todo_ids": ["todo_first"], + "retained_standing_decision_count": 0, + } + + monkeypatch.setattr(completed_archive, "effect_runtime_result", select) + original = ( + "# Goal\n\n## Agent Todo\n\n" + "- [x] First.\n" + " \n" + "- [x] Second.\n" + " \n\n" + "## Completed Work Archive\n" + ) + + result = archive_completed_todo_lines( + original.splitlines(), + max_active_done=1, + ) + + assert len(calls) == 1 + assert calls[0][0] == "todo.archive.select" + assert [todo["todo_id"] for todo in calls[0][1]["todos"]] == [ + "todo_first", + "todo_second", + ] + assert all(todo["archive_state"] == "active" for todo in calls[0][1]["todos"]) + assert result["moved_count"] == 1 + updated = "\n".join(result["lines"]) + assert updated.index("Second.") < updated.index("First.") diff --git a/tests/control_plane/test_coordination_runtime_shadow_adapter.py b/tests/control_plane/test_coordination_runtime_shadow_adapter.py index 76aa7d1ff5..e0a6cd149f 100644 --- a/tests/control_plane/test_coordination_runtime_shadow_adapter.py +++ b/tests/control_plane/test_coordination_runtime_shadow_adapter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -462,20 +463,111 @@ def test_todo_projection_is_complete_stable_and_declares_read_model_contract() - }, {"status": "open"}, ], + leases=[ + { + "todo_id": "todo_b", + "owner": "agent-a", + "version": 2, + "lease_epoch": 1, + "status": "released", + }, + { + "todo_id": "todo_retired", + "owner": "agent-a", + "version": 9, + "lease_epoch": 4, + "status": "released", + }, + ], ) assert [item["todo_id"] for item in projection["todos"]] == [ "todo_a", "todo_b", ] - assert projection["todos"][1]["note"] == "operator note retained by local canonical authority" - assert projection["todos"][1]["evidence"] == "durable evidence retained for Todo list semantics" - assert projection["leases"] == [] + assert ( + projection["todos"][1]["note"] + == "operator note retained by local canonical authority" + ) + assert ( + projection["todos"][1]["evidence"] + == "durable evidence retained for Todo list semantics" + ) + assert projection["leases"] == [ + { + "todo_id": "todo_b", + "owner": "agent-a", + "version": 2, + "lease_epoch": 1, + "status": "released", + } + ] assert projection["todo_read_model"]["todo_count"] == 2 assert "text" in projection["todo_read_model"]["contract_fields"] assert "resume_when" in projection["todo_read_model"]["contract_fields"] +def test_production_scale_projection_filters_retired_lease_history() -> None: + envelope = json.loads( + ( + Path(__file__).parents[1] + / "fixtures" + / "control_plane" + / "coordination_production_scale_v0.json" + ).read_text(encoding="utf-8") + ) + todos: list[dict[str, object]] = [] + for role, counts in ( + ("agent", envelope["agent_status_counts"]), + ("user", envelope["user_status_counts"]), + ): + index = 0 + for status, count in counts.items(): + for _ in range(count): + todos.append( + _canonical_todo( + todo_id=f"todo_fixture_{role}_{index:03d}", + role=role, + status=status, + index=index + 1, + ) + ) + index += 1 + current_lease_count = int(envelope["current_lease_count"]) + current_ids = [str(todo["todo_id"]) for todo in todos[:current_lease_count]] + leases = [ + { + "todo_id": todo_id, + "owner": "agent-a", + "version": index + 1, + "lease_epoch": index + 1, + "status": "released", + } + for index, todo_id in enumerate(current_ids) + ] + leases.extend( + { + "todo_id": f"todo_fixture_retired_{index:03d}", + "owner": "agent-a", + "version": index + 1, + "lease_epoch": index + 1, + "status": "released", + } + for index in range(int(envelope["retired_lease_count"])) + ) + + projection = build_todo_runtime_shadow_projection( + goal_id="goal-production-scale", + todos=todos, + leases=leases, + ) + + assert len(projection["todos"]) == len(todos) == 464 + assert len(leases) == 224 + assert len(projection["leases"]) == current_lease_count == 64 + assert {lease["todo_id"] for lease in projection["leases"]} == set(current_ids) + + def test_todo_projection_rejects_incomplete_consumer_semantics() -> None: with pytest.raises(ValueError, match="omits required fields"): build_todo_runtime_shadow_projection( @@ -508,12 +600,6 @@ def test_todo_projection_rejects_unversioned_machine_owned_fields() -> None: - - - - - - def test_lease_projection_preserves_complete_terminal_record(tmp_path: Path) -> None: lease_dir = tmp_path / "goals" / "goal-a" / "task-leases" lease_dir.mkdir(parents=True) diff --git a/tests/control_plane/test_local_authority_shadow_outbox.py b/tests/control_plane/test_local_authority_shadow_outbox.py index 2e3a594916..2436e4dee9 100644 --- a/tests/control_plane/test_local_authority_shadow_outbox.py +++ b/tests/control_plane/test_local_authority_shadow_outbox.py @@ -81,7 +81,18 @@ def _fixture(tmp_path: Path, *, bootstrap: bool = True) -> tuple[Path, Path, Pat operation_id="bootstrap:outbox-test", source_version="source:initial", projection=projection, source_snapshot=snapshot, ) - assert result["status"] == "applied", result + # The managed Effect runtime may lose the first response after the + # durable bootstrap commit and retry the same operation. Windows CI is + # slow enough to exercise that path, so the public success contract is + # applied/recovered/replayed rather than applied-only. + assert result["status"] in {"applied", "recovered", "replayed"}, result + assert result["operation_id"] == "bootstrap:outbox-test", result + assert result["cursor"] == "1", result + assert result["provider_revision"] == result["bootstrap_provider_revision"], result + binding = require_shadow_primary_write_allowed(runtime_root, GOAL_ID) + assert binding is not None, result + assert binding["bootstrap_operation_id"] == result["operation_id"], result + assert binding["bootstrap_provider_revision"] == result["provider_revision"], result return registry, state, runtime_root diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index bfa70026ab..de9bd1d4d8 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -5,9 +5,19 @@ import sys from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from threading import Barrier import pytest +from canonical_authority_fixture import initialize_canonical_authority +from loopx.control_plane.coordination import local_authority as local_authority_module +from loopx.control_plane.coordination.coordination_state_contract import ( + TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, + TODO_DOMAIN_RECORD_FIELDS, +) +from loopx.control_plane.coordination.legacy_writer_fence import ( + legacy_coordination_writer_fence_path, +) from loopx.control_plane.coordination.local_authority import ( LocalCoordinationAuthorityRejection, LocalCoordinationAuthorityUnavailable, @@ -17,17 +27,32 @@ from loopx.control_plane.coordination.runtime_shadow import ( build_todo_runtime_shadow_projection, ) -from loopx.control_plane.coordination.coordination_state_contract import ( - TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, - TODO_DOMAIN_RECORD_FIELDS, +from loopx.control_plane.todos import ( + provider_create, + provider_projection, + provider_terminal_lifecycle, ) -from loopx.control_plane.todos.active_state_editing import TODO_SECTION_HEADINGS -from loopx.control_plane.todos import provider_projection -from loopx.control_plane.coordination.legacy_writer_fence import ( - legacy_coordination_writer_fence_path, +from loopx.control_plane.todos.active_state_editing import ( + TODO_SECTION_HEADINGS, + section_bounds, + todo_blocks, +) +from loopx.control_plane.todos.completion_validation_projection import ( + completion_validation_declaration_sha256, + project_completion_validation_authority, +) +from loopx.control_plane.todos.completion_validation_store import ( + completion_validation_declaration_path, + read_completion_validation_declaration, +) +from loopx.control_plane.todos.contract import format_todo_metadata_line +from loopx.todos import ( + add_goal_todo, + archive_completed_todos, + complete_goal_todo, + list_goal_todos, + supersede_goal_todo, ) -from loopx.todos import add_goal_todo, list_goal_todos -from canonical_authority_fixture import initialize_canonical_authority def _engage_fence(runtime_root: Path, goal_id: str = "goal-a") -> None: @@ -46,6 +71,55 @@ def _todo_read_model(todo_count: int) -> dict[str, object]: } +@pytest.mark.parametrize("invalid", [True, "1", 1.5]) +def test_python_terminal_adapter_rejects_coercible_numeric_values( + tmp_path: Path, + invalid: object, +) -> None: + state_file = tmp_path / "ACTIVE_GOAL_STATE.md" + state_file.write_text("# Goal\n\n## Agent Todo\n", encoding="utf-8") + runtime_root = tmp_path / "runtime" + registry = tmp_path / "registry.json" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "repo": str(tmp_path), + "state_file": state_file.name, + } + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="max_active_done must be a non-negative integer"): + archive_completed_todos( + registry_path=registry, + goal_id="goal-a", + max_active_done=invalid, # type: ignore[arg-type] + dry_run=False, + ) + with pytest.raises( + ValueError, + match="task_lease_expected_version must be a non-negative integer or None", + ): + complete_goal_todo( + registry_path=registry, + goal_id="goal-a", + todo_id="todo-a", + evidence="strict adapter validation", + task_lease_idempotency_key="lease-a", + task_lease_expected_version=invalid, # type: ignore[arg-type] + next_agent_todo="Continue after strict validation.", + next_task_class="advancement_task", + ) + assert not runtime_root.exists() + + def test_absent_fence_preserves_legacy_path_without_starting_typescript( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -180,9 +254,13 @@ def test_promoted_add_invokes_native_create_without_markdown_state( def _create(method: str, params: dict[str, object]) -> dict[str, object]: calls.append((method, params)) + todo = params["todo"] + assert isinstance(todo, dict) return { "status": "applied", "changed": True, + "todo_id": todo["todo_id"], + "todo": todo, "source_authority": "file_v0", "decision_read_from_provider": True, "legacy_fallback_used": False, @@ -207,7 +285,16 @@ def _create(method: str, params: dict[str, object]) -> dict[str, object]: assert calls[0][0] == "coordination.local_authority.todo_create" assert calls[0][1]["todo"]["schema_version"] == "todo_domain_record_v0" assert calls[0][1]["todo"]["claimed_by"] == "agent-a" - assert calls[0][1]["todo"]["validation_command_argv"] == ["python", "-c", "pass"] + assert calls[0][1]["todo"]["completion_validation_required"] is True + assert len(calls[0][1]["todo"]["completion_validation_sha256"]) == 64 + assert "validation_command_argv" not in calls[0][1]["todo"] + private_declaration = read_completion_validation_declaration( + runtime_root=tmp_path / "runtime", + goal_id="goal-a", + todo_id=str(result["todo_id"]), + ) + assert private_declaration is not None + assert private_declaration["validation_command_argv"] == ["python", "-c", "pass"] assert calls[0][1]["registered_agents"] == ["agent-a", "agent-b"] @@ -273,6 +360,247 @@ def _create(method: str, params: dict[str, object]) -> dict[str, object]: assert calls[0][0] == "coordination.local_authority.todo_create" +def _promoted_create_fixture(tmp_path: Path) -> tuple[Path, Path, Path]: + runtime_root = tmp_path / "runtime" + project = tmp_path / "project" + state_file = project / ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md" + state_file.parent.mkdir(parents=True) + state_file.write_text( + "# Goal\n\n## User Todo / Owner Review Reading Queue\n\n" + "## Agent Todo\n\n## Completed Work Archive\n", + encoding="utf-8", + ) + registry_path = tmp_path / "registry.json" + registry_path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "repo": str(project), + "state_file": ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md", + "coordination": {"registered_agents": ["agent-a"]}, + } + ], + } + ), + encoding="utf-8", + ) + projection = build_todo_runtime_shadow_projection( + goal_id="goal-a", todos=[], handoff_mode="soft_claim" + ) + projection["todo_read_model"] = { + **projection["todo_read_model"], + "schema_version": TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, + "contract_fields": list(TODO_DOMAIN_RECORD_FIELDS), + } + initialize_canonical_authority( + runtime_root, "goal-a", projection, state_path=state_file + ) + return registry_path, runtime_root, state_file + + +def test_rejected_validated_create_publishes_no_private_sidecar( + tmp_path: Path, +) -> None: + registry_path, runtime_root, _state_file = _promoted_create_fixture(tmp_path) + first = add_goal_todo( + registry_path=registry_path, + goal_id="goal-a", + role="agent", + text="Keep one accepted validation declaration", + claimed_by="agent-a", + agent_id="agent-a", + validation_command_json=json.dumps(["python3", "-c", "raise SystemExit(0)"]), + validation_label="accepted declaration", + ) + first_id = str(first["todo_id"]) + accepted = read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id="goal-a", + todo_id=first_id, + ) + assert accepted is not None + + with pytest.raises(LocalCoordinationAuthorityUnavailable) as exc_info: + add_goal_todo( + registry_path=registry_path, + goal_id="goal-a", + role="agent", + text="Keep one accepted validation declaration", + claimed_by="agent-a", + agent_id="agent-a", + validation_command_json=json.dumps( + ["python3", "-c", "raise SystemExit(7)"] + ), + validation_label="rejected declaration", + ) + assert exc_info.value.code == "todo_semantic_duplicate_conflict" + declaration_dir = completion_validation_declaration_path( + runtime_root=runtime_root, + goal_id="goal-a", + todo_id=first_id, + ).parent + assert [path.name for path in declaration_dir.glob("*.json")] == [ + f"{first_id}.json" + ] + assert ( + read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id="goal-a", + todo_id=first_id, + ) + == accepted + ) + + +def test_concurrent_validated_create_publishes_only_the_canonical_winner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + registry_path, runtime_root, _state_file = _promoted_create_fixture(tmp_path) + real_read = provider_create.read_canonical_todos_if_promoted + barrier = Barrier(2) + + def synchronized_read(**kwargs: object) -> dict[str, object] | None: + result = real_read(**kwargs) # type: ignore[arg-type] + barrier.wait(timeout=10) + return result + + monkeypatch.setattr( + provider_create, + "read_canonical_todos_if_promoted", + synchronized_read, + ) + + def create(label: str, exit_code: int) -> tuple[str, object]: + try: + return ( + "accepted", + add_goal_todo( + registry_path=registry_path, + goal_id="goal-a", + role="agent", + text="Resolve concurrent validation ownership", + claimed_by="agent-a", + agent_id="agent-a", + validation_command_json=json.dumps( + ["python3", "-c", f"raise SystemExit({exit_code})"] + ), + validation_label=label, + ), + ) + except LocalCoordinationAuthorityUnavailable as exc: + return ("rejected", exc.code) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list( + executor.map( + lambda values: create(*values), + [("candidate-a", 0), ("candidate-b", 7)], + ) + ) + assert [kind for kind, _value in results].count("accepted") == 1 + assert [kind for kind, _value in results].count("rejected") == 1 + assert next(value for kind, value in results if kind == "rejected") == ( + "todo_semantic_duplicate_conflict" + ) + + canonical = read_canonical_todos_if_promoted( + runtime_root=runtime_root, goal_id="goal-a" + ) + assert canonical is not None and len(canonical["todos"]) == 1 + canonical_todo = canonical["todos"][0] + canonical_todo_id = str(canonical_todo["todo_id"]) + stored = read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id="goal-a", + todo_id=canonical_todo_id, + ) + assert stored is not None + assert completion_validation_declaration_sha256(stored) == ( + canonical_todo["completion_validation_sha256"] + ) + declaration_dir = completion_validation_declaration_path( + runtime_root=runtime_root, + goal_id="goal-a", + todo_id=canonical_todo_id, + ).parent + assert [path.name for path in declaration_dir.glob("*.json")] == [ + f"{canonical_todo_id}.json" + ] + + +def test_validated_create_recovers_sidecar_after_commit_before_publish_crash( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + registry_path, runtime_root, _state_file = _promoted_create_fixture(tmp_path) + real_persist = provider_create.persist_completion_validation_declaration + persist_calls = 0 + + def crash_once(**kwargs: object) -> str: + nonlocal persist_calls + persist_calls += 1 + if persist_calls == 1: + raise OSError("injected validation sidecar publication crash") + return real_persist(**kwargs) # type: ignore[arg-type] + + monkeypatch.setattr( + provider_create, + "persist_completion_validation_declaration", + crash_once, + ) + create_kwargs = { + "registry_path": registry_path, + "goal_id": "goal-a", + "role": "agent", + "text": "Recover the private declaration publication", + "claimed_by": "agent-a", + "agent_id": "agent-a", + "validation_command_json": json.dumps( + ["python3", "-c", "raise SystemExit(0)"] + ), + "validation_label": "recover publication", + } + with pytest.raises(OSError, match="publication crash"): + add_goal_todo(**create_kwargs) + + canonical = read_canonical_todos_if_promoted( + runtime_root=runtime_root, + goal_id="goal-a", + ) + assert canonical is not None and len(canonical["todos"]) == 1 + todo_id = str(canonical["todos"][0]["todo_id"]) + assert read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id="goal-a", + todo_id=todo_id, + ) is None + + recovered = add_goal_todo(**create_kwargs) + assert recovered["status"] == "no_change" + assert recovered["todo_id"] == todo_id + assert recovered["added"] is False + assert recovered["already_exists"] is True + stored = read_completion_validation_declaration( + runtime_root=runtime_root, + goal_id="goal-a", + todo_id=todo_id, + ) + assert stored is not None + assert completion_validation_declaration_sha256(stored) == ( + canonical["todos"][0]["completion_validation_sha256"] + ) + canonical_after = read_canonical_todos_if_promoted( + runtime_root=runtime_root, + goal_id="goal-a", + ) + assert canonical_after is not None and len(canonical_after["todos"]) == 1 + + def test_promoted_native_create_recovers_markdown_after_delivery_crash( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -285,28 +613,37 @@ def test_promoted_native_create_recovers_markdown_after_delivery_crash( Human context. -## User Todo / Owner Review Reading Queue - ## Agent Todo -## Completed Work Archive - ## Next Action Continue. """ state_file.write_text(source, encoding="utf-8") registry_path = tmp_path / "registry.json" - registry_path.write_text(json.dumps({ - "schema_version": 1, - "common_runtime_root": str(runtime_root), - "goals": [{ - "id": "goal-a", "status": "active", "repo": str(project), - "state_file": ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md", - "coordination": {"registered_agents": ["agent-a", "agent-b"]}, - }], - }), encoding="utf-8") - projection = build_todo_runtime_shadow_projection(goal_id="goal-a", todos=[]) + registry_path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "status": "active", + "repo": str(project), + "state_file": ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md", + "coordination": { + "registered_agents": ["agent-a", "agent-b"] + }, + } + ], + } + ), + encoding="utf-8", + ) + projection = build_todo_runtime_shadow_projection( + goal_id="goal-a", todos=[], handoff_mode="soft_claim" + ) projection["todo_read_model"] = { **projection["todo_read_model"], "schema_version": TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, @@ -329,6 +666,8 @@ def crash(*_args: object, **_kwargs: object) -> None: action_kind="implement", claimed_by="agent-a", agent_id="agent-a", + validation_command_json=json.dumps(["python3", "-c", "raise SystemExit(0)"]), + validation_label="recoverable provider validation", ) assert applied["status"] == "applied" @@ -339,6 +678,9 @@ def crash(*_args: object, **_kwargs: object) -> None: ) assert canonical is not None assert canonical["todos"][0]["schema_version"] == "todo_domain_record_v0" + assert canonical["todos"][0]["completion_validation_required"] is True + assert len(canonical["todos"][0]["completion_validation_sha256"]) == 64 + assert "validation_command_argv" not in canonical["todos"][0] assert state_file.read_text(encoding="utf-8") == source monkeypatch.setattr(provider_projection, "_atomic_write_text", real_write) @@ -351,13 +693,29 @@ def crash(*_args: object, **_kwargs: object) -> None: action_kind="implement", claimed_by="agent-a", agent_id="agent-a", + validation_command_json=json.dumps(["python3", "-c", "raise SystemExit(0)"]), + validation_label="recoverable provider validation", ) assert replay["status"] == "no_change" assert replay["projection_delivery"] == "delivered" rendered = state_file.read_text(encoding="utf-8") assert "Recover the native compatibility projection" in rendered + assert "## User Todo / Owner Review Reading Queue" in rendered assert "Human context." in rendered assert "Continue." in rendered + completed = complete_goal_todo( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id="goal-a", + todo_id=str(applied["todo_id"]), + role="agent", + claimed_by="agent-a", + agent_id="agent-a", + no_followup=True, + ) + assert completed["status"] == "done" + assert completed["provider_status"] == "applied" + assert completed["validation_receipt"]["passed"] is True def test_engaged_fence_never_falls_back_when_provider_is_missing( @@ -702,6 +1060,7 @@ def test_canonical_hard_lease_claim_cli_atomically_acquires_ownership( capture_output=True, text=True, timeout=30, + check=False, ) assert initial_failure.returncode == 1 failure_payload = json.loads(initial_failure.stdout) @@ -768,6 +1127,737 @@ def test_canonical_hard_lease_claim_cli_atomically_acquires_ownership( assert not state_file.exists() +def test_promoted_terminal_lifecycle_commits_successors_and_archive_natively( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_root = tmp_path / "runtime" + project = tmp_path / "project" + state_file = project / ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md" + state_file.parent.mkdir(parents=True) + validation_argv = ["python3", "-c", "raise SystemExit(0)"] + complete_metadata = format_todo_metadata_line( + todo_id="todo_complete_native", + status="open", + task_class="advancement_task", + claimed_by="agent-a", + validation_command_argv=json.dumps(validation_argv), + validation_label="provider terminal integration", + validation_timeout_seconds=5, + ) + supersede_metadata = format_todo_metadata_line( + todo_id="todo_supersede_native", + status="open", + task_class="advancement_task", + claimed_by="agent-b", + ) + state_file.write_text( + f"""# Goal + +Human narrative remains outside canonical Todo authority. + +## User Todo / Owner Review Reading Queue + +## Agent Todo + +- [ ] Complete through TypeScript authority +{complete_metadata} +- [ ] Supersede through TypeScript authority +{supersede_metadata} + +## Completed Work Archive + +## Next Action + +Continue provider-first delivery. +""", + encoding="utf-8", + ) + registry_path = tmp_path / "registry.json" + registry_path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "status": "active", + "repo": str(project), + "state_file": ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md", + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a", "agent-b"], + }, + } + ], + } + ), + encoding="utf-8", + ) + todos = [ + { + "schema_version": "todo_item_v0", + "index": 1, + "done": False, + "text": "Complete through TypeScript authority", + "todo_id": "todo_complete_native", + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": TODO_SECTION_HEADINGS["agent"], + "task_class": "advancement_task", + "claimed_by": "agent-a", + "validation_command_argv": validation_argv, + "validation_label": "provider terminal integration", + "validation_timeout_seconds": 5, + }, + { + "schema_version": "todo_item_v0", + "index": 2, + "done": False, + "text": "Supersede through TypeScript authority", + "todo_id": "todo_supersede_native", + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": TODO_SECTION_HEADINGS["agent"], + "task_class": "advancement_task", + "claimed_by": "agent-b", + }, + ] + projection = build_todo_runtime_shadow_projection( + goal_id="goal-a", + todos=[project_completion_validation_authority(todo) for todo in todos], + handoff_mode="soft_claim", + ) + initialize_canonical_authority( + runtime_root, + "goal-a", + projection, + state_path=state_file, + ) + runtime_calls: list[str] = [] + archive_operation_ids: list[str] = [] + original_effect_runtime_result = provider_terminal_lifecycle.effect_runtime_result + original_authority_runtime_result = local_authority_module.effect_runtime_result + + def count_runtime_call(method: str, params: dict[str, object]) -> object: + runtime_calls.append(method) + if method == "coordination.local_authority.todo_archive": + archive_operation_ids.append(str(params["operation_id"])) + return original_effect_runtime_result(method, params) + + def count_authority_runtime_call( + method: str, params: dict[str, object] + ) -> object: + runtime_calls.append(method) + return original_authority_runtime_result(method, params) + + monkeypatch.setattr( + provider_terminal_lifecycle, + "effect_runtime_result", + count_runtime_call, + ) + monkeypatch.setattr( + local_authority_module, + "effect_runtime_result", + count_authority_runtime_call, + ) + + completed = complete_goal_todo( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id="goal-a", + todo_id="todo_complete_native", + role="agent", + claimed_by="agent-a", + agent_id="agent-a", + next_agent_todo="Continue after the native terminal commit", + next_claimed_by="agent-b", + next_task_class="advancement_task", + next_action_kind="implement", + evidence="provider integration passed", + ) + assert completed["status"] == "done" + assert completed["provider_status"] == "applied" + assert completed["completed"] is True + assert completed["projection_delivery"] == "delivered", completed + assert completed["validation_receipt"]["passed"] is True + assert completed["validation_receipt"]["command_label"] == ( + "provider terminal integration" + ) + assert runtime_calls == [ + "coordination.local_authority.todo_list", + "coordination.local_authority.todo_terminal", + "coordination.local_authority.todo_terminal", + "coordination.local_authority.todo_list", + ] + successor_id = completed["generated_successor_todo_ids"][0] + + runtime_calls.clear() + superseded = supersede_goal_todo( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id="goal-a", + todo_id="todo_supersede_native", + role="agent", + agent_id="agent-b", + reason="replace with a smaller continuation", + next_agent_todo="Replacement after native supersede", + next_claimed_by="agent-b", + ) + assert superseded["status"] == "done" + assert superseded["provider_status"] == "applied" + assert superseded["superseded"] is True + assert superseded["projection_delivery"] == "delivered" + assert runtime_calls == [ + "coordination.local_authority.todo_list", + "coordination.local_authority.todo_terminal", + "coordination.local_authority.todo_list", + ] + + canonical = read_canonical_todos_if_promoted( + runtime_root=runtime_root, + goal_id="goal-a", + ) + assert canonical is not None + by_id = {todo["todo_id"]: todo for todo in canonical["todos"]} + assert by_id["todo_complete_native"]["status"] == "done" + assert by_id["todo_complete_native"]["successor_todo_ids"] == [successor_id] + assert by_id[successor_id]["claimed_by"] == "agent-b" + assert by_id["todo_supersede_native"]["status"] == "done" + assert by_id["todo_supersede_native"]["superseded_by"] in by_id + + runtime_calls.clear() + archived = archive_completed_todos( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id="goal-a", + role="agent", + max_active_done=0, + dry_run=False, + ) + assert archived["status"] == "applied" + assert archived["moved_count"] == 2 + assert archived["projection_delivery"] == "delivered" + assert runtime_calls == [ + "coordination.local_authority.todo_list", + "coordination.local_authority.todo_archive", + "coordination.local_authority.todo_list", + ] + canonical_after_archive = read_canonical_todos_if_promoted( + runtime_root=runtime_root, + goal_id="goal-a", + ) + assert canonical_after_archive is not None + archived_ids = { + todo["todo_id"] + for todo in canonical_after_archive["todos"] + if todo["archive_state"] == "archive" + } + assert archived_ids == {"todo_complete_native", "todo_supersede_native"} + archive_revision = canonical_after_archive["provider_revision"] + for _ in range(2): + runtime_calls.clear() + no_change = archive_completed_todos( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id="goal-a", + role="agent", + max_active_done=0, + dry_run=False, + ) + assert no_change["status"] == "no_change" + assert no_change["changed"] is False + assert no_change["moved_count"] == 0 + assert no_change["provider_revision"] == archive_revision + assert runtime_calls == [ + "coordination.local_authority.todo_list", + "coordination.local_authority.todo_archive", + "coordination.local_authority.todo_list", + ] + unchanged = read_canonical_todos_if_promoted( + runtime_root=runtime_root, + goal_id="goal-a", + ) + assert unchanged is not None + assert unchanged["provider_revision"] == archive_revision + assert archive_operation_ids[-2] == archive_operation_ids[-1] + assert archive_operation_ids[0] != archive_operation_ids[-1] + rendered = state_file.read_text(encoding="utf-8") + assert "Human narrative remains outside canonical Todo authority." in rendered + assert "Continue provider-first delivery." in rendered + + +def test_public_terminal_optional_prose_matches_before_and_after_promotion( + tmp_path: Path, +) -> None: + cases = [ + ("note", None, None), + ("note", "", None), + ("note", " \u0085 ", None), + ("note", "ordinary note", "ordinary note"), + ("note", " first\u0085 second ", "first second"), + ("evidence", None, None), + ("evidence", "", None), + ("evidence", " \u0085 ", None), + ("evidence", "ordinary evidence", "ordinary evidence"), + ("evidence", " first\u0085 second ", "first second"), + ("reason", None, None), + ("reason", "", None), + ("reason", " \u0085 ", None), + ("reason", "ordinary reason", "ordinary reason"), + ("reason", " first\u0085 second ", "first second"), + ] + + def exercise(root: Path, *, promoted: bool) -> dict[str, object]: + runtime_root = root / "runtime" + project = root / "project" + state_file = project / "ACTIVE_GOAL_STATE.md" + project.mkdir(parents=True) + records: list[dict[str, object]] = [] + todo_lines: list[str] = [] + for index, (field, _value, _expected) in enumerate(cases, start=1): + todo_id = f"todo_prose_{index:02d}" + text = f"Exercise optional {field} case {index}" + metadata = format_todo_metadata_line( + todo_id=todo_id, + status="open", + task_class="advancement_task", + claimed_by="agent-a", + ) + todo_lines.extend([f"- [ ] {text}", str(metadata)]) + records.append( + { + "schema_version": "todo_item_v0", + "index": index, + "done": False, + "text": text, + "todo_id": todo_id, + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": TODO_SECTION_HEADINGS["agent"], + "task_class": "advancement_task", + "claimed_by": "agent-a", + } + ) + state_file.write_text( + "# Goal\n\n## User Todo / Owner Review Reading Queue\n\n" + "## Agent Todo\n\n" + "\n".join(todo_lines) + + "\n\n## Completed Work Archive\n", + encoding="utf-8", + ) + registry_path = root / "registry.json" + registry_path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "status": "active", + "repo": str(project), + "state_file": state_file.name, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a"], + }, + } + ], + } + ), + encoding="utf-8", + ) + if promoted: + initialize_canonical_authority( + runtime_root, + "goal-a", + build_todo_runtime_shadow_projection( + goal_id="goal-a", + todos=records, + handoff_mode="soft_claim", + ), + state_path=state_file, + ) + + for index, (field, value, _expected) in enumerate(cases, start=1): + common = { + "registry_path": registry_path, + "runtime_root_arg": str(runtime_root), + "goal_id": "goal-a", + "todo_id": f"todo_prose_{index:02d}", + "role": "agent", + "agent_id": "agent-a", + } + if field == "reason": + result = supersede_goal_todo(**common, reason=value) + assert result["superseded"] is True + else: + result = complete_goal_todo( + **common, + claimed_by="agent-a", + no_followup=True, + **{field: value}, + ) + assert result["completed"] is True + + if promoted: + projection = read_canonical_todos_if_promoted( + runtime_root=runtime_root, + goal_id="goal-a", + ) + assert projection is not None + todos = projection["todos"] + else: + lines = state_file.read_text(encoding="utf-8").splitlines() + bounds = section_bounds(lines, "agent") + assert bounds is not None + todos = todo_blocks( + lines, + bounds[0], + bounds[1], + role="agent", + source_section=bounds[2], + ) + by_id = {todo["todo_id"]: todo for todo in todos} + return { + f"{field}:{index}": by_id[f"todo_prose_{index:02d}"].get(field) + for index, (field, _value, _expected) in enumerate(cases, start=1) + } + + legacy = exercise(tmp_path / "legacy", promoted=False) + canonical = exercise(tmp_path / "canonical", promoted=True) + expected = { + f"{field}:{index}": value + for index, (field, _input, value) in enumerate(cases, start=1) + } + assert legacy == expected + assert canonical == expected + + +def test_illegal_terminal_actor_is_a_domain_valueerror_before_and_after_promotion( + tmp_path: Path, +) -> None: + def rejected(root: Path, *, promoted: bool) -> ValueError: + runtime_root = root / "runtime" + project = root / "project" + state_file = project / "ACTIVE_GOAL_STATE.md" + project.mkdir(parents=True) + metadata = format_todo_metadata_line( + todo_id="todo_terminal_actor", + status="open", + task_class="advancement_task", + claimed_by="agent-a", + ) + state_file.write_text( + "# Goal\n\n## User Todo / Owner Review Reading Queue\n\n" + "## Agent Todo\n\n- [ ] Reject an illegal terminal actor\n" + f"{metadata}\n\n## Completed Work Archive\n", + encoding="utf-8", + ) + registry_path = root / "registry.json" + registry_path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "repo": str(project), + "state_file": state_file.name, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a"], + }, + } + ], + } + ), + encoding="utf-8", + ) + if promoted: + initialize_canonical_authority( + runtime_root, + "goal-a", + build_todo_runtime_shadow_projection( + goal_id="goal-a", + handoff_mode="soft_claim", + todos=[ + { + "schema_version": "todo_item_v0", + "index": 1, + "done": False, + "text": "Reject an illegal terminal actor", + "todo_id": "todo_terminal_actor", + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": TODO_SECTION_HEADINGS["agent"], + "task_class": "advancement_task", + "claimed_by": "agent-a", + } + ], + ), + state_path=state_file, + ) + with pytest.raises(ValueError) as exc_info: + complete_goal_todo( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id="goal-a", + todo_id="todo_terminal_actor", + role="agent", + claimed_by="agent-a", + agent_id="agent-b", + no_followup=True, + ) + return exc_info.value + + legacy = rejected(tmp_path / "legacy", promoted=False) + canonical = rejected(tmp_path / "canonical", promoted=True) + assert not isinstance(legacy, LocalCoordinationAuthorityUnavailable) + assert isinstance(canonical, LocalCoordinationAuthorityRejection) + assert canonical.code == "actor_not_registered" + + +@pytest.mark.parametrize( + "reason_code", + [ + "version_mismatch", + "lease_cas_mismatch", + "actor_not_registered", + "handoff_mode_requires_lease", + ], +) +def test_promoted_terminal_rejection_code_survives_public_python_facade( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + reason_code: str, +) -> None: + runtime_root = tmp_path / "runtime" + project = tmp_path / "project" + state_file = project / "ACTIVE_GOAL_STATE.md" + project.mkdir() + state_file.write_text("# Goal\n\n## Agent Todo\n", encoding="utf-8") + registry_path = tmp_path / "registry.json" + registry_path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "repo": str(project), + "state_file": state_file.name, + "coordination": {"registered_agents": ["agent-a"]}, + } + ], + } + ), + encoding="utf-8", + ) + projection = build_todo_runtime_shadow_projection( + goal_id="goal-a", + todos=[ + { + "schema_version": "todo_item_v0", + "index": 1, + "done": False, + "text": "Complete through the promoted provider", + "todo_id": "todo_terminal", + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": TODO_SECTION_HEADINGS["agent"], + "task_class": "advancement_task", + "claimed_by": "agent-a", + } + ], + handoff_mode="soft_claim", + ) + initialize_canonical_authority( + runtime_root, "goal-a", projection, state_path=state_file + ) + + monkeypatch.setattr( + "loopx.control_plane.todos.provider_terminal_lifecycle.effect_runtime_result", + lambda *_args, **_kwargs: { + "schema_version": "loopx_coordination_todo_terminal_lifecycle_result_v0", + "status": "failed", + "changed": False, + "failure_kind": "decision_rejection", + "reason_code": reason_code, + "reason": f"terminal request rejected: {reason_code}", + "source_authority": "file_v0", + "decision_read_from_provider": True, + "legacy_fallback_used": False, + }, + ) + + with pytest.raises(LocalCoordinationAuthorityRejection) as exc_info: + complete_goal_todo( + registry_path=registry_path, + goal_id="goal-a", + todo_id="todo_terminal", + claimed_by="agent-a", + agent_id="agent-a", + no_followup=True, + ) + assert exc_info.value.code == reason_code + assert exc_info.value.payload["reason_code"] == reason_code + + +def test_promoted_terminal_retry_reuses_receipt_after_projection_crash( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runtime_root = tmp_path / "runtime" + project = tmp_path / "project" + state_file = project / "ACTIVE_GOAL_STATE.md" + project.mkdir() + state_file.write_text("# Goal\n\n## Agent Todo\n", encoding="utf-8") + registry_path = tmp_path / "registry.json" + registry_path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": "goal-a", + "repo": str(project), + "state_file": state_file.name, + "coordination": {"registered_agents": ["agent-a"]}, + } + ], + } + ), + encoding="utf-8", + ) + projection = build_todo_runtime_shadow_projection( + goal_id="goal-a", + todos=[ + { + "schema_version": "todo_item_v0", + "index": 1, + "done": False, + "text": "Complete once despite a projection crash", + "todo_id": "todo_terminal", + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": TODO_SECTION_HEADINGS["agent"], + "task_class": "advancement_task", + "claimed_by": "agent-a", + } + ], + handoff_mode="soft_claim", + ) + initialize_canonical_authority( + runtime_root, "goal-a", projection, state_path=state_file + ) + runtime_calls: list[str] = [] + original_effect_runtime_result = provider_terminal_lifecycle.effect_runtime_result + original_authority_runtime_result = local_authority_module.effect_runtime_result + + def count_runtime_call(method: str, params: dict[str, object]) -> object: + runtime_calls.append(method) + return original_effect_runtime_result(method, params) + + def count_authority_runtime_call( + method: str, params: dict[str, object] + ) -> object: + runtime_calls.append(method) + return original_authority_runtime_result(method, params) + + monkeypatch.setattr( + provider_terminal_lifecycle, + "effect_runtime_result", + count_runtime_call, + ) + monkeypatch.setattr( + local_authority_module, + "effect_runtime_result", + count_authority_runtime_call, + ) + original_settle = provider_projection.settle_canonical_todo_projection + + def _crash_projection(*_args: object, **_kwargs: object) -> dict[str, object]: + raise OSError("injected projection delivery crash") + + # The adapter imports this symbol directly, so fail only the compatibility + # delivery after the canonical transaction has committed. + monkeypatch.setattr( + "loopx.control_plane.todos.provider_terminal_lifecycle.settle_canonical_todo_projection", + _crash_projection, + ) + request = { + "registry_path": registry_path, + "goal_id": "goal-a", + "todo_id": "todo_terminal", + "claimed_by": "agent-a", + "agent_id": "agent-a", + "next_agent_todo": "Continue after the recovered projection.", + "next_claimed_by": "agent-a", + "next_task_class": "advancement_task", + } + with pytest.raises(OSError, match="projection delivery crash"): + complete_goal_todo(**request) + assert runtime_calls == [ + "coordination.local_authority.todo_list", + "coordination.local_authority.todo_terminal", + ] + + monkeypatch.setattr( + "loopx.control_plane.todos.provider_terminal_lifecycle.settle_canonical_todo_projection", + original_settle, + ) + replay = complete_goal_todo(**request) + assert replay["status"] == "done" + assert replay["provider_status"] == "replayed" + assert replay["idempotent_replay"] is True + assert runtime_calls == [ + "coordination.local_authority.todo_list", + "coordination.local_authority.todo_terminal", + "coordination.local_authority.todo_list", + "coordination.local_authority.todo_terminal", + "coordination.local_authority.todo_list", + ] + canonical = read_canonical_todos_if_promoted( + runtime_root=runtime_root, goal_id="goal-a" + ) + assert canonical is not None + successors = [ + todo + for todo in canonical["todos"] + if todo["todo_id"] != "todo_terminal" + ] + assert len(successors) == 1 + assert successors[0]["text"] == "Continue after the recovered projection." + + prose_replay = complete_goal_todo( + **request, + note="Retry with a clearer explanation.", + evidence="Public retry evidence may be enriched.", + ) + assert prose_replay["provider_status"] == "replayed" + assert prose_replay["idempotent_replay"] is True + + with pytest.raises(LocalCoordinationAuthorityRejection) as exc_info: + complete_goal_todo( + **{ + **request, + "next_agent_todo": "Start a genuinely different continuation.", + } + ) + assert exc_info.value.code == "coordination_operation_identity_mismatch" + + def test_real_canonical_provider_preserves_complete_complex_todo_semantics( tmp_path: Path, ) -> None: @@ -1032,7 +2122,9 @@ def test_real_canonical_provider_preserves_complete_complex_todo_semantics( changed_intent = [ "agent-b" if part == "agent-a" else part for part in claim_command ] - rejected = subprocess.run(changed_intent, capture_output=True, text=True) + rejected = subprocess.run( + changed_intent, capture_output=True, text=True, check=False + ) assert rejected.returncode != 0 assert ( json.loads(rejected.stdout)["error"] @@ -1040,7 +2132,10 @@ def test_real_canonical_provider_preserves_complete_complex_todo_semantics( ) for invalid_key in ("", " padded-operation "): invalid = subprocess.run( - [*claim_command[:-1], invalid_key], capture_output=True, text=True + [*claim_command[:-1], invalid_key], + capture_output=True, + text=True, + check=False, ) assert invalid.returncode != 0 assert not state_file.exists() diff --git a/tests/control_plane/test_runtime_shadow_bounded_e2e.py b/tests/control_plane/test_runtime_shadow_bounded_e2e.py index c8da588364..9f1d2d1f6e 100644 --- a/tests/control_plane/test_runtime_shadow_bounded_e2e.py +++ b/tests/control_plane/test_runtime_shadow_bounded_e2e.py @@ -7,15 +7,21 @@ import json import os +import subprocess +import sys from pathlib import Path import pytest -import subprocess -import sys -from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot -from loopx.control_plane.coordination.coordination_state_contract_generated import TASK_LEASE_ACQUIRE_REQUEST_SCHEMA -from loopx.control_plane.work_items.task_lease_acquire_adapter import task_lease_acquire_authority_facts +from loopx.control_plane.coordination.coordination_state_contract_generated import ( + TASK_LEASE_ACQUIRE_REQUEST_SCHEMA, +) +from loopx.control_plane.coordination.runtime_shadow import ( + build_runtime_shadow_source_snapshot, +) +from loopx.control_plane.work_items.task_lease_acquire_adapter import ( + task_lease_acquire_authority_facts, +) REPO = Path(__file__).resolve().parents[2] @@ -39,7 +45,8 @@ def workspace(tmp_path: Path) -> tuple[Path, Path, Path]: def cli(registry: Path, runtime: Path, *arguments: str, success: bool = True) -> dict: completed = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", *arguments], cwd=REPO, - env={**os.environ, "PYTHONPATH": str(REPO)}, capture_output=True, text=True, timeout=45) + env={**os.environ, "PYTHONPATH": str(REPO)}, capture_output=True, text=True, + timeout=45, check=False) assert completed.stdout.strip(), completed.stderr payload = json.loads(completed.stdout) if success: @@ -62,7 +69,7 @@ def native(tmp_path: Path, module: str, function: str, request: dict) -> dict: "import {readFile} from 'node:fs/promises';" f"process.stdout.write(JSON.stringify(await {function}(JSON.parse(await readFile(process.argv[1],'utf8')))));" ) process = subprocess.run(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script, str(path)], - cwd=tmp_path, capture_output=True, text=True, timeout=45) + cwd=tmp_path, capture_output=True, text=True, timeout=45, check=False) assert process.returncode == 0, process.stderr return json.loads(process.stdout) @@ -86,7 +93,9 @@ def acquire_native(tmp_path: Path, registry: Path, runtime: Path, todo_id: str) }) -def test_source_snapshot_preserves_ordinal_mixed_case_lease_inventory(tmp_path: Path) -> None: +def test_source_snapshot_preserves_inventory_without_projecting_orphan_leases( + tmp_path: Path, +) -> None: registry, runtime, state = workspace(tmp_path) directory = runtime / "goals" / "goal-a" / "task-leases" directory.mkdir(parents=True) @@ -103,12 +112,12 @@ def test_source_snapshot_preserves_ordinal_mixed_case_lease_inventory(tmp_path: goal=goal, runtime_root=runtime, state_path=state, registry_path=registry) expected = ["todo_Bravo", "todo_Zulu", "todo_alpha"] assert [entry["name"] for entry in snapshot["lease_inventory"]] == [f"{name}.json" for name in expected] - assert [lease["todo_id"] for lease in projection["leases"]] == expected + assert projection["leases"] == [] boot = cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") assert boot["bootstrap"]["status"] == "applied", boot inspected = cli(registry, runtime, "coordination-shadow", "inspect", "--goal-id", "goal-a") assert inspected["inspection"]["status"] == "matched", inspected - assert history(tmp_path, runtime)[0]["projection"]["leases"] == projection["leases"] + assert history(tmp_path, runtime)[0]["projection"]["leases"] == [] def test_public_cli_and_independent_native_writer_qualify_one_complete_lineage(tmp_path: Path) -> None: @@ -280,7 +289,10 @@ def test_native_lease_writer_cannot_reuse_a_sequence_when_its_cursor_is_missing( def test_public_committed_primary_cannot_be_relabelled_abandoned_by_native_request(tmp_path: Path) -> None: from shadow_e2e_fixture import workspace as crash_workspace - from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter + + from loopx.control_plane.coordination import ( + local_authority_shadow_adapter as adapter, + ) from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox w = crash_workspace(tmp_path) diff --git a/tests/control_plane/test_shadow_fence_caller_parity_e2e.py b/tests/control_plane/test_shadow_fence_caller_parity_e2e.py index b16496fd1c..75a335e3cc 100644 --- a/tests/control_plane/test_shadow_fence_caller_parity_e2e.py +++ b/tests/control_plane/test_shadow_fence_caller_parity_e2e.py @@ -8,14 +8,13 @@ """ from __future__ import annotations -from collections.abc import Callable, Iterator import json -from pathlib import Path import shutil import sys +from collections.abc import Callable, Iterator +from pathlib import Path import pytest - from test_shadow_observable_e2e import Caller from test_shadow_observable_native_e2e import native @@ -24,7 +23,8 @@ FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "control_plane" / "legacy_writer_fence_caller_parity_v0.json" BUILDER = ( "from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection as build; " - "import json,sys; value=build(goal_id='observable', todos=json.loads(sys.argv[1])); " + "import json,sys; value=build(goal_id='observable', todos=json.loads(sys.argv[1]), " + "leases=json.loads(sys.argv[2])); " "value['handoff_mode']='hard_lease'; print(json.dumps(value))" ) PLACEHOLDERS = ("runtime_root", "todo_a", "todo_b", "todo_gate") @@ -49,7 +49,9 @@ def outbox(self) -> set[str]: return {k for k in self.w.files() if k.startswith("runtime/authority-shadow/outbox/")} def fence_path(self) -> Path: - from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path + from loopx.control_plane.coordination.legacy_writer_fence import ( + legacy_coordination_writer_fence_path, + ) return legacy_coordination_writer_fence_path(runtime_root=self.w.root, goal_id="observable") @@ -79,8 +81,14 @@ def observe(self, args: tuple[str, ...]) -> dict: def seed_and_fence(ws: Workspace, todo_keys: tuple[str, ...]) -> None: records = [ws.w.read(ws.ids[key]) for key in todo_keys] + lease_path = ( + ws.w.root / "goals" / "observable" / "task-leases" / + f"{ws.ids['todo_b']}.json" + ) + leases = [json.loads(lease_path.read_text(encoding="utf-8"))] projection = ws.w.invoke( - [sys.executable, "-c", BUILDER, json.dumps(records)], ["fixture-projection", json.dumps(records)] + [sys.executable, "-c", BUILDER, json.dumps(records), json.dumps(leases)], + ["fixture-projection", json.dumps(records), ""], ) assert native(ws.w, "seed", projection)["status"] == "applied" diff --git a/tests/control_plane/test_split_root_todo_writeback_fence.py b/tests/control_plane/test_split_root_todo_writeback_fence.py index 57f5215869..1ab87e791c 100644 --- a/tests/control_plane/test_split_root_todo_writeback_fence.py +++ b/tests/control_plane/test_split_root_todo_writeback_fence.py @@ -7,6 +7,8 @@ from __future__ import annotations import json +import subprocess +import sys from pathlib import Path from typing import Any @@ -20,6 +22,9 @@ LegacyCoordinationWriterFenced, legacy_coordination_writer_fence_path, ) +from loopx.control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, +) from loopx.control_plane.quota import monitor_poll from loopx.control_plane.scheduler.monitor_poll_writeback import ( write_monitor_poll_todo_state, @@ -31,6 +36,7 @@ MONITOR_ID = "todo_splitroot_monitor" OVERRIDE_POLL_HASH = "split-v2" LEGACY_POLL_HASH = "split-v1" +REPOSITORY = Path(__file__).resolve().parents[2] def _write_split_root_goal(tmp_path: Path) -> tuple[Path, Path, Path, Path]: @@ -265,13 +271,14 @@ def test_turn_validated_completion_blocked_when_override_root_is_fenced( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - registry, _state, _runtime_registry, runtime_override = ( + registry, state, runtime_registry, runtime_override = ( _write_split_root_goal(tmp_path) ) _engage_fence_at(runtime_override) _fence_check_blocks(monkeypatch) + before = state.read_bytes() - with pytest.raises(LegacyCoordinationWriterFenced): + with pytest.raises(LocalCoordinationAuthorityUnavailable) as error: write_turn_validated_completion( registry_path=registry, runtime_root_arg=str(runtime_override), @@ -282,3 +289,68 @@ def test_turn_validated_completion_blocked_when_override_root_is_fenced( note="advance to the next bounded slice", agent_id=AGENT_ID, ) + + assert error.value.code == "local_authority_todo_list_unavailable" + assert error.value.payload == { + "schema_version": "loopx_local_coordination_todo_list_result_v0", + "status": "missing", + "source_authority": "file_v0", + "decision_read_from_provider": True, + "legacy_fallback_used": False, + "recovery": { + "action": "restore_canonical_authority", + "runtime_root": str(runtime_override.resolve()), + "goal_id": GOAL_ID, + "legacy_markdown_fallback_allowed": False, + "retry_after": "canonical_provider_readback_loaded", + }, + } + assert state.read_bytes() == before + assert not (runtime_override / "authority").exists() + assert not (runtime_registry / "authority").exists() + + process = subprocess.run( + [ + sys.executable, + "-m", + "loopx.entrypoint", + "--registry", + str(registry), + "--runtime-root", + str(runtime_override), + "--format", + "json", + "todo", + "complete", + "--goal-id", + GOAL_ID, + "--todo-id", + ADVANCE_ID, + "--role", + "agent", + "--agent-id", + AGENT_ID, + "--evidence", + "split-root provider recovery contract", + "--next-agent-todo", + "Resume only after canonical authority is restored.", + "--next-task-class", + "advancement_task", + ], + cwd=REPOSITORY, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert process.returncode == 1, process.stdout + process.stderr + payload = json.loads(process.stdout) + assert payload["error_code"] == "local_authority_todo_list_unavailable" + assert payload["status"] == "missing" + assert payload["source_authority"] == "file_v0" + assert payload["decision_read_from_provider"] is True + assert payload["legacy_fallback_used"] is False + assert payload["recovery"] == error.value.payload["recovery"] + assert state.read_bytes() == before + assert not (runtime_override / "authority").exists() + assert not (runtime_registry / "authority").exists() diff --git a/tests/control_plane/test_todo_completion_validation.py b/tests/control_plane/test_todo_completion_validation.py index 3b000222d4..f156e32173 100644 --- a/tests/control_plane/test_todo_completion_validation.py +++ b/tests/control_plane/test_todo_completion_validation.py @@ -12,8 +12,17 @@ import loopx.control_plane.todos.completion_validation as completion_validation_module import loopx.control_plane.todos.completion_transaction as completion_transaction_module from loopx.control_plane.todos.completion_validation_projection import ( + completion_validation_declaration_sha256, project_completion_validation_authority, ) +from loopx.control_plane.todos.completion_validation import ( + resolve_private_completion_validation_declaration, +) +from loopx.control_plane.todos.completion_validation_store import ( + completion_validation_declaration_path, + persist_completion_validation_declaration, + read_completion_validation_declaration, +) from loopx.event_sourced_state import ( TODO_ADDED, TODO_COMPLETED, @@ -842,12 +851,110 @@ def test_event_projection_preserves_private_validation_and_public_marker() -> No assert "validation_command_argv=" in rendered public = project_completion_validation_authority(item) assert public["completion_validation_required"] is True + assert len(public["completion_validation_sha256"]) == 64 assert "validation_command" not in public assert "validation_command_argv" not in public assert "validation_label" not in public assert "validation_timeout_seconds" not in public +def test_private_validation_store_is_owner_only_and_detects_tampering( + tmp_path: Path, +) -> None: + declaration = { + "validation_command": None, + "validation_command_argv": [sys.executable, "-c", "pass"], + "validation_label": "private validation", + "validation_timeout_seconds": 5, + } + digest = persist_completion_validation_declaration( + runtime_root=tmp_path, + goal_id=GOAL_ID, + todo_id="todo_private_validation", + declaration=declaration, + ) + path = completion_validation_declaration_path( + runtime_root=tmp_path, + goal_id=GOAL_ID, + todo_id="todo_private_validation", + ) + + assert path.stat().st_mode & 0o777 == 0o600 + assert read_completion_validation_declaration( + runtime_root=tmp_path, + goal_id=GOAL_ID, + todo_id="todo_private_validation", + ) == declaration + assert digest == completion_validation_declaration_sha256(declaration) + + stored = json.loads(path.read_text(encoding="utf-8")) + stored["declaration"]["validation_label"] = "tampered validation" + path.write_text(json.dumps(stored), encoding="utf-8") + with pytest.raises(ValueError, match="digest mismatch"): + read_completion_validation_declaration( + runtime_root=tmp_path, + goal_id=GOAL_ID, + todo_id="todo_private_validation", + ) + + +def test_canonical_validation_marker_fails_closed_without_private_declaration( + tmp_path: Path, +) -> None: + canonical = project_completion_validation_authority( + {"validation_command_argv": [sys.executable, "-c", "pass"]} + ) + registry = tmp_path / "registry.json" + registry.write_text( + json.dumps({"schema_version": 1, "goals": [{"id": GOAL_ID}]}), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="declaration is unavailable"): + resolve_private_completion_validation_declaration( + canonical_todo=canonical, + state_file=tmp_path / "missing.md", + runtime_root=tmp_path / "runtime", + registry_path=registry, + goal_id=GOAL_ID, + todo_id="todo_missing_private_validation", + role="agent", + persist_if_resolved=True, + ) + + +def test_canonical_validation_digest_rejects_different_private_declaration( + tmp_path: Path, +) -> None: + todo_id = "todo_mismatched_private_validation" + canonical = project_completion_validation_authority( + {"validation_command_argv": [sys.executable, "-c", "pass"]} + ) + persist_completion_validation_declaration( + runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, + todo_id=todo_id, + declaration={ + "validation_command": None, + "validation_command_argv": [sys.executable, "-c", "raise SystemExit(1)"], + "validation_label": None, + "validation_timeout_seconds": None, + }, + ) + + with pytest.raises(ValueError, match="does not match canonical Todo digest"): + resolve_private_completion_validation_declaration( + canonical_todo=canonical, + state_file=tmp_path / "missing.md", + runtime_root=tmp_path / "runtime", + registry_path=tmp_path / "missing-registry.json", + goal_id=GOAL_ID, + todo_id=todo_id, + role="agent", + persist_if_resolved=False, + ) + + def test_event_projected_failing_validation_blocks_completion( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/control_plane/test_todo_machine_section_projection.py b/tests/control_plane/test_todo_machine_section_projection.py index 1d515ab8f7..63c18417b6 100644 --- a/tests/control_plane/test_todo_machine_section_projection.py +++ b/tests/control_plane/test_todo_machine_section_projection.py @@ -121,14 +121,27 @@ def test_projection_replaces_only_machine_sections_and_is_idempotent(newline: st assert replay.rendered_sha256 == projected.rendered_sha256 -def test_projection_rejects_missing_role_section() -> None: +def test_projection_creates_missing_machine_owned_role_section() -> None: source = "# Goal\n\nHuman introduction.\n\n## Agent Todo\n\n- [ ] old\n\n## Next Action\n\n- Continue.\n" - with pytest.raises(TodoSectionProjectionError, match="required Todo sections: user"): - render_canonical_todo_sections( - source, - [_records()[0]], - provider_revision="rev-9", - ) + projected = render_canonical_todo_sections( + source, + [_records()[0]], + provider_revision="rev-9", + ) + assert projected.changed is True + assert "## User Todo / Owner Review Reading Queue" in projected.markdown + assert "Human introduction." in projected.markdown + assert "## Next Action\n\n- Continue." in projected.markdown + assert {item["role"] for item in inspect_todo_section_projection( + projected.markdown + )["sections"]} == {"user", "agent"} + replay = render_canonical_todo_sections( + projected.markdown, + [_records()[0]], + provider_revision="rev-9", + ) + assert replay.changed is False + assert replay.markdown == projected.markdown def test_projection_assigns_display_only_provenance_to_native_records() -> None: @@ -212,7 +225,7 @@ def test_projection_renders_native_archive_with_role_and_replays() -> None: assert replay.changed is False -def test_projection_requires_archive_region_for_archived_records() -> None: +def test_projection_creates_archive_region_for_archived_records() -> None: archived = { "schema_version": "todo_domain_record_v0", "todo_id": "todo_archived", @@ -222,12 +235,22 @@ def test_projection_requires_archive_region_for_archived_records() -> None: "text": "Completed provider-owned work.", "archive_state": "archive", } - with pytest.raises(TodoSectionProjectionError, match="Completed Work Archive"): - render_canonical_todo_sections( - SOURCE, - [archived], - provider_revision="rev-archive", - ) + projected = render_canonical_todo_sections( + SOURCE, + [archived], + provider_revision="rev-archive", + ) + assert "## Completed Work Archive" in projected.markdown + assert "Completed provider-owned work." in projected.markdown + assert {item["role"] for item in inspect_todo_section_projection( + projected.markdown + )["sections"]} == {"user", "agent", "archive"} + replay = render_canonical_todo_sections( + projected.markdown, + [archived], + provider_revision="rev-archive", + ) + assert replay.changed is False def test_projection_rejects_duplicate_sections_and_unsafe_revision() -> None: diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index b0b5af5859..22e7156896 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -6,19 +6,31 @@ import type { AuthorityStore, AuthorityStoreCommit, } from "../../loopx/control_plane/coordination/authority_store.ts"; -import { canonicalAuthorityBytes } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import { + canonicalAuthorityBytes, + canonicalAuthoritySha256, +} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import { TODO_CANONICAL_READ_RECORD_FIELDS, TODO_CANONICAL_READ_RECORD_SCHEMA, TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT, + TODO_ITEM_SCHEMA, } from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import { prepareCoordinationProjectionCommit } from "../../loopx/control_plane/coordination/coordination_projection.ts"; import { executeCoordinationTodoClaim } from "../../loopx/control_plane/coordination/todo_claim.ts"; import { executeCoordinationTodoCreate } from "../../loopx/control_plane/coordination/todo_create.ts"; import { executeCoordinationTodoUpdate } from "../../loopx/control_plane/coordination/todo_update.ts"; +import { + executeCoordinationTodoArchiveCompleted, + executeCoordinationTodoTerminalLifecycle, +} from "../../loopx/control_plane/coordination/todo_terminal_lifecycle.ts"; import { editCoordinationTodo, TODO_COMPATIBILITY_EDIT_SCHEMA } from "../../loopx/control_plane/coordination/todo_compatibility_edit.ts"; +import { + PRODUCTION_SCALE_VALIDATION_DECLARATION, + productionScaleCoordinationFixture, +} from "./production_scale_coordination_fixture.ts"; export interface AuthorityStoreConformanceFixture { store: AuthorityStore; @@ -28,6 +40,13 @@ export type AuthorityStoreConformanceFactory = ( context: test.TestContext, ) => Promise; +const TERMINAL_VALIDATION_DECLARATION = { + validation_command: null, + validation_command_argv: ["python3", "-c", "raise SystemExit(0)"], + validation_label: "provider conformance validation", + validation_timeout_seconds: 5, +}; + function todoClaimProjection(goalId: string, native: boolean): Record { const todos = [{ schema_version: "todo_item_v0", @@ -62,6 +81,102 @@ function todoClaimProjection(goalId: string, native: boolean): Record { + const todos = [ + { + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: "todo-terminal", + role: "agent", + status: "open", + done: false, + text: "Finish the provider-neutral transaction family", + archive_state: "active", + task_class: "advancement_task", + claimed_by: "agent-a", + note: "preserve target metadata", + completion_validation_required: true, + completion_validation_sha256: canonicalAuthoritySha256( + TERMINAL_VALIDATION_DECLARATION, + ), + }, + { + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: "todo-done-a", + role: "agent", + status: "done", + done: true, + text: "Older completed work", + archive_state: "active", + completed_at: "2026-09-01T00:00:00Z", + }, + { + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: "todo-done-b", + role: "agent", + status: "done", + done: true, + text: "Newer completed work", + archive_state: "active", + completed_at: "2026-09-02T00:00:00Z", + }, + { + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: "todo-user-decision", + role: "user", + status: "done", + done: true, + text: "Retain the standing provider decision", + archive_state: "active", + task_class: "user_gate", + decision_scope: {kind: "direction", granularity: "goal", scope_key: goalId}, + decision_outcome: "approve", + global_gate: true, + goal_bound: true, + completed_at: "2026-08-30T00:00:00Z", + }, + { + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: "todo-user-ordinary", + role: "user", + status: "done", + done: true, + text: "Archive the ordinary completed user action", + archive_state: "active", + task_class: "user_action", + goal_bound: true, + completed_at: "2026-08-31T00:00:00Z", + }, + ].sort((left, right) => left.todo_id.localeCompare(right.todo_id)); + return { + goal_id: goalId, + handoff_mode: "hard_lease", + todos, + leases: [{ + schema_version: "task_lease_v0", + goal_id: goalId, + todo_id: "todo-terminal", + owner: "agent-a", + idempotency_key: "terminal-lease", + write_scopes: ["loopx/control_plane/**"], + acquire_ttl_seconds: 600, + version: 1, + lease_epoch: 1, + acquired_at: "2026-09-07T05:50:00Z", + updated_at: "2026-09-07T05:50:00Z", + expires_at: "2026-09-07T06:10:00Z", + status: "active", + }], + todo_read_model: { + schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, + todo_count: todos.length, + records_sha256: createHash("sha256") + .update(canonicalAuthorityBytes(todos)) + .digest("hex"), + contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields], + }, + }; +} + export function authorityStoreCommitFixture( expectedProviderRevision: string | null, operationId: string, @@ -224,6 +339,450 @@ export function registerAuthorityStoreConformance( assert.deepEqual(await store.loadAuthority(), { status: "missing" }); }); + test(`${providerName} conformance: terminal lifecycle and archive share atomic authority`, async (t) => { + const {store, contender} = await factory(t); + const goalId = "goal-terminal"; + const initialized = await store.commitAuthority({ + expected_provider_revision: null, + operation_id: "initialize-terminal", + events: [], + receipts: [], + next_projection: todoTerminalProjection(goalId), + }); + assert.equal(initialized.status, "applied"); + const successorIntent = { + role: "agent", + text: "Continue with the next provider-neutral transaction", + task_class: "advancement_task", + }; + const request = { + goal_id: goalId, + todo_id: "todo-terminal", + expected_role: "agent" as const, + command: "complete" as const, + actor_agent_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], + lifecycle_grants: [], + authority_reason: null, + decision_outcome: null, + operation_id: "complete-terminal", + lease_idempotency_key: "terminal-lease", + lease_expected_version: 1, + allow_user_gate_auto_acquire: false, + requested_no_followup: false, + requested_completion_turn_key: null, + requested_completion_identity_source: null, + linked_successor_todo_ids: [], + successor_intents: [successorIntent], + note: "completed atomically", + evidence: "focused provider conformance", + reason: null, + clear_claim: false, + validation_declaration: TERMINAL_VALIDATION_DECLARATION, + validation_receipt: null, + completion_policy_request: { + schema_version: "loopx_todo_completion_policy_request_v0", + goal_id: goalId, + agent_model: "peer_v1", + claimed_by: "agent-a", + registered_agents: ["agent-a", "agent-b"], + next_claimed_by: "agent-b", + next_agent_todo: successorIntent.text, + next_continuation_policy: null, + next_excluded_agents: [], + self_merged: false, + evidence: null, + linked_successors: [], + }, + dry_run: false, + now: new Date("2026-09-07T06:00:00Z"), + }; + const preview = await executeCoordinationTodoTerminalLifecycle( + store, {...request, dry_run: true}, + ); + assert.equal(preview.status, "planned", JSON.stringify(preview)); + const validation = await executeCoordinationTodoTerminalLifecycle(store, request); + assert.equal(validation.status, "execute_validation", JSON.stringify(validation)); + assert.equal( + (validation.validation_effect as Record)?.kind, + "caller_validation", + ); + const commitRequest = { + ...request, + validation_receipt: { + schema_version: "issue_fix_validation_command_v0", + command_label: "provider conformance validation", + exit_code: 0, + passed: true, + status: "passed", + summary: "provider conformance validation passed", + stdout_captured: false as const, + stderr_captured: false as const, + local_path_captured: false as const, + }, + }; + const dangling = await executeCoordinationTodoTerminalLifecycle(store, { + ...commitRequest, + operation_id: "complete-dangling-successor", + linked_successor_todo_ids: ["todo-missing"], + successor_intents: [], + }); + assert.equal(dangling.status, "failed"); + assert.equal(dangling.reason_code, "todo_successor_not_found"); + const [first, second] = await Promise.all([ + executeCoordinationTodoTerminalLifecycle(store, commitRequest), + executeCoordinationTodoTerminalLifecycle(contender, commitRequest), + ]); + assert.ok( + [first.status, second.status].includes("applied"), + JSON.stringify([first, second]), + ); + assert.ok( + [first.status, second.status].every((status) => + typeof status === "string" && + ["applied", "recovered", "replayed", "conflict"].includes(status)), + JSON.stringify([first, second]), + ); + const committedRevisions = [first, second] + .filter((item) => item.status !== "conflict") + .map((item) => item.provider_revision); + assert.equal(new Set(committedRevisions).size, 1, JSON.stringify([first, second])); + const replayed = await executeCoordinationTodoTerminalLifecycle(store, commitRequest); + assert.equal(replayed.status, "replayed", JSON.stringify(replayed)); + assert.equal(replayed.changed, false); + const proseReplay = await executeCoordinationTodoTerminalLifecycle(store, { + ...commitRequest, + note: "same operation, revised prose", + evidence: "revised evidence does not create a new operation", + }); + assert.equal(proseReplay.status, "replayed", JSON.stringify(proseReplay)); + const changedIntent = await executeCoordinationTodoTerminalLifecycle(store, { + ...commitRequest, + successor_intents: [{...successorIntent, text: "A genuinely different successor"}], + completion_policy_request: { + ...commitRequest.completion_policy_request, + next_agent_todo: "A genuinely different successor", + }, + }); + assert.equal(changedIntent.status, "failed", JSON.stringify(changedIntent)); + assert.equal(changedIntent.failure_kind, "decision_rejection"); + assert.equal(changedIntent.reason_code, "coordination_operation_identity_mismatch"); + + const afterCompletion = await store.loadAuthority(); + assert.equal(afterCompletion.status, "loaded"); + if (afterCompletion.status !== "loaded") return; + const completed = (afterCompletion.head.todos as Record[]) + .find((todo) => todo.todo_id === "todo-terminal"); + const committedResult = first.status === "conflict" ? second : first; + const generatedIds = committedResult.generated_successor_todo_ids as string[] | undefined; + const generatedId = generatedIds?.[0]; + const created = (afterCompletion.head.todos as Record[]) + .find((todo) => todo.todo_id === generatedId); + assert.equal(completed?.status, "done"); + assert.equal(completed?.note, "completed atomically"); + assert.equal(completed?.evidence, "focused provider conformance"); + assert.deepEqual(completed?.successor_todo_ids, [generatedId]); + assert.equal(created?.claimed_by, "agent-b"); + assert.equal(created?.created_by, "agent-a"); + const releasedLease = (afterCompletion.head.leases as Record[]) + .find((lease) => lease.todo_id === "todo-terminal"); + assert.equal(releasedLease?.status, "released"); + + const archiveRequest = { + goal_id: goalId, + role: "agent" as const, + max_active_done: 1, + operation_id: "archive-terminal", + dry_run: false, + now: new Date("2026-09-07T06:01:00Z"), + }; + const archived = await executeCoordinationTodoArchiveCompleted(store, archiveRequest); + assert.equal(archived.status, "applied", JSON.stringify(archived)); + assert.equal(archived.moved_count, 2); + assert.deepEqual(archived.moved_todo_ids, ["todo-done-a", "todo-done-b"]); + assert.equal((await executeCoordinationTodoArchiveCompleted(store, archiveRequest)).status, + "replayed"); + const afterArchive = await store.loadAuthority(); + assert.equal(afterArchive.status, "loaded"); + if (afterArchive.status !== "loaded") return; + const archivedIds = (afterArchive.head.todos as Record[]) + .filter((todo) => todo.archive_state === "archive") + .map((todo) => todo.todo_id); + assert.deepEqual(archivedIds, ["todo-done-a", "todo-done-b"]); + + const userArchive = await executeCoordinationTodoArchiveCompleted(store, { + ...archiveRequest, + role: "user", + max_active_done: 0, + operation_id: "archive-user-terminal", + }); + assert.equal(userArchive.status, "applied", JSON.stringify(userArchive)); + assert.deepEqual(userArchive.moved_todo_ids, ["todo-user-ordinary"]); + assert.equal(userArchive.retained_standing_decision_count, 1); + const afterUserArchive = await store.loadAuthority(); + assert.equal(afterUserArchive.status, "loaded"); + if (afterUserArchive.status !== "loaded") return; + const standing = (afterUserArchive.head.todos as Record[]) + .find((todo) => todo.todo_id === "todo-user-decision"); + assert.equal(standing?.archive_state, "active"); + + const beforeNoChange = await store.loadAuthority(); + assert.equal(beforeNoChange.status, "loaded"); + if (beforeNoChange.status !== "loaded") return; + const noChangeOperation = "archive-terminal-no-change"; + const noChange = await executeCoordinationTodoArchiveCompleted(store, { + ...archiveRequest, + operation_id: noChangeOperation, + }); + assert.equal(noChange.status, "no_change", JSON.stringify(noChange)); + assert.equal(noChange.changed, false); + assert.equal(noChange.moved_count, 0); + assert.equal(noChange.provider_revision, beforeNoChange.provider_revision); + assert.equal(noChange.cursor, beforeNoChange.cursor); + assert.equal((await store.readReceipt(noChangeOperation)).status, "missing"); + const afterNoChange = await store.loadAuthority(); + assert.equal(afterNoChange.status, "loaded"); + if (afterNoChange.status !== "loaded") return; + assert.equal(afterNoChange.provider_revision, beforeNoChange.provider_revision); + assert.equal(afterNoChange.cursor, beforeNoChange.cursor); + }); + + test(`${providerName} conformance: supersede preserves the legacy terminal continuation`, async (t) => { + const {store} = await factory(t); + const goalId = "goal-supersede"; + const initialized = await store.commitAuthority({ + expected_provider_revision: null, + operation_id: "initialize-supersede", + events: [], + receipts: [], + next_projection: todoClaimProjection(goalId, true), + }); + assert.equal(initialized.status, "applied"); + const superseded = await executeCoordinationTodoTerminalLifecycle(store, { + goal_id: goalId, + todo_id: "todo-claim", + expected_role: "agent", + command: "supersede", + actor_agent_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], + lifecycle_grants: [], + authority_reason: null, + decision_outcome: null, + lease_idempotency_key: null, + lease_expected_version: null, + operation_id: "supersede-terminal", + allow_user_gate_auto_acquire: false, + requested_no_followup: false, + requested_completion_turn_key: null, + requested_completion_identity_source: null, + linked_successor_todo_ids: [], + successor_intents: [], + note: "superseded", + evidence: null, + reason: "the replacement owns the next action", + clear_claim: false, + validation_declaration: null, + validation_receipt: null, + completion_policy_request: null, + dry_run: false, + now: new Date("2026-09-07T06:02:00Z"), + }); + assert.equal(superseded.status, "applied", JSON.stringify(superseded)); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") return; + const target = (loaded.head.todos as Record[]) + .find((todo) => todo.todo_id === "todo-claim"); + assert.equal(target?.status, "done"); + assert.equal(target?.completion_continuation, "active_goal"); + }); + + test(`${providerName} conformance: archive preserves legacy source order`, async (t) => { + const {store} = await factory(t); + const goalId = "goal-archive-order"; + const todos = [ + { + schema_version: TODO_ITEM_SCHEMA, + todo_id: "todo-a-newer", + role: "agent", + status: "done", + done: true, + text: "Newer completed compatibility Todo", + archive_state: "active", + source_section: "Agent Todo", + index: 2, + }, + { + schema_version: TODO_ITEM_SCHEMA, + todo_id: "todo-z-older", + role: "agent", + status: "done", + done: true, + text: "Older completed compatibility Todo", + archive_state: "active", + source_section: "Agent Todo", + index: 1, + }, + ].sort((left, right) => left.todo_id.localeCompare(right.todo_id)); + const initialized = await store.commitAuthority({ + expected_provider_revision: null, + operation_id: "initialize-archive-order", + events: [], + receipts: [], + next_projection: { + goal_id: goalId, + todos, + leases: [], + todo_read_model: { + schema_version: TODO_CANONICAL_READ_RECORD_SCHEMA, + todo_count: todos.length, + records_sha256: createHash("sha256") + .update(canonicalAuthorityBytes(todos)) + .digest("hex"), + contract_fields: [...TODO_CANONICAL_READ_RECORD_FIELDS], + }, + }, + }); + assert.equal(initialized.status, "applied"); + const archived = await executeCoordinationTodoArchiveCompleted(store, { + goal_id: goalId, + role: "agent", + max_active_done: 1, + operation_id: "archive-by-source-order", + dry_run: false, + now: new Date("2026-09-07T06:03:00Z"), + }); + assert.equal(archived.status, "applied", JSON.stringify(archived)); + assert.deepEqual(archived.moved_todo_ids, ["todo-z-older"]); + }); + + test(`${providerName} conformance: production-scale terminal lifecycle stays bounded`, async (t) => { + const {store} = await factory(t); + const goalId = "goal-production-scale"; + const fixture = productionScaleCoordinationFixture(goalId); + assert.equal(fixture.projection.handoff_mode, "hard_lease"); + const initialized = await store.commitAuthority({ + expected_provider_revision: null, + operation_id: "initialize-production-scale", + events: [], + receipts: [], + next_projection: fixture.projection, + }); + assert.equal(initialized.status, "applied"); + const common = { + goal_id: goalId, + expected_role: "agent" as const, + registered_agents: fixture.registered_agents, + lifecycle_grants: [], + authority_reason: null, + decision_outcome: null, + lease_idempotency_key: null, + lease_expected_version: null, + allow_user_gate_auto_acquire: false, + requested_completion_turn_key: null, + requested_completion_identity_source: null, + linked_successor_todo_ids: [], + successor_intents: [], + note: null, + evidence: "synthetic production-scale conformance", + reason: null, + clear_claim: false, + completion_policy_request: null, + dry_run: false, + }; + const completed = await executeCoordinationTodoTerminalLifecycle(store, { + ...common, + todo_id: fixture.completion_todo_id, + command: "complete", + actor_agent_id: "agent-a", + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, + operation_id: "complete-production-scale", + requested_no_followup: true, + validation_declaration: PRODUCTION_SCALE_VALIDATION_DECLARATION, + validation_receipt: { + schema_version: "issue_fix_validation_command_v0", + command_label: "production-scale fixture validation", + exit_code: 0, + passed: true, + status: "passed", + summary: "synthetic production-scale validation passed", + stdout_captured: false, + stderr_captured: false, + local_path_captured: false, + }, + now: new Date("2026-09-07T07:00:00Z"), + }); + assert.equal(completed.status, "applied", JSON.stringify(completed)); + const completedDecision = completed.terminal_decision as { + lease_fence?: unknown; + next_lease?: {status?: unknown}; + }; + assert.equal(completedDecision.lease_fence, "required"); + assert.equal(completedDecision.next_lease?.status, "released"); + const superseded = await executeCoordinationTodoTerminalLifecycle(store, { + ...common, + todo_id: fixture.supersede_todo_id, + command: "supersede", + actor_agent_id: "agent-b", + lease_idempotency_key: fixture.supersede_lease_idempotency_key, + lease_expected_version: fixture.supersede_lease_expected_version, + operation_id: "supersede-production-scale", + requested_no_followup: false, + validation_declaration: null, + validation_receipt: null, + reason: "synthetic replacement owns the next action", + now: new Date("2026-09-07T07:01:00Z"), + }); + assert.equal(superseded.status, "applied", JSON.stringify(superseded)); + const supersededDecision = superseded.terminal_decision as { + lease_fence?: unknown; + next_lease?: {status?: unknown}; + }; + assert.equal(supersededDecision.lease_fence, "required"); + assert.equal(supersededDecision.next_lease?.status, "released"); + const agentArchive = await executeCoordinationTodoArchiveCompleted(store, { + goal_id: goalId, + role: "agent", + max_active_done: 5, + operation_id: "archive-production-scale-agent", + dry_run: false, + now: new Date("2026-09-07T07:02:00Z"), + }); + assert.equal(agentArchive.status, "applied", JSON.stringify(agentArchive)); + assert.equal( + agentArchive.moved_count, + fixture.expected_agent_archive_count_after_terminals, + ); + const userArchive = await executeCoordinationTodoArchiveCompleted(store, { + goal_id: goalId, + role: "user", + max_active_done: 5, + operation_id: "archive-production-scale-user", + dry_run: false, + now: new Date("2026-09-07T07:03:00Z"), + }); + assert.equal(userArchive.status, "applied", JSON.stringify(userArchive)); + assert.equal(userArchive.moved_count, fixture.expected_user_archive_count); + assert.equal( + userArchive.retained_standing_decision_count, + fixture.expected_standing_user_decision_count, + ); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") return; + const todos = loaded.head.todos as Record[]; + const leases = loaded.head.leases as Record[]; + assert.equal(todos.length, fixture.expected_initial_todo_count); + assert.equal(leases.length, fixture.expected_current_lease_count); + assert.equal( + todos.filter((todo) => todo.archive_state === "active").length, + fixture.expected_initial_todo_count - + fixture.expected_agent_archive_count_after_terminals - + fixture.expected_user_archive_count, + ); + }); + for (const native of [false, true]) { test(`${providerName} conformance: native Todo create is atomic and replayable (${native ? "native" : "v0"})`, async (t) => { const {store, contender} = await factory(t); diff --git a/tests/control_plane_ts/local_authority_runtime.test.ts b/tests/control_plane_ts/local_authority_runtime.test.ts index e9f5e3768e..2daabb81c8 100644 --- a/tests/control_plane_ts/local_authority_runtime.test.ts +++ b/tests/control_plane_ts/local_authority_runtime.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -24,14 +24,18 @@ import { import { LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA, LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_ARCHIVE_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, + archiveLocalCoordinationTodos, listLocalCoordinationTodos, claimLocalCoordinationTodo, mutateLocalCoordinationAuthority, promoteLocalCoordinationAuthority, readLocalCoordinationTodo, + terminalLifecycleLocalCoordinationTodo, } from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; import { COORDINATION_TODO_CLAIM_RESULT_SCHEMA, @@ -1031,6 +1035,220 @@ test("local canonical runtime never falls back when provider state is missing", assert.equal(result.legacy_fallback_used, false); }); +test("terminal and archive wire adapters reject coercible numeric values", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-strict-numbers-")); + t.after(() => rm(root, {recursive: true, force: true})); + const terminalRequest = (leaseExpectedVersion: unknown) => ({ + schema_version: LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + todo_id: "todo-a", + role: "agent", + command: "complete", + actor_agent_id: "agent-a", + registered_agents: ["agent-a"], + lifecycle_grants: [], + authority_reason: null, + decision_outcome: null, + operation_id: "terminal-strict-number", + lease_idempotency_key: null, + lease_expected_version: leaseExpectedVersion, + allow_user_gate_auto_acquire: false, + requested_no_followup: true, + requested_completion_turn_key: null, + requested_completion_identity_source: null, + linked_successor_todo_ids: [], + successor_intents: [], + note: null, + evidence: "strict wire validation", + reason: null, + clear_claim: false, + validation_declaration: null, + validation_receipt: null, + completion_policy_request: null, + dry_run: false, + observed_at: "2026-09-07T12:00:00Z", + }); + const archiveRequest = (maxActiveDone: unknown) => ({ + schema_version: LOCAL_COORDINATION_TODO_ARCHIVE_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + role: "agent", + max_active_done: maxActiveDone, + operation_id: "archive-strict-number", + dry_run: false, + observed_at: "2026-09-07T12:00:00Z", + }); + + for (const invalid of [true, "1", 1.5]) { + let terminalOpened = 0; + const terminal = await terminalLifecycleLocalCoordinationTodo( + terminalRequest(invalid), + {createStore: (directory, goalId) => { + terminalOpened += 1; + return new FileAuthorityStore(directory, goalId, {existingOnly: true}); + }}, + ); + assert.equal(terminal.status, "failed"); + assert.equal( + terminal.reason_code, + "invalid_local_coordination_todo_terminal_lifecycle_request", + ); + assert.match(String(terminal.reason), /lease_expected_version.*safe integer/); + assert.equal(terminalOpened, 0); + + let archiveOpened = 0; + const archive = await archiveLocalCoordinationTodos( + archiveRequest(invalid), + {createStore: (directory, goalId) => { + archiveOpened += 1; + return new FileAuthorityStore(directory, goalId, {existingOnly: true}); + }}, + ); + assert.equal(archive.status, "failed"); + assert.equal(archive.reason_code, "invalid_local_coordination_todo_archive_request"); + assert.match(String(archive.reason), /max_active_done.*safe integer/); + assert.equal(archiveOpened, 0); + } + + let opened = 0; + const terminal = await terminalLifecycleLocalCoordinationTodo( + terminalRequest(1), + {createStore: (directory, goalId) => { + opened += 1; + return new FileAuthorityStore(directory, goalId, {existingOnly: true}); + }}, + ); + const archive = await archiveLocalCoordinationTodos( + archiveRequest(1), + {createStore: (directory, goalId) => { + opened += 1; + return new FileAuthorityStore(directory, goalId, {existingOnly: true}); + }}, + ); + assert.equal(terminal.status, "missing"); + assert.equal(archive.status, "missing"); + assert.equal(opened, 2, "legal integers must cross the wire boundary unchanged"); +}); + +test("terminal wire preserves legacy optional prose semantics", async (t) => { + const cases = [ + {field: "note", value: null, expected: "existing-note"}, + {field: "note", value: "", expected: "existing-note"}, + {field: "note", value: "ordinary note", expected: "ordinary note"}, + {field: "note", value: " \u0085 ", expected: "existing-note"}, + {field: "note", value: " first\u0085 second ", expected: "first second"}, + {field: "evidence", value: null, expected: "existing-evidence"}, + {field: "evidence", value: "", expected: "existing-evidence"}, + {field: "evidence", value: "ordinary evidence", expected: "ordinary evidence"}, + {field: "evidence", value: " \u0085 ", expected: "existing-evidence"}, + {field: "evidence", value: " first\u0085 second ", expected: "first second"}, + {field: "reason", value: null, expected: "existing-reason", command: "supersede"}, + {field: "reason", value: "", expected: "existing-reason", command: "supersede"}, + {field: "reason", value: "ordinary reason", expected: "ordinary reason", command: "supersede"}, + {field: "reason", value: " \u0085 ", expected: "existing-reason", command: "supersede"}, + {field: "reason", value: " first\u0085 second ", expected: "first second", command: "supersede"}, + ] as const; + + for (const [index, item] of cases.entries()) { + const root = await mkdtemp(join(tmpdir(), `loopx-terminal-prose-${index}-`)); + t.after(() => rm(root, {recursive: true, force: true})); + const store = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a"); + assert.equal((await store.commitAuthority({ + expected_provider_revision: null, + operation_id: `seed-prose-${index}`, + events: [], + next_projection: withTodoReadModel({ + goal_id: "goal-a", + handoff_mode: "soft_claim", + todos: [todoRecord({ + claimed_by: "agent-a", + note: "existing-note", + evidence: "existing-evidence", + reason: "existing-reason", + })], + leases: [], + }), + receipts: [], + })).status, "applied"); + const request = { + schema_version: LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + todo_id: "todo_a", + role: "agent", + command: "command" in item ? item.command : "complete", + actor_agent_id: "agent-a", + registered_agents: ["agent-a"], + lifecycle_grants: [], + authority_reason: null, + decision_outcome: null, + operation_id: `terminal-prose-${index}`, + lease_idempotency_key: null, + lease_expected_version: null, + allow_user_gate_auto_acquire: false, + requested_no_followup: true, + requested_completion_turn_key: null, + requested_completion_identity_source: null, + linked_successor_todo_ids: [], + successor_intents: [], + note: item.field === "note" ? item.value : null, + evidence: item.field === "evidence" ? item.value : null, + reason: item.field === "reason" ? item.value : null, + clear_claim: false, + validation_declaration: null, + validation_receipt: null, + completion_policy_request: null, + dry_run: false, + observed_at: "2026-09-08T04:00:00Z", + }; + const result = await terminalLifecycleLocalCoordinationTodo(request); + assert.equal(result.status, "applied", `${item.field}=${JSON.stringify(item.value)}: ${JSON.stringify(result)}`); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") continue; + const todo = (loaded.head.todos as Record[])[0]!; + assert.equal(todo[item.field], item.expected, `${item.field}=${JSON.stringify(item.value)}`); + } + + for (const field of ["note", "evidence", "reason"] as const) { + const invalid = await terminalLifecycleLocalCoordinationTodo({ + schema_version: LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, + runtime_root: join(tmpdir(), "loopx-invalid-terminal-prose"), + goal_id: "goal-a", + todo_id: "todo-a", + role: "agent", + command: field === "reason" ? "supersede" : "complete", + actor_agent_id: "agent-a", + registered_agents: ["agent-a"], + lifecycle_grants: [], + authority_reason: null, + decision_outcome: null, + operation_id: `terminal-invalid-${field}`, + lease_idempotency_key: null, + lease_expected_version: null, + allow_user_gate_auto_acquire: false, + requested_no_followup: true, + requested_completion_turn_key: null, + requested_completion_identity_source: null, + linked_successor_todo_ids: [], + successor_intents: [], + note: field === "note" ? 1 : null, + evidence: field === "evidence" ? 1 : null, + reason: field === "reason" ? 1 : null, + clear_claim: false, + validation_declaration: null, + validation_receipt: null, + completion_policy_request: null, + dry_run: false, + observed_at: "2026-09-08T04:00:00Z", + }, {createStore: (directory, goalId) => + new FileAuthorityStore(directory, goalId, {existingOnly: true})}); + assert.equal(invalid.status, "failed"); + assert.match(String(invalid.reason), new RegExp(`${field} must be a string or null`)); + } +}); + test("engaged promotion fence blocks every native legacy task-lease writer", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-lease-fence-")); const shadow = await qualifiedShadow(root); diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.ts b/tests/control_plane_ts/production_scale_coordination_fixture.ts new file mode 100644 index 0000000000..cb5fe43434 --- /dev/null +++ b/tests/control_plane_ts/production_scale_coordination_fixture.ts @@ -0,0 +1,180 @@ +import {createHash} from "node:crypto"; +import {readFileSync} from "node:fs"; + +import {canonicalAuthorityBytes, canonicalAuthoritySha256} from + "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import { + TODO_CANONICAL_READ_RECORD_FIELDS, + TODO_CANONICAL_READ_RECORD_SCHEMA, + TODO_ITEM_SCHEMA, +} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; + +const envelope = JSON.parse(readFileSync(new URL( + "../fixtures/control_plane/coordination_production_scale_v0.json", + import.meta.url, +), "utf8")) as { + schema_version: string; + agent_status_counts: Record; + user_status_counts: Record; + current_lease_count: number; + retired_lease_count: number; + standing_user_decision_count: number; + completion_target_index: number; + supersede_target_index: number; +}; + +export const PRODUCTION_SCALE_FIXTURE_SCHEMA = + "loopx_coordination_production_scale_fixture_v0"; +export const PRODUCTION_SCALE_VALIDATION_DECLARATION = { + validation_command: null, + validation_command_argv: ["python3", "-c", "raise SystemExit(0)"], + validation_label: "production-scale fixture validation", + validation_timeout_seconds: 5, +}; + +export interface ProductionScaleCoordinationFixture { + readonly projection: Record; + readonly registered_agents: readonly string[]; + readonly completion_todo_id: string; + readonly supersede_todo_id: string; + readonly completion_lease_idempotency_key: string; + readonly completion_lease_expected_version: number; + readonly supersede_lease_idempotency_key: string; + readonly supersede_lease_expected_version: number; + readonly expected_initial_todo_count: number; + readonly expected_current_lease_count: number; + readonly expected_agent_archive_count_after_terminals: number; + readonly expected_user_archive_count: number; + readonly expected_standing_user_decision_count: number; +} + +function statusSeries(counts: Record): string[] { + return Object.entries(counts).flatMap(([status, count]) => + Array.from({length: count}, () => status)); +} + +function todoId(role: "agent" | "user", index: number): string { + return `todo_fixture_${role}_${String(index).padStart(3, "0")}`; +} + +function observedAt(index: number): string { + return new Date(Date.UTC(2025, 0, 1, 0, index)).toISOString().replace(/\.\d{3}Z$/u, "Z"); +} + +function todoRecords( + goalId: string, + role: "agent" | "user", + counts: Record, +): Record[] { + return statusSeries(counts).map((status, index) => { + const done = status === "done" || status === "deferred"; + const record: Record = { + schema_version: TODO_ITEM_SCHEMA, + todo_id: todoId(role, index), + role, + status, + done, + text: `Synthetic ${role} Todo ${String(index).padStart(3, "0")}`, + archive_state: "active", + source_section: role === "agent" ? "Agent Todo" : "User Todo", + index: index + 1, + task_class: role === "agent" + ? index % 4 === 0 ? "continuous_monitor" : "advancement_task" + : index % 3 === 0 ? "user_gate" : "user_action", + ...(done ? {updated_at: observedAt(index), completed_at: observedAt(index)} : {}), + ...(status === "deferred" ? {resume_when: "material_change"} : {}), + }; + if (role === "agent" && status !== "done" && status !== "deferred") { + record.claimed_by = index % 2 === 0 ? "agent-a" : "agent-b"; + } + if (role === "agent" && status === "done" && index < 3) { + record.successor_todo_ids = [todoId("agent", envelope.completion_target_index + index)]; + record.completion_continuation = "successor"; + } + if (role === "user" && index < envelope.standing_user_decision_count) { + record.task_class = "user_gate"; + record.decision_scope = {kind: "direction", granularity: "goal", scope_key: goalId}; + record.decision_outcome = "approve"; + record.global_gate = true; + record.goal_bound = true; + } + return record; + }); +} + +export function productionScaleCoordinationFixture( + goalId: string, +): ProductionScaleCoordinationFixture { + if (envelope.schema_version !== PRODUCTION_SCALE_FIXTURE_SCHEMA) { + throw new Error("production-scale fixture envelope schema mismatch"); + } + const agents = todoRecords(goalId, "agent", envelope.agent_status_counts); + const users = todoRecords(goalId, "user", envelope.user_status_counts); + const completionTodo = agents[envelope.completion_target_index]!; + const supersedeTodo = agents[envelope.supersede_target_index]!; + completionTodo.task_class = "advancement_task"; + completionTodo.claimed_by = "agent-a"; + completionTodo.completion_validation_required = true; + completionTodo.completion_validation_sha256 = canonicalAuthoritySha256( + PRODUCTION_SCALE_VALIDATION_DECLARATION, + ); + supersedeTodo.task_class = "advancement_task"; + supersedeTodo.claimed_by = "agent-b"; + const todos = [...agents, ...users] + .sort((left, right) => String(left.todo_id).localeCompare(String(right.todo_id))); + const leasedIds = [ + String(completionTodo.todo_id), + String(supersedeTodo.todo_id), + ...agents.map((todo) => String(todo.todo_id)), + ] + .filter((value, index, values) => values.indexOf(value) === index) + .slice(0, envelope.current_lease_count); + const leases = leasedIds.map((leasedTodoId, index) => ({ + schema_version: "task_lease_v0", + goal_id: goalId, + todo_id: leasedTodoId, + owner: leasedTodoId === completionTodo.todo_id ? "agent-a" : "agent-b", + idempotency_key: `fixture-lease-${index}`, + write_scopes: ["loopx/control_plane/**"], + version: index + 1, + lease_epoch: index + 1, + acquired_at: observedAt(index), + updated_at: observedAt(index), + expires_at: index < 2 ? "2027-01-01T00:00:00Z" : observedAt(index + 1), + status: index < 2 ? "active" : "released", + })).sort((left, right) => left.todo_id.localeCompare(right.todo_id)); + const completionLease = leases.find((lease) => lease.todo_id === completionTodo.todo_id)!; + const supersedeLease = leases.find((lease) => lease.todo_id === supersedeTodo.todo_id)!; + const initialAgentDone = envelope.agent_status_counts.done ?? 0; + return { + projection: { + goal_id: goalId, + source_authority: "synthetic_production_scale_fixture", + handoff_mode: "hard_lease", + todos, + leases, + todo_read_model: { + schema_version: TODO_CANONICAL_READ_RECORD_SCHEMA, + todo_count: todos.length, + records_sha256: createHash("sha256") + .update(canonicalAuthorityBytes(todos)) + .digest("hex"), + contract_fields: [...TODO_CANONICAL_READ_RECORD_FIELDS], + }, + }, + registered_agents: ["agent-a", "agent-b"], + completion_todo_id: String(completionTodo.todo_id), + supersede_todo_id: String(supersedeTodo.todo_id), + completion_lease_idempotency_key: completionLease.idempotency_key, + completion_lease_expected_version: completionLease.version, + supersede_lease_idempotency_key: supersedeLease.idempotency_key, + supersede_lease_expected_version: supersedeLease.version, + expected_initial_todo_count: todos.length, + expected_current_lease_count: leases.length, + expected_agent_archive_count_after_terminals: initialAgentDone + 2 - 5, + expected_user_archive_count: (envelope.user_status_counts.done ?? 0) - 5, + expected_standing_user_decision_count: envelope.standing_user_decision_count, + }; +} + +export const PRODUCTION_SCALE_RETIRED_LEASE_COUNT = envelope.retired_lease_count; diff --git a/tests/control_plane_ts/shadow_native_writer_boundary.test.ts b/tests/control_plane_ts/shadow_native_writer_boundary.test.ts index 024be5f3da..41770b89ce 100644 --- a/tests/control_plane_ts/shadow_native_writer_boundary.test.ts +++ b/tests/control_plane_ts/shadow_native_writer_boundary.test.ts @@ -5,26 +5,41 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { atomicWriteJson } from "../../loopx/control_plane/effect_runtime_io.ts"; -import { shadowManagementStatePath } from "../../loopx/control_plane/coordination/shadow_management.ts"; import { + shadowMaintenanceLockPath, + shadowManagementStatePath, +} from "../../loopx/control_plane/coordination/shadow_management.ts"; +import { + archiveLocalCoordinationTodos, createLocalCoordinationTodo, claimLocalCoordinationTodo, mutateLocalCoordinationAuthority, editLocalCoordinationTodo, + terminalLifecycleLocalCoordinationTodo, + LOCAL_COORDINATION_TODO_ARCHIVE_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA, } from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; -for (const [name, invoke, schema] of [ - ["create", createLocalCoordinationTodo, LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA], - ["claim", claimLocalCoordinationTodo, LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA], - ["mutate", mutateLocalCoordinationAuthority, LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA], - ["edit", editLocalCoordinationTodo, "loopx_todo_compatibility_edit_request_v0"], +for (const [name, invoke, schema, requestFields] of [ + ["create", createLocalCoordinationTodo, LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA, {}], + ["claim", claimLocalCoordinationTodo, LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA, {}], + ["mutate", mutateLocalCoordinationAuthority, LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA, {}], + ["edit", editLocalCoordinationTodo, "loopx_todo_compatibility_edit_request_v0", {}], + ["terminal", terminalLifecycleLocalCoordinationTodo, + LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, { + registered_agents: [], lifecycle_grants: [], successor_intents: [], + linked_successor_todo_ids: [], lease_expected_version: null, + }], + ["archive", archiveLocalCoordinationTodos, + LOCAL_COORDINATION_TODO_ARCHIVE_REQUEST_SCHEMA, {max_active_done: 0}], ] as const) { test(`promoted ${name} checks maintenance before opening a provider`, async (t) => { const root = await mkdtemp(join(tmpdir(), "loopx-native-maintenance-")); t.after(() => rm(root, {recursive: true, force: true})); await atomicWriteJson(shadowManagementStatePath(root, "goal-a"), {}); let opened = 0; - const result = await invoke({schema_version: schema, runtime_root: root, goal_id: "goal-a", dry_run: false}, { + const result = await invoke({schema_version: schema, runtime_root: root, goal_id: "goal-a", + dry_run: false, ...requestFields}, { createStore: () => { opened++; throw new Error("provider touched"); }, }); assert.equal(result.reason_code, "shadow_management_state_invalid"); @@ -32,6 +47,43 @@ for (const [name, invoke, schema] of [ }); } +for (const [name, invoke, schema, requestFields] of [ + ["terminal", terminalLifecycleLocalCoordinationTodo, + LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, { + registered_agents: [], lifecycle_grants: [], successor_intents: [], + linked_successor_todo_ids: [], lease_expected_version: null, + }], + ["archive", archiveLocalCoordinationTodos, + LOCAL_COORDINATION_TODO_ARCHIVE_REQUEST_SCHEMA, {max_active_done: 0}], +] as const) { + test(`promoted ${name} waits behind the bootstrap and rollback maintenance lock`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-native-maintenance-race-")); + t.after(() => rm(root, {recursive: true, force: true})); + let opened = 0; + let completed = false; + let pending: Promise> | undefined; + await withFileMutationLock(shadowMaintenanceLockPath(root, "goal-a"), async () => { + pending = invoke({schema_version: schema, runtime_root: root, goal_id: "goal-a", + dry_run: false, ...requestFields}, { + createStore: () => { + opened += 1; + throw new Error("provider opened only after maintenance"); + }, + }).then((result) => { + completed = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(completed, false); + assert.equal(opened, 0, "provider access must not overlap bootstrap or rollback"); + }); + const result = await pending!; + assert.equal(opened, 1); + assert.equal(result.status, "failed"); + assert.match(String(result.reason), /provider opened only after maintenance/); + }); +} + import { engageLegacyCoordinationWriterFence, legacyCoordinationTodoLockPath, legacyCoordinationWriterFencePath } from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; import { taskLeaseLockPath } from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; import { withFileMutationLock } from "../../loopx/control_plane/effect_runtime_io.ts"; diff --git a/tests/control_plane_ts/todo_archive_selection.test.ts b/tests/control_plane_ts/todo_archive_selection.test.ts new file mode 100644 index 0000000000..4033d0e05f --- /dev/null +++ b/tests/control_plane_ts/todo_archive_selection.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COORDINATION_TODO_ARCHIVE_SELECTION_SCHEMA, + evaluateCoordinationTodoArchiveSelection, + selectCoordinationTodoArchive, +} from "../../loopx/control_plane/coordination/todo_archive_selection.ts"; + +function doneTodo(todoId: string, overrides: Record = {}) { + return { + todo_id: todoId, + role: "agent", + status: "done", + archive_state: "active", + ...overrides, + }; +} + +test("archive selection preserves imported order before native timestamp fallback", () => { + const selected = selectCoordinationTodoArchive({ + role: "agent", + max_active_done: 2, + todos: [ + doneTodo("native-new", {completed_at: "2026-09-08T03:00:00Z"}), + doneTodo("imported-new", {index: 2}), + doneTodo("native-old-z", {completed_at: "2026-09-08T01:00:00Z"}), + doneTodo("imported-old", {index: 1}), + doneTodo("native-old-a", {completed_at: "2026-09-08T01:00:00Z"}), + ], + }); + + assert.equal(selected.schema_version, COORDINATION_TODO_ARCHIVE_SELECTION_SCHEMA); + assert.deepEqual(selected.moved_todo_ids, [ + "imported-old", + "imported-new", + "native-old-a", + ]); + assert.equal(selected.active_done_before, 5); + assert.equal(selected.active_done_after, 2); +}); + +test("archive selection retains standing user decision receipts", () => { + const selected = selectCoordinationTodoArchive({ + role: "user", + max_active_done: 0, + todos: [ + doneTodo("ordinary", {role: "user", index: 1}), + doneTodo("standing", { + role: "user", + index: 2, + task_class: "user_gate", + decision_scope: {granularity: "goal", key: "release"}, + decision_outcome: "approve", + global_gate: true, + }), + doneTodo("scoped", { + role: "user", + index: 3, + task_class: "user_gate", + decision_scope: {granularity: "goal", key: "one-task"}, + decision_outcome: "approve", + global_gate: true, + unblocks_todo_id: "todo-a", + }), + ], + }); + + assert.deepEqual(selected.moved_todo_ids, ["ordinary", "scoped"]); + assert.equal(selected.retained_standing_decision_count, 1); + assert.equal(selected.active_done_after, 1); +}); + +test("archive selection ignores nonmatching and nonterminal records without pressure", () => { + const selected = selectCoordinationTodoArchive({ + role: "agent", + max_active_done: 1, + todos: [ + doneTodo("only-complete"), + doneTodo("already-archived", {archive_state: "archive"}), + doneTodo("open", {status: "open"}), + doneTodo("user", {role: "user"}), + ], + }); + + assert.deepEqual(selected.moved_todo_ids, []); + assert.equal(selected.active_done_before, 1); + assert.equal(selected.active_done_after, 1); +}); + +test("archive wire rejects invalid limits, roles, and duplicate completed ids", () => { + for (const max_active_done of [true, "1", 1.5, -1]) { + assert.throws(() => evaluateCoordinationTodoArchiveSelection({ + role: "agent", + max_active_done, + todos: [], + }), /max_active_done.*safe integer/); + } + assert.throws(() => evaluateCoordinationTodoArchiveSelection({ + role: "observer", + max_active_done: 0, + todos: [], + }), /archive role/); + assert.throws(() => evaluateCoordinationTodoArchiveSelection({ + role: "agent", + max_active_done: 0, + todos: [doneTodo("duplicate"), doneTodo("duplicate")], + }), /must be unique/); +}); diff --git a/tests/control_plane_ts/todo_successor_derivation.test.ts b/tests/control_plane_ts/todo_successor_derivation.test.ts new file mode 100644 index 0000000000..f989364c4c --- /dev/null +++ b/tests/control_plane_ts/todo_successor_derivation.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveCoordinationTodoSuccessorProposals, + evaluateCoordinationTodoSuccessorDerivation, + TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA, +} from "../../loopx/control_plane/coordination/todo_successor_derivation.ts"; + +const predecessor = { + todo_id: "todo-predecessor", + role: "agent", + status: "open", + text: "[P1] Finish the authority transition", + task_class: "advancement_task", + claimed_by: "agent-a", + capability_binding_ref: "binding-a", + unblocks_todo_id: "todo-parent", +}; + +test("complete derives priority, authority bindings, exclusions, and predecessor relation once", () => { + const input = { + schema_version: TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + command: "complete" as const, + predecessor, + registered_agents: ["agent-a", "agent-b", "agent-c"], + actor_agent_id: "agent-a", + completion_policy: { + effective_claimed_by: "agent-a", + effective_next_claimed_by: "agent-b", + effective_next_excluded_agents: ["agent-c"], + }, + successor_intents: [ + { + role: "agent", + text: "Continue the provider migration", + action_kind: "\u0085IMPLEMENTATION\u0085", + required_capabilities: ["authority_write"], + continuation_policy: "\u0085INDEPENDENT_HANDOFF\u0085", + }, + { + role: "user", + text: "Approve the resulting authority boundary", + task_class: "user_gate", + }, + ], + }; + + const proposals = deriveCoordinationTodoSuccessorProposals(input); + assert.deepEqual(proposals, [ + { + role: "agent", + text: "[P1] Continue the provider migration", + task_class: "advancement_task", + created_by: "agent-a", + action_kind: "implementation", + capability_binding_ref: "binding-a", + required_capabilities: ["authority_write"], + continuation_policy: "independent_handoff", + claimed_by: "agent-b", + excluded_agents: ["agent-c"], + unblocks_todo_id: "todo-predecessor", + }, + { + role: "user", + text: "[P1] Approve the resulting authority boundary", + task_class: "user_gate", + created_by: "agent-a", + bound_agent: "agent-a", + blocks_agent: "agent-a", + action_kind: "gate", + }, + ]); + assert.equal(input.successor_intents[0]!.text, "Continue the provider migration"); +}); + +test("supersede inherits same-agent continuity, binding, and existing unblock relation", () => { + const proposals = deriveCoordinationTodoSuccessorProposals({ + schema_version: TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + command: "supersede", + predecessor, + registered_agents: ["agent-a", "agent-b"], + actor_agent_id: "agent-b", + completion_policy: null, + successor_intents: [ + { + role: "agent", + text: "[P0] Replace the failed implementation", + task_class: "blocker", + continuation_policy: "same_agent_non_delivery", + excluded_agents: ["agent-b"], + }, + { + role: "user", + text: "Confirm the replacement", + task_class: "user_action", + }, + ], + }); + + assert.deepEqual(proposals[0], { + role: "agent", + text: "[P0] Replace the failed implementation", + task_class: "blocker", + created_by: "agent-b", + capability_binding_ref: "binding-a", + continuation_policy: "same_agent_non_delivery", + claimed_by: "agent-a", + excluded_agents: ["agent-b"], + unblocks_todo_id: "todo-parent", + }); + assert.deepEqual(proposals[1], { + role: "user", + text: "[P1] Confirm the replacement", + task_class: "user_action", + created_by: "agent-b", + bound_agent: "agent-a", + }); +}); + +test("empty intent is identity and skips successor-only authority validation", () => { + const result = evaluateCoordinationTodoSuccessorDerivation({ + schema_version: TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + command: "complete", + predecessor, + registered_agents: [], + actor_agent_id: "legacy-unregistered-agent", + completion_policy: {}, + successor_intents: [], + }); + + assert.deepEqual(result, { + schema_version: TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA, + status: "derived", + successors: [], + }); +}); + +test("derivation fails closed for contradictory or malformed caller intent", () => { + const result = evaluateCoordinationTodoSuccessorDerivation({ + schema_version: TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + command: "complete", + predecessor, + registered_agents: ["agent-a", "agent-b"], + actor_agent_id: "agent-a", + completion_policy: { + effective_claimed_by: "agent-a", + effective_next_claimed_by: "agent-b", + effective_next_excluded_agents: ["agent-b"], + }, + successor_intents: [{ + role: "agent", + text: "Continue", + task_class: "advancement_task", + }], + }); + + assert.equal(result.schema_version, TODO_SUCCESSOR_DERIVATION_RESULT_SCHEMA); + assert.equal(result.status, "failed"); + assert.equal(result.reason_code, "invalid_todo_successor_derivation"); + assert.match(String(result.reason), /claimed_by cannot also appear in excluded_agents/u); + + const missingText = evaluateCoordinationTodoSuccessorDerivation({ + schema_version: TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, + command: "supersede", + predecessor, + registered_agents: ["agent-a"], + actor_agent_id: "agent-a", + completion_policy: null, + successor_intents: [{role: "agent", text: "", claimed_by: "agent-a"}], + }); + assert.equal(missingText.status, "failed"); + assert.match(String(missingText.reason), /text must not be empty/u); +}); diff --git a/tests/control_plane_ts/todo_terminal_decision.test.ts b/tests/control_plane_ts/todo_terminal_decision.test.ts new file mode 100644 index 0000000000..46022e6893 --- /dev/null +++ b/tests/control_plane_ts/todo_terminal_decision.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA, + evaluateCoordinationTodoTerminalDecision, +} from "../../loopx/control_plane/coordination/todo_terminal_decision.ts"; + +function request(overrides: Record = {}) { + return { + schema_version: COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA, + command: "complete", + handoff_mode: "legacy", + registered_agents: ["agent-a", "agent-b"], + lifecycle_grants: [], + todo: { + todo_id: "todo_target", + status: "open", + role: "agent", + task_class: "advancement_task", + claimed_by: "agent-a", + excluded_agents: [], + bound_agent: null, + blocks_agent: null, + decision_scope: null, + required_decision_scopes: [], + unblocks_todo_id: null, + }, + decision_target: null, + lease: null, + actor_agent_id: "agent-a", + authority_action: "complete", + authority_reason: null, + decision_outcome: null, + lease_idempotency_key: null, + lease_expected_version: null, + allow_user_gate_auto_acquire: false, + ...overrides, + }; +} + +test("terminal decision owns complete and supersede authority", () => { + for (const command of ["complete", "supersede"]) { + const decided = evaluateCoordinationTodoTerminalDecision(request({ + command, + authority_action: command, + })); + assert.equal(decided.outcome, "apply"); + assert.equal(decided.code, "terminal_transition"); + assert.equal(decided.authority_mode, "registered_peer_actor"); + assert.equal(decided.next_todo_status, "done"); + } +}); + +test("terminal authority rejects missing, unknown, excluded, bound, and foreign actors", () => { + const cases: Array<[Record, string]> = [ + [{ actor_agent_id: null }, "actor_required"], + [{ actor_agent_id: "agent-c" }, "actor_not_registered"], + [{ todo: { ...request().todo as object, excluded_agents: ["agent-a"] } }, "actor_excluded"], + [{ todo: { ...request().todo as object, role: "user", claimed_by: null, + bound_agent: "agent-b" } }, "bound_agent_mismatch"], + [{ actor_agent_id: "agent-b" }, "claim_owner_mismatch"], + ]; + for (const [overrides, code] of cases) { + const decided = evaluateCoordinationTodoTerminalDecision(request(overrides)); + assert.equal(decided.outcome, "rejected"); + assert.equal(decided.code, code); + } +}); + +test("delegated terminal authority is explicit and reason-bound", () => { + const base = { + actor_agent_id: "agent-b", + lifecycle_grants: [{ + agent_id: "agent-b", + actions: ["complete"], + requires_reason: true, + }], + }; + assert.equal(evaluateCoordinationTodoTerminalDecision(request(base)).code, + "delegation_reason_required"); + const accepted = evaluateCoordinationTodoTerminalDecision(request({ + ...base, + authority_reason: "recover abandoned owner", + })); + assert.equal(accepted.outcome, "apply"); + assert.equal(accepted.authority_mode, "delegated_orchestration_override"); + + const wrongAction = evaluateCoordinationTodoTerminalDecision(request({ + ...base, + authority_action: "supersede", + authority_reason: "recover abandoned owner", + })); + assert.equal(wrongAction.code, "delegation_action_not_granted"); +}); + +test("exact linked user gate decision scope does not invent Agent authority", () => { + const scope = {kind: "direction", granularity: "action", scope_key: "release"}; + const gate = { + todo_id: "todo_gate", + status: "open", + role: "user", + task_class: "user_gate", + claimed_by: null, + excluded_agents: [], + bound_agent: null, + blocks_agent: "agent-a", + decision_scope: scope, + required_decision_scopes: [], + unblocks_todo_id: "todo_target", + }; + const target = { + ...request().todo as object, + todo_id: "todo_target", + required_decision_scopes: [scope], + }; + const accepted = evaluateCoordinationTodoTerminalDecision(request({ + todo: gate, + decision_target: target, + actor_agent_id: null, + decision_outcome: "approve", + })); + assert.equal(accepted.outcome, "apply"); + assert.equal(accepted.authority_mode, "exact_user_gate_decision_scope_override"); + + const mismatch = evaluateCoordinationTodoTerminalDecision(request({ + todo: gate, + decision_target: { ...target, required_decision_scopes: [] }, + actor_agent_id: null, + decision_outcome: "approve", + })); + assert.equal(mismatch.code, "actor_required"); +}); + +test("hard-lease terminal transition releases the exact owned generation", () => { + const lease = { + present: true, + active: true, + status: "active", + owner: "agent-a", + idempotency_key: "lease-a", + version: 3, + lease_epoch: 7, + write_scopes: ["docs/**"], + acquire_ttl_seconds: 600, + }; + const missingFence = evaluateCoordinationTodoTerminalDecision(request({ + handoff_mode: "hard_lease", + lease, + })); + assert.equal(missingFence.code, "lease_fence_required"); + const stale = evaluateCoordinationTodoTerminalDecision(request({ + handoff_mode: "hard_lease", + lease, + lease_idempotency_key: "lease-a", + lease_expected_version: 2, + })); + assert.equal(stale.outcome, "conflict"); + assert.equal(stale.code, "version_mismatch"); + const accepted = evaluateCoordinationTodoTerminalDecision(request({ + handoff_mode: "hard_lease", + lease, + lease_idempotency_key: "lease-a", + lease_expected_version: 3, + })); + assert.equal(accepted.outcome, "apply"); + assert.equal(accepted.lease_fence, "required"); + assert.equal(accepted.next_lease?.status, "released"); + assert.equal(accepted.next_lease?.version, 3); + assert.equal(accepted.next_lease?.lease_epoch, 7); +}); + +test("hard-lease divergence and terminal replay fail closed in the established order", () => { + const divergent = evaluateCoordinationTodoTerminalDecision(request({ + handoff_mode: "hard_lease", + lease: { + present: true, + active: true, + status: "active", + owner: "agent-b", + idempotency_key: "lease-b", + version: 1, + lease_epoch: 1, + write_scopes: [], + acquire_ttl_seconds: 600, + }, + })); + assert.equal(divergent.code, "handoff_mode_lease_claim_divergence"); + + const replayed = evaluateCoordinationTodoTerminalDecision(request({ + todo: { ...request().todo as object, status: "done" }, + handoff_mode: "hard_lease", + })); + assert.equal(replayed.outcome, "no_change"); + assert.equal(replayed.code, "terminal_replay"); + assert.equal(replayed.idempotent, true); +}); diff --git a/tests/fixtures/control_plane/coordination_production_scale_v0.json b/tests/fixtures/control_plane/coordination_production_scale_v0.json new file mode 100644 index 0000000000..50ac2fcac1 --- /dev/null +++ b/tests/fixtures/control_plane/coordination_production_scale_v0.json @@ -0,0 +1,19 @@ +{ + "schema_version": "loopx_coordination_production_scale_fixture_v0", + "agent_status_counts": { + "done": 160, + "open": 48, + "blocked": 32, + "deferred": 16 + }, + "user_status_counts": { + "done": 192, + "open": 8, + "deferred": 8 + }, + "current_lease_count": 64, + "retired_lease_count": 160, + "standing_user_decision_count": 4, + "completion_target_index": 160, + "supersede_target_index": 161 +} diff --git a/tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json b/tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json index 67271a22c3..8269bc2eb3 100644 --- a/tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json +++ b/tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json @@ -1,7 +1,7 @@ { "schema_version": "loopx_legacy_writer_fence_caller_parity_v0", "source_baseline": "0fb497af8", - "description": "Complete observable behaviour of every fenced legacy writer entry point: whole-object envelopes, exit status, and exclusion-free effect snapshots. `baseline` entries are documentation of earlier revisions and are never executed.", + "description": "Observable behaviour of every fenced coordination entry point: legacy-only writers remain fenced, while promoted terminal and archive rows deliberately route through canonical authority. Stable provider-first fields use subset matching; exit status and exclusion-free effect snapshots remain exact. `baseline` entries document earlier revisions and are never executed.", "placeholders": [ "{runtime_root}", "{todo_a}", @@ -1277,18 +1277,16 @@ "dry_run": false, "added": false, "already_exists": false, + "changed": false, "goal_id": "observable", "role": null, "todo": "", - "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", - "error_code": "legacy_coordination_writer_fenced", - "write_check": { - "schema_version": "loopx_legacy_coordination_write_check_result_v0", - "status": "blocked", - "reason_code": "legacy_coordination_writer_fenced", - "authority_mode": "file_v0", - "fence_id": "caller-fixture" - } + "status": "failed", + "reason_code": "handoff_mode_requires_lease", + "error_code": "handoff_mode_requires_lease", + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false }, "effect": { "added": [], @@ -1296,6 +1294,7 @@ "changed": [] }, "outbox_added": [], + "match": "subset", "baseline": { "note": "recorded through the real CLI against each revision", "0fb497af8": { @@ -1357,18 +1356,16 @@ "dry_run": false, "added": false, "already_exists": false, + "changed": false, "goal_id": "observable", "role": null, "todo": "Parity successor", - "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", - "error_code": "legacy_coordination_writer_fenced", - "write_check": { - "schema_version": "loopx_legacy_coordination_write_check_result_v0", - "status": "blocked", - "reason_code": "legacy_coordination_writer_fenced", - "authority_mode": "file_v0", - "fence_id": "caller-fixture" - } + "status": "failed", + "reason_code": "handoff_mode_requires_lease", + "error_code": "handoff_mode_requires_lease", + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false }, "effect": { "added": [], @@ -1376,6 +1373,7 @@ "changed": [] }, "outbox_added": [], + "match": "subset", "baseline": { "note": "recorded through the real CLI against each revision", "0fb497af8": { @@ -1396,31 +1394,30 @@ "workspace": "w1", "caller": "todo_archive_completed_execute", "fence_state": "engaged", - "exit": 1, + "exit": 0, "expect": { - "ok": false, + "ok": true, "dry_run": false, - "added": false, - "already_exists": false, + "changed": false, "goal_id": "observable", - "role": null, - "todo": "", - "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", - "error_code": "legacy_coordination_writer_fenced", - "write_check": { - "schema_version": "loopx_legacy_coordination_write_check_result_v0", - "status": "blocked", - "reason_code": "legacy_coordination_writer_fenced", - "authority_mode": "file_v0", - "fence_id": "caller-fixture" - } + "role": "agent", + "status": "no_change", + "moved_count": 0, + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false, + "projection_delivery": "delivered" }, "effect": { "added": [], "removed": [], - "changed": [] + "changed": [ + "STATE.md", + "runtime/goals/observable/rollout-event-log.jsonl" + ] }, "outbox_added": [], + "match": "subset", "baseline": { "note": "recorded through the real CLI against each revision", "0fb497af8": { @@ -1531,7 +1528,12 @@ "expect": { "ok": true, "dry_run": true, - "changed": false + "changed": false, + "status": "no_change", + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false, + "projection_delivery": "not_required" }, "effect": { "added": [], @@ -1585,26 +1587,20 @@ "workspace": "w1", "caller": "todo_complete_dry_run_gate", "fence_state": "engaged", - "exit": 1, + "exit": 0, "expect": { - "ok": false, + "ok": true, "dry_run": true, - "added": false, - "already_exists": false, + "changed": true, + "completed": true, + "superseded": false, "goal_id": "observable", - "role": null, - "todo": "", - "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", - "error_code": "legacy_coordination_writer_fenced", - "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_gate}.json", - "write_check": { - "schema_version": "loopx_legacy_coordination_write_check_result_v0", - "status": "blocked", - "reason_code": "legacy_coordination_writer_fenced", - "authority_mode": "file_v0", - "fence_id": "caller-fixture" - }, - "handoff_mode": "hard_lease" + "role": "user", + "status": "planned", + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false, + "projection_delivery": "not_required" }, "effect": { "added": [], @@ -1612,6 +1608,7 @@ "changed": [] }, "outbox_added": [], + "match": "subset", "baseline": { "note": "ee1b17217 returned the untyped 'native task-lease lifecycle schema mismatch' and left an acquired intent receipt; the verify path now checks the fence before its first receipt" } @@ -1628,20 +1625,16 @@ "dry_run": true, "added": false, "already_exists": false, + "changed": false, "goal_id": "observable", "role": null, "todo": "Parity successor", - "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", - "error_code": "legacy_coordination_writer_fenced", - "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_a}.json", - "write_check": { - "schema_version": "loopx_legacy_coordination_write_check_result_v0", - "status": "blocked", - "reason_code": "legacy_coordination_writer_fenced", - "authority_mode": "file_v0", - "fence_id": "caller-fixture" - }, - "handoff_mode": "hard_lease" + "status": "failed", + "reason_code": "handoff_mode_requires_lease", + "error_code": "handoff_mode_requires_lease", + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false }, "effect": { "added": [], @@ -1649,6 +1642,7 @@ "changed": [] }, "outbox_added": [], + "match": "subset", "baseline": { "note": "ee1b17217 reported handoff_mode_requires_lease and left an acquired intent receipt for this preview" } @@ -1659,26 +1653,20 @@ "workspace": "w1", "caller": "todo_complete_dry_run_leased", "fence_state": "engaged", - "exit": 1, + "exit": 0, "expect": { - "ok": false, + "ok": true, "dry_run": true, - "added": false, - "already_exists": false, + "changed": true, + "completed": true, + "superseded": false, "goal_id": "observable", - "role": null, - "todo": "", - "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", - "error_code": "legacy_coordination_writer_fenced", - "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", - "write_check": { - "schema_version": "loopx_legacy_coordination_write_check_result_v0", - "status": "blocked", - "reason_code": "legacy_coordination_writer_fenced", - "authority_mode": "file_v0", - "fence_id": "caller-fixture" - }, - "handoff_mode": "hard_lease" + "role": "agent", + "status": "planned", + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false, + "projection_delivery": "not_required" }, "effect": { "added": [], @@ -1686,6 +1674,7 @@ "changed": [] }, "outbox_added": [], + "match": "subset", "baseline": { "note": "ee1b17217 previewed ok=true for a write the fence forbids and persisted a closed lifecycle-fence receipt" } diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index 8afbf5cd51..3d540acf02 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -11,7 +11,13 @@ import pytest import loopx.cli_commands.turn as turn_command +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, +) from loopx.cli import main as cli_main +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) from loopx.control_plane.quota.turn_envelope import build_turn_envelope from loopx.control_plane.turn_driver import ( LOOPX_TURN_SESSION_BINDING_SCHEMA_VERSION, @@ -1161,6 +1167,43 @@ def _write_live_fixture( return project, runtime, registry +def _promote_turn_fixture(project: Path, runtime: Path) -> None: + state = ( + project + / ".codex" + / "goals" + / "loopx-turn-fixture" + / "ACTIVE_GOAL_STATE.md" + ) + projection = build_todo_runtime_shadow_projection( + goal_id="loopx-turn-fixture", + handoff_mode="soft_claim", + todos=[ + { + "schema_version": "todo_item_v0", + "index": 1, + "done": False, + "text": "[P0] Advance one public fixture.", + "todo_id": "todo_fixture0001", + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": "Agent Todo", + "task_class": "advancement_task", + "action_kind": "fixture", + "claimed_by": "codex-fixture", + "priority": "P0", + } + ], + ) + initialize_canonical_authority( + runtime, + "loopx-turn-fixture", + projection, + state_path=state, + ) + + def test_quota_cli_projects_outer_controller_without_codex_app_action( tmp_path: Path, ) -> None: @@ -1812,6 +1855,90 @@ def test_turn_run_once_cli_completes_selected_todo_after_validation( assert not any(replayed["effects"].values()) +def test_promoted_turn_completion_replays_after_commit_before_journal_crash( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, runtime, registry = _write_live_fixture(tmp_path) + _promote_turn_fixture(project, runtime) + host_project = tmp_path / "isolated-host-workspace" + host_project.mkdir() + host_script, validation_script = _completion_host_and_validation_scripts() + argv = _turn_run_once_completion_argv( + host_project, + runtime, + registry, + host_script, + validation_script, + ) + real_completion = turn_command.write_turn_validated_completion + committed_results: list[dict[str, object]] = [] + + def crash_after_canonical_completion(**kwargs: object) -> dict[str, object]: + result = real_completion(**kwargs) + committed_results.append(result) + if len(committed_results) == 1: + raise OSError("injected crash after canonical Todo commit") + return result + + monkeypatch.setattr( + turn_command, + "write_turn_validated_completion", + crash_after_canonical_completion, + ) + first_output = io.StringIO() + with contextlib.redirect_stdout(first_output): + first_exit_code = cli_main(argv) + first = json.loads(first_output.getvalue()) + + assert first_exit_code == 1, first + assert first["error"] == "injected crash after canonical Todo commit" + assert committed_results[0]["status"] == "done" + assert committed_results[0]["provider_status"] == "applied" + assert committed_results[0]["idempotent_replay"] is False + interrupted = _turn_journal(runtime) + assert interrupted["effect_attempts"]["durable_writeback"]["status"] == "prepared" + turn_key = str(interrupted["turn_key"]) + + resumed_output = io.StringIO() + with contextlib.redirect_stdout(resumed_output): + resumed_exit_code = cli_main( + [ + *argv[:-1], + "--resume-turn-key", + turn_key, + "--execute", + ] + ) + resumed = json.loads(resumed_output.getvalue()) + + assert resumed_exit_code == 0, json.dumps(resumed, indent=2) + assert resumed["status"] == "committed" + assert resumed["effects"]["host_invoked"] is False + assert committed_results[1]["status"] == "done" + assert committed_results[1]["provider_status"] == "replayed" + assert committed_results[1]["idempotent_replay"] is True + assert _turn_journal(runtime)["writeback"]["completion"] == { + "todo_id": "todo_fixture0001", + "continuation": "active_goal", + } + event_path = ( + runtime + / "goals" + / "loopx-turn-fixture" + / "rollout-event-log.jsonl" + ) + events = [ + json.loads(line) + for line in event_path.read_text(encoding="utf-8").splitlines() + ] + assert sum( + event.get("event_kind") == "todo_complete" + and event.get("run_id") == turn_key + for event in events + ) == 1 + + def _completion_host_and_validation_scripts() -> tuple[str, str]: host_script = """ import json