diff --git a/.github/workflows/full-public-smokes.yml b/.github/workflows/full-public-smokes.yml index 587d151042..8b2e68dd92 100644 --- a/.github/workflows/full-public-smokes.yml +++ b/.github/workflows/full-public-smokes.yml @@ -95,7 +95,9 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zsh - name: Install smoke runtime dependencies - run: python -m pip install --disable-pip-version-check "jsonschema>=4.23,<5" + # Smokes use the declared test extra (including YAML workflow readers). + # Install this source for qualification outside the checkout as well. + run: python -m pip install --disable-pip-version-check --editable '.[test]' - name: Install locked TypeScript parser for semantic production checks run: npm ci --ignore-scripts diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 97d2250861..d5091ffcfb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -150,7 +150,7 @@ const capabilityCopy: Record> = { const fieldCopy: Record = { en: { new_goal_provider: { label: "New Goal storage provider", description: "File or SQLite; frozen at creation, independently of execution policy." }, - canonical_creation: { label: "Create canonical authority", description: "Future Goals only. Disabled retains the post-promotion target; failures require the original creation retry." }, + canonical_creation: { label: "Create canonical authority", description: "Required for new Goals in 2.0. An older disabled setting remains visible until you enable it; existing Goals require import." }, new_goal_handoff_mode: { label: "New Goal execution policy", description: "soft_claim or hard_lease, used with canonical creation. Agents inherit the Goal policy; tool authority is unchanged." }, runtime_profile: { label: "Runtime profile", description: "Restricted keeps scoped LoopX reads only. Trusted owner enables normal host tools while protected operations retain separate checks." }, selection_policy: { label: "Selection policy", description: "Preferred allows an explicit user choice; pinned rejects another executor; flexible permits fallback only inside the eligible pool." }, @@ -185,7 +185,7 @@ const fieldCopy: Record = { }, "zh-CN": { new_goal_provider: { label: "新 Goal 的存储 provider", description: "选择 File 或 SQLite;创建时固定,与执行策略分别选择。" }, - canonical_creation: { label: "建立 canonical 权威存储", description: "仅影响此后新建的 Goal。关闭时仅固定晋升后的目标;失败须重试原创建操作。" }, + canonical_creation: { label: "建立 canonical 权威存储", description: "2.0 新建 Goal 必须启用。旧的关闭设置会保留显示,需自行启用;已有 Goal 需导入。" }, new_goal_handoff_mode: { label: "新 Goal 的执行策略", description: "canonical 创建使用 soft_claim 或 hard_lease。Agent 继承 Goal 策略;不授予工具权限。" }, runtime_profile: { label: "运行模式", description: "restricted 仅使用受限 LoopX 读取;trusted_owner 开放常规宿主工具,但受保护操作仍单独校验。" }, selection_policy: { label: "选择策略", description: "preferred 允许用户显式改选;pinned 拒绝其他执行器;flexible 只在已授权资源池内回退。" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index 283ee318bd..9018c9711b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -382,8 +382,8 @@ export function MachineConfigurationSettings({ section, onChanged }: { section:
{locale === "zh-CN" ? "仅影响此后创建的 Goal" : "Future Goals only"}

{locale === "zh-CN" - ? "启用 canonical 创建后,新 Goal 直接建立权威存储,并采用所选执行策略;失败时须重试原创建操作。关闭时仅固定晋升后的目标存储。已有 Goal 的升级仍需备份、停止写入和结算租约;这里不授予工具权限。" - : "With canonical creation enabled, new Goals establish authority with the selected execution policy; retry the original operation after failure. Disabled only freezes the post-promotion target. Existing Goals still require backup, stopped writers and settled leases for upgrade; this setting grants no tool permissions."}

+ ? "2.0 新建 Goal 必须启用 canonical 创建,并采用所选执行策略。旧的关闭设置不会自动改变;启用后再重试创建。已有 Goal 需备份、停止写入并导入;这里不迁移已有数据,也不授予工具权限。" + : "2.0 requires canonical creation with the selected execution policy. An older disabled setting is not changed automatically; enable it before retrying creation. Back up, stop writers and import existing Goals separately. This setting neither migrates existing data nor grants tool permissions."}

) : null} diff --git a/demo/auto_research/worker_runtime.py b/demo/auto_research/worker_runtime.py index 9937201e44..fe90a20eb5 100644 --- a/demo/auto_research/worker_runtime.py +++ b/demo/auto_research/worker_runtime.py @@ -34,6 +34,9 @@ normalize_todo_id, ) from loopx.control_plane.todos.handoff_mode import HANDOFF_MODE_HARD_LEASE +from loopx.control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, read_canonical_todos_if_promoted, +) from loopx.control_plane.work_items.task_lease import ( TaskLeaseError, acquire_task_lease, @@ -388,28 +391,38 @@ def _maybe_add_role_successor_todos( action=action, ) failure_continuation = decision_summary.get("failure_continuation") - if isinstance(failure_continuation, dict): - next_outcome = str(failure_continuation.get("next_outcome") or "") - successor_specs = [ - spec - for spec in successor_specs - if spec.get("action_kind") == next_outcome - ] text_source_todo_id = None if isinstance(failure_continuation, dict): - text_source_todo_id = str(failure_continuation.get("source_todo_id") or "") or None - return apply_role_successor_todos( - registry_path=registry_path, - goal_id=goal_id, - source_todo_id=source_todo_id, - current_agent_id=agent_id, - role_id=role_id, - action=action, - successor_specs=successor_specs, - decision_summary=decision_summary, - execute=execute, - text_source_todo_id=text_source_todo_id, - ) + next_outcome = str(failure_continuation.get("next_outcome") or "") + successor_specs = [spec for spec in successor_specs if spec.get("action_kind") == next_outcome] + source_todo_id = str(failure_continuation.get("source_todo_id") or "") or source_todo_id + text_source_todo_id = source_todo_id + def apply_binding(parent_id: str, *, revision: str | None = None) -> dict[str, object]: + return apply_role_successor_todos(registry_path=registry_path, goal_id=goal_id, + source_todo_id=parent_id, current_agent_id=agent_id, role_id=role_id, + action=action, successor_specs=successor_specs, decision_summary=decision_summary, + execute=execute, text_source_todo_id=text_source_todo_id, + expected_provider_revision=revision) + + try: + return apply_binding(source_todo_id) + except LocalCoordinationAuthorityUnavailable as error: + if error.code != 'todo_semantic_duplicate_conflict' or text_source_todo_id is None: + raise + # Native create owns normalized title/intent matching. Qualify only its + # exact conflicting record, without rewriting a historical parent. + snapshot = read_canonical_todos_if_promoted( + runtime_root=runtime_root_from_registry(registry_path, None), goal_id=goal_id, + ) + todos = {row['todo_id']: row for row in (snapshot or {}).get('todos', [])} + existing = todos.get(error.payload.get('todo_id')) + parent_id = existing.get('unblocks_todo_id') if existing else None + parent = todos.get(parent_id) + if not (parent and parent['role'] == 'agent' and parent.get('action_kind') in SUMMARY_ACTIONS + and parent.get('unblocks_todo_id') == text_source_todo_id): + raise + return apply_binding(parent_id, revision=snapshot['provider_revision']) + def _executed_successor_todo_ids(successor_todos: dict[str, object]) -> list[str]: diff --git a/demo/multi_agent/role_successor.py b/demo/multi_agent/role_successor.py index 5dd13cefac..4a054582a8 100644 --- a/demo/multi_agent/role_successor.py +++ b/demo/multi_agent/role_successor.py @@ -173,13 +173,13 @@ def apply_role_successor_todos( decision_summary: Mapping[str, object], execute: bool, text_source_todo_id: str | None = None, + expected_provider_revision: str | None = None, ) -> dict[str, object]: - """Preview or create role-declared successor todos through LoopX state. + """Create successors, retaining qualified historical source bindings. - When text_source_todo_id is provided, it is used for rendering the - successor text (so the title is stable across different summary todos - that review the same failure lineage). source_todo_id is still used - for the unblocks_todo_id binding. + Normally one source supplies both title and lineage. A caller recovering + an existing successor may preserve its original parent while rendering + the same title from the independently verified evidence lineage. """ specs = [dict(spec) for spec in successor_specs if isinstance(spec, Mapping)] @@ -257,6 +257,7 @@ def apply_role_successor_todos( action_kind=action_kind, claimed_by=claimed_by, unblocks_todo_id=source_todo_id, + expected_provider_revision=expected_provider_revision, dry_run=False, ) successors.append( diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index f21018f2fe..974b4da449 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -4,7 +4,7 @@ - Supersedes / closes: none - Proposed by: LoopX maintainers - Date: 2026-08-15 -- Last revised: 2026-10-07 +- Last revised: 2026-10-15 - Scope: an incremental, replacement-first migration of the LoopX control-plane core from Python to TypeScript without maintaining two semantic implementations @@ -127,6 +127,27 @@ The existing delivery Todos should carry these concrete exits: This updates the existing R5/T4 frontier and release proposal; it creates no parallel migration owner, runtime gate or release activation. +The retirement proposal is now reconciled through contributor-led #6396 and +its fork supplement #2. The [absorption map](../../reference/local-authority-provider-selection.md#retirement-proposal-reconciliation) +retains canonical ownership/terminal invariants, fixed 1.x receipt recovery, +reviewed App creation and the actual-contention mutation oracle from #6366. +The competing implementation stack is not an integration prerequisite; adopted +combined-head validation, core trial, real Host and D2/release exits stay open. + + +The same C1/T4 boundary now retires the live `legacy` ownership policy rather +than preserving a third execution strategy. The existing TS policy owner rejects +fresh claim/update/terminal/Monitor admission and execution fences; acquire and +lease maintenance share that rule. Current-proof readback and inspection cannot +turn a retained lease or original receipt into execution. Delegation consumes the +same typed support before launch/resume, including old no-lease-required records; +source inspection no longer carries a second Todo-admission projection. Historical mode decoding, +original-operation recovery, exact release and reviewed backup/plan/migrate remain. +Normal fixtures use soft/hard independently of their legacy/native wire shape; +explicit legacy-policy negatives and retained 1.x artifacts preserve that boundary. +No live Goal is changed by this proposal. Adopted combined-head qualification, +installed policy migration, real Host adoption and D2/release remain open. + Team-plan recovery prerequisite: an unpromoted plan that published its original receipt can now recover through the Chat confirmation owner while shadow bootstrap is interrupted. Registry/Todo/source locks still protect the read, 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 54d50bcefc..aac72285fc 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 @@ -4,7 +4,7 @@ - 替代 / 关闭:无 - Proposed by:LoopX maintainers - Date:2026-08-15 -- Last revised:2026-10-07 +- Last revised:2026-10-15 - Scope:LoopX 控制面核心从 Python 到 TypeScript 的增量、replacement-first 迁移;不长期维护两份语义实现 - Tracking issue:[#3225](https://github.com/loopx-project/loopx/issues/3225) @@ -71,6 +71,17 @@ actor/可运行状态/输入拒绝、精确 lineage 读回,以及当前输 隔离路径验证。新能力进入时持续守卫,不把历史库存当永久出口。最后受支持的 旧源 writer、历史恢复、安装 cohort 与发布验收各自保持开放。 +当前 C1/T4 退役提案由贡献者主导的 #6396 及 fork 补缺 #2 承接,#6366 已被替代; +[吸收清单](../../reference/local-authority-provider-selection.md#retirement-proposal-reconciliation) +保留 canonical ownership/终态不变量、固定 1.x 回执恢复、审核后的 App 创建及真实锁竞争 oracle。 +同一 TS 策略 owner 清理第三种 live legacy 执行策略:fresh claim/update/terminal、 +Monitor、执行 fence、acquire 及租约维护拒绝 legacy;当前 proof 读回和 inspect 也不能把 +保留租约或原回执变为执行资格。delegation 首次启动和恢复都消费同一 typed support, +包括旧“无需租约”记录;source inspect 不再二次解析 Todo 准入。历史 mode 解码、原操作恢复、精确 release 与有备份的 +plan/migrate 保留。正常 fixture 使用 soft/hard;legacy/native wire shape 与策略分开, +显式 legacy 拒绝和固定 1.x artifact 继续验证恢复边界。提案不自动修改活跃 Goal。 +采用后的 combined head 验证、安装态策略迁移、真实 Host 采用与 D2/发布保持开放。 + 保留的 import-compatible field writer 在既有 typed field plan 中统一等待条件 归一化及矛盾 clear 拒绝;保持 Python 空白输入省略和锁内 IO,只退役重复的 normalization crossing,不删除仍受支持的 writer 或迁移 reader。 diff --git a/docs/development/contributor-tasks-history.md b/docs/development/contributor-tasks-history.md index 195bec1394..387e6cfe0f 100644 --- a/docs/development/contributor-tasks-history.md +++ b/docs/development/contributor-tasks-history.md @@ -58,7 +58,7 @@ points: | Review quality | PR review now requires scope-fit evidence for production surface changes (#3090), the execution contract carries four self-dev review lenses (#3123), example-only PRs need durable smoke-value evidence (#3134), age-fair exact-head scheduling persists across restarts (#3317), durable projection ACK is required (#3744), and disproportionate changes are gated (#3731). | Add synthetic conformance and negative cases around scope-fit evidence, causal chains, exact-head review packets, durable projection ACKs, and durable-smoke-value claims. | | Task leases | Typed task-lease CLI with preserved legacy error codes landed (#3095); on-disk hard leases surface in goal-channel projection (#3039); Turn fencing uses lease fences plus an append-only journal, and the OpenCode 2 goal worker fences its own live worker lease. A task-lease generation ABA fix shipped (#3393), a Pi `loopx_task_lease` facade over the shipped `task_lease_v0` CLI merged (#3559, closing #3549), task-lease settlement cut over to TypeScript (#3674), and native task-lease acquire cut over too (#3702). | Adopt the same facade in one more real host integration (for example TraeX) or add a transfer/overlap-write-scope fixture. Keep soft-claim routing and undeclared-lease authority unchanged. | | Status, quota, monitors | Replan context is host-projected from the evidence ledger; two equivalent typed progress observations create an obligation, maintenance writes fail closed against the same full goal-frontier reducer used by quota, and an exact runnable-successor Todo carries the obligation-bound semantic receipt and turn boundary. Heartbeat todos survive capability reentry (#3321), Todo identity filtering and UTC ordering are corrected (#3311), unbound `/loopx` sessions inherit existing agent identity (#3315), declared validation gates run on user-role done updates (#3291, #3293), quota guards follow the selected Todo (#3506), and a bounded fallback action portfolio keeps quota decisions actionable (#3514). Guided start is bound to one turn (#3572), stale generated Next Actions rebind to the current todo (#3524), unknown workspace causality is repairable (#3519), malformed runtime recovery and settlement payloads fail closed (#3525), managed and queued turn creation are serialized (#3542, #3562), and guided selection packet consumption is explicit (#3707). Post-v0.5.3, `todo list` gained a bounded thin projection with an output-budget cap (#3679), the dashboard projects completed Todos with done-count run progress (#3689), guided-todo onboarding delta coverage shipped (#3716), agent-lane Next Action selection prefers the selected Todo over shared prose (#3693), typed blocker outcomes settle (#3734), Turn receipt priority wins in compact output (#3726), and CLI common command owners load lazily (#3717). Manual evidence reads, prose ACKs, and historical repair-delta claims are diagnostic only. Compact scheduler-hint and heartbeat-prompt budgets plus todo-detail cold paths remain the reference. | Extend one measured performance, detail-readback, lock-timeout, malformed-state, typed progress, or obligation-bound semantic-transition case. Keep default output bounded and cold-path detail available. | -| Governance and productization | A synthetic visible-governance slice landed (#3086) and its Stage-2 proposal-vs-shipped refresh keeps additive `claim_work` / coordination-head truth plus a lease-as-fence negative explicit; decision-context evidence cursors settle (#3079); the React homepage rebuild (#3098) landed; a deterministic project registry (#3170) serializes global sync; per-goal handoff mode gates claim/lease authority (#3164) and hard-lease gates auto-acquire completion keys (#3198); a repository change-window gate and pending ledger ship (#3319); goal channels default to human-gate auto-notify on new channels (#3523); coordination state rules are centralized (#3410) and the Stage 2 slice ships an aggregate head, file provider, and `claim_work` executor (#3529); project repository delivery lands through capability hooks (#3570) with gitless delivery workspaces settled (#3574) and CPA/provider-routing qualification recorded (#3576, #3573, #3563); a read-only stride shadow observation M1 (#3207), a synthetic stride-boundary shadow fixture (#3290), and the hierarchical stride RFC (#3204) open the next boundary; goal-artifact lifecycle projection (#3136) proposes a read-model boundary and post-outcome memory utility attribution from RFC #3215 is implemented (#3280). Post-v0.5.3, periodic-report post-writeback hooks ship (#3691) with terminal Todo closeout dispatch (#3748), pending-intent consumption (#3749), Chinese-analysis editorial requirement (#3750), rejected-draft reopen (#3751), actual work interval (#3754), approved delivery bound to the Goal Channel Bot (#3755), and normalized card readback (#3757); the dashboard retains startup error context (#3745), keeps the default status source same-origin (#3732), and avoids an unbound lark-cli variable (#3738); doctor validates deep checks by install kind (#3736); Frontstage Pages PR validation is isolated (#3728); Luna ships as a bounded account-ring extension (#3737); and structured outbound mentions are verified (#3741). | Add one synthetic lifecycle or stride-boundary fixture, extend the visible-governance walkthrough with a missing negative case, or characterize the shipped `claim_work` executor with a file-backed parity fixture. Keep activation explicit and leave source bodies, draft bodies, review text, provider payloads, private locators, cursor state, and apply/publish authority outside public fixtures. | +| Governance and productization | A synthetic visible-governance slice landed (#3086) and received a Stage-2 refresh (#3657); the isolated prototype and its sole smoke are now retired in the 2.0 candidate, while canonical Goal Channel/status ownership and lease coverage remain with their existing typed owner; decision-context evidence cursors settle (#3079); the React homepage rebuild (#3098) landed; a deterministic project registry (#3170) serializes global sync; per-goal handoff mode gates claim/lease authority (#3164) and hard-lease gates auto-acquire completion keys (#3198); a repository change-window gate and pending ledger ship (#3319); goal channels default to human-gate auto-notify on new channels (#3523); coordination state rules are centralized (#3410) and the Stage 2 slice ships an aggregate head, file provider, and `claim_work` executor (#3529); project repository delivery lands through capability hooks (#3570) with gitless delivery workspaces settled (#3574) and CPA/provider-routing qualification recorded (#3576, #3573, #3563); a read-only stride shadow observation M1 (#3207), a synthetic stride-boundary shadow fixture (#3290), and the hierarchical stride RFC (#3204) open the next boundary; goal-artifact lifecycle projection (#3136) proposes a read-model boundary and post-outcome memory utility attribution from RFC #3215 is implemented (#3280). Post-v0.5.3, periodic-report post-writeback hooks ship (#3691) with terminal Todo closeout dispatch (#3748), pending-intent consumption (#3749), Chinese-analysis editorial requirement (#3750), rejected-draft reopen (#3751), actual work interval (#3754), approved delivery bound to the Goal Channel Bot (#3755), and normalized card readback (#3757); the dashboard retains startup error context (#3745), keeps the default status source same-origin (#3732), and avoids an unbound lark-cli variable (#3738); doctor validates deep checks by install kind (#3736); Frontstage Pages PR validation is isolated (#3728); Luna ships as a bounded account-ring extension (#3737); and structured outbound mentions are verified (#3741). | Extend the existing canonical Goal Channel/status owner only for demonstrated gaps; retain lease, unavailable-provider and recovery coverage in the existing canonical coordination and task-lease tests. Do not restore the retired visible-governance or `claim_work` prototype. Keep activation explicit and leave source bodies, draft bodies, review text, provider payloads, private locators, cursor state, and apply/publish authority outside public fixtures. | | Security boundaries | Four merged hardening fixes contain state-file override writes (#3140), reject shell metacharacters in launcher worker commands (#3139), validate `goal_id` in reward routes (#3138), and stop ACAO:* on unauthenticated status reads (#3137). Loopback CORS, worker-command charset, and state-file symlink containment are pinned as regression tests (#3340). GH-C90 audited the four shipped negative fixtures and added one durable mutation per boundary: path-prefix sibling containment, worker-command input redirect, absolute `goal_id` path segments, and `file://localhost` ACAO rejection (`tests/test_state_file_containment.py`, `tests/test_worker_command_validation.py`, `tests/test_feedback_goal_id_validation.py`, `tests/test_status_server_cors.py`), completing the GH-C90 slice (#3655, closing #3636). | Keep credentials, private reproduction details, and advisory coordination out of public fixtures; extend only when a new shipped boundary lacks a distinct fail-closed mutation. | | Runtime connectors and content workflows | DeepSeek Harness Turn adapter with real e2e smoke landed (#3188); OpenCode 1 and OpenCode 2 continuous goal loops ship (#3151); content-ops gained a layout template library and dense-cover defaults (#3222, #3223); provider-neutral PR program snapshots ship (#2814); `computer_use_runtime_v0` is now a machine-checkable protocol contract (#3279); a `loopx-community-discussion` public source provider (#3299) and a `loopx-repo-health` provider (#3272) extend public source coverage; goal-channel botmux runtime integration lands with terminal dispatches preserved and uncertain dispatches persisted, desktop chat routes through a loopback service, chat-wide routing and contextual inbox replies land (#3555), and managed and queued turn creation are serialized (#3542, #3562); the desktop added workspace language settings, localized write previews, and preserved heartbeat schedule and off-hours semantics (#3594); host parity, skill-delivery, and observable-handle thin pytest now cover the expanded host list. Post-v0.5.3, bot document comment scopes read from the published app (#3722), turn-start reaction replay is bounded (#3733), and the DSH plugin is one-step ready (#3725). | Add one provider-neutral parity slice for DeepSeek Harness, OpenCode 1/2, goal-channel botmux, content-ops delivery/readback, or desktop locale parity; keep raw transcripts, provider payloads, credentials, and host-local paths out of fixtures. | | Benchmark boundary | Benchmark research was reset around native runners (#3267): native Codex Goal connects to the real runtime (#3271), workers are isolated from the host (#3277), provider env binds to installed Goal profiles (#3276) with formally verified treatments (#3275) and live continuations (#3273), runtime evidence binds to the exact container (#3303), a post-run case analyst brief ships (#3289), an integrity qualification toolkit landed (#3241), source-env usage is distinguished from credential probes (#3298, #3278), and accountable closeout requires Todo validation (#3229). A provider-neutral four-arm study contract ships (#3516), alongside restricted task-source access (#3504), non-http git clones classified as network with loopback integrity probes allowed (#3510), namespaced public case ids (#3532), locked git-clone integrity boundaries (#3515), orchestrator runtime provenance, and fail-closed runtime closeout drift (#3503). Deterministic fixtures already cover GH-C99 without live scoring or uploads: `tests/capabilities/test_benchmark_four_arm_contract.py`, `tests/capabilities/test_benchmark_experiment_board.py` (orchestrator runtime provenance negatives), `tests/capabilities/test_benchmark_runtime_continuity.py` (fail-closed closeout drift), and integrity/network/task-source cases in `tests/capabilities/test_benchmark_toolkit.py`; the covered GH-C99 fixture task was retired (#3653). Public native Goal trajectory summaries now derive from compact lifecycle facts without retaining raw artifacts (#3327), completing the GH-C16 slice. Post-v0.5.3, an external agent phase ships (#3565), restricted-access suspicion adjudicates by causal use (#3747), the plan-role fidelity gate is removed (#3753), and retired fidelity references are cleaned up (#3756). Shared lifecycle, readiness, ledger, and reducer contracts remain the public seam; generic Effect Program conformance and replay tests harden settlement infrastructure but do not change benchmark scoring or authorize live runs. Live scored comparisons stay held until a fresh task-free runner lifecycle receipt proves readiness. | Extend synthetic setup/termination attribution, derive the public trajectory summary for a second non-SkillsBench adapter, or add a SWE adapter only when a second SWE route needs shared launch/observe/ingest behavior. Do not launch scoring, duplicate the controller, or expose raw task text, logs, trajectories, verifier tails, credentials, uploads, or local paths. | diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index f4b803e45c..33768adf7d 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -862,6 +862,15 @@ path, and canary route rather than as a user-facing release baseline. SQLite qualification remain unqualified. No paired-version benchmark uplift or isolated Astra causal effect is claimed. +- `v1.4.1` on 2026-10-15 16:50 +08:00: clearer Goal workspace and result + adoption, explicit research decisions, and original-operation recovery at + frozen commit `4f27801c4e26fd51246d352eada72c98f8e7e657`. The + [published release](https://github.com/loopx-project/loopx/releases/tag/v1.4.1) + records bilingual operating instructions, community contributions and + source/artifact validation limits. Existing Goals retain their recorded + policy and require explicit migration; canonical-only retirement remains + a separate 2.0 candidate. + When a new public release is promoted, add it here only after the matching tag, release note, stable ref, update path, and focused release canary agree. diff --git a/docs/reference/canonical-todo-completion-update.md b/docs/reference/canonical-todo-completion-update.md index 8adff406fd..334a5b985d 100644 --- a/docs/reference/canonical-todo-completion-update.md +++ b/docs/reference/canonical-todo-completion-update.md @@ -530,6 +530,36 @@ Markdown 投影、备份读取及活跃 Host IO 保留。真实 File/SQLite CLI wheel 路径验证完成与结算恢复;Markdown writer 和 shadow capture producer 已 退役。代码回滚不需要转换数据或重写回执。 +### Repairing imported completion metadata / 修复导入的完成元数据 + +An imported completed Todo may lack `completion_continuation`. Use an authorized +Actor and its original completion identity to preview and apply the ordinary completion +command without new note, evidence, successor, policy or validation intent. +For a record without a stored Turn identity: + +```sh +loopx todo complete --goal-id example --todo-id todo_imported --agent-id agent-a --dry-run +loopx todo complete --goal-id example --todo-id todo_imported --agent-id agent-a +loopx todo list --goal-id example --todo-id todo_imported +``` + +A stored identity requires the original Turn tuple and any required execution +proof; do not replace it with a new identity. The TypeScript owner accepts the +ordinary transport's read-only policy facts, including an explicit `--claimed-by` +that matches the stored claim, but applies no policy and acquires no lease during +this repair. A different claim is rejected. It preserves identity, completion time, content, +claim and original receipt. Preview/refusal has no effects; retrying the same +operation preserves newer writes. Fresh edits require their own authorized +operation after repair. + +导入的已完成 Todo 若缺少 `completion_continuation`,使用已授权 Actor、原完成身份和所需 +执行证明,通过普通完成命令预览并修复;不夹带新的 note、evidence、successor、 +policy 或验证意图。上例适用于未存储 Turn 身份的记录;已有身份必须提供原 tuple, +不能替换身份。TS owner 接受普通入口携带的只读 policy 事实,包括与已存 claim 一致的 +显式 `--claimed-by`;不同 claim 会被拒绝。修复不应用 policy、 +不领取租约,并保留原 ID、完成时间、内容、claim 与回执。预览和拒绝无副作用; +原操作重试保留后写。后续编辑仍须单独通过原授权入口。 + ## 2.0 canonical mutation boundary Create, update, complete, supersede and archive share diff --git a/docs/reference/handoff-mode.md b/docs/reference/handoff-mode.md index e3ba872707..b58e75a0e7 100644 --- a/docs/reference/handoff-mode.md +++ b/docs/reference/handoff-mode.md @@ -3,8 +3,17 @@ `soft_claim` uses the Todo assignment; `hard_lease` also requires the existing execution lease fences. New `set` and migration intents accept these two modes. `legacy` remains a historical source for backed-up upgrades and original receipt -recovery; new requests cannot select it, even as a no-op. Existing Goals are not -silently changed, and creation/Host defaults remain unchanged in this stage. +recovery; new requests cannot select it, even as a no-op. In the 2.0 candidate, +fresh claim/update/terminal and Monitor admission, execution fences, acquisition +and lease maintenance reject `legacy` with `handoff_mode_retired`. Inspection +keeps the original lease record but reports it ineffective; an original acquire +receipt cannot be presented as current execution proof. Exact release and original +receipt recovery remain available. Delegation checks the typed inspection's +`execution_policy.supported` before first launch and resuming a running operation, +including retained records declaring that no lease was needed. This readback grants +no execution authority; hard mode still needs its original claim/lease proof. +Source-only lease inspection is historical: it cannot report an effective execution +and no longer asks Python for a second full Todo projection. Existing Goals are not silently changed, and creation/Host defaults remain unchanged in this stage. This is an ownership policy, not a capability grant or a storage provider. The App's Task list and Task details use the same reviewed completion entry. @@ -40,9 +49,9 @@ loopx --format json handoff-mode set --goal-id example-goal --mode soft_claim -- loopx --format json handoff-mode set --goal-id example-goal --mode soft_claim --operation-id mode-change-1 ``` -Before promotion, `show`/`set` use the existing frontmatter adapter and locks. -After promotion they use the selected canonical provider, even when Markdown -is stale or absent. Provider failure rejects the operation without fallback; +Before import, `show` can inspect historical frontmatter; normal `set` requires +canonical authority. Canonical operations use the selected provider even when +Markdown is stale or absent. Provider failure rejects the operation without fallback; leftover lease files cannot override canonical records. Only frontmatter and compact ownership facts cross the legacy TS planning boundary; unrelated body, metadata, line endings and the final newline remain intact. @@ -50,9 +59,9 @@ metadata, line endings and the final newline remain intact. A changed `set` requires no unfinished claimed active Todo and no time-active lease in the complete snapshot. Invalid expiry/schema cannot prove quiescence; concurrent work invalidates its CAS. Identical soft/hard mode is a no-op even -with active work. Malformed legacy frontmatter can be repaired only when -quiescent; duplicate fields or missing frontmatter reject, and malformed -canonical state is never repaired through the legacy adapter. +with active work. Historical malformed frontmatter must be resolved through the reviewed import +boundary; it cannot create a normal writer fallback. Malformed canonical state +is never repaired through that adapter. Canonical `--operation-id` binds Goal and target. Even an accepted no-op seals a receipt. Retry with the same ID/target recovers the original decision and never @@ -61,8 +70,8 @@ restores an old mode over later work; changed intent needs a fresh ID. Malformed receipts reject. An original `set --mode legacy --operation-id ` request still reaches receipt recovery after upgrade, including after a later policy change. New legacy writes and dry-runs return `handoff_mode_retired`; -`plan-migration` also refuses legacy as a target. The unpromoted writer does not -promise durable operation replay. +`plan-migration` also refuses legacy as a target. An unpromoted normal write returns `canonical_authority_required`; use the +reviewed import before changing current policy. ## Migrate an assigned canonical Goal with a verified backup @@ -127,16 +136,18 @@ unretained preview. CLI and App share registry validation and the same TS migration owner. Lark ordinary Todo/lease actions consume that canonical state; policy migration has no Lark editor in this stage. The capability editor does not own this policy. Migration does not -qualify provider defaults, D1–D3, or PostgreSQL deployment. Complete Host lease -lifecycles and migration of remaining legacy Goals precede legacy execution and -last-caller Python retirement; historical import/receipt readers stay at their -migration/recovery boundary. +qualify provider defaults, D1–D3, or PostgreSQL deployment. Complete Host lease lifecycles, reviewed migration of remaining Goals and installed +adoption remain release qualifications. The candidate's legacy execution cutoff +is not evidence that those qualifications passed. Historical import/receipt +readers stay at their migration/recovery boundary. ## 中文 新增 `set` 和迁移只接受 `soft_claim`、`hard_lease`。legacy 仍可作为升级来源、 -历史回执恢复输入,不能再作为新请求的目标;已有 Goal 和创建/Host 默认值不被 -静默改写。soft 用于明确的单执行者,hard 用于本机并行 Agent/automation 和云端 +历史回执恢复输入,不能再作为新请求的目标;已有 Goal 和创建/Host 默认值不被静默改写。2.0 候选的 fresh +claim/update/terminal、Monitor、执行 fence 及租约执行准入明确拒绝 legacy; +inspect 保留原记录但报告无有效执行,旧 acquire 回执也不能换成当前 proof。 +精确 CAS release、历史原操作恢复及有备份的审核迁移继续可用。soft 用于明确的单执行者,hard 用于本机并行 Agent/automation 和云端 共享;默认策略切换前还需验证 Host 的 acquire、renew、release 和阻塞结算。 App 的任务列表和详情共用审核完成入口。终态 owner 要求租约或当前执行证明时, 页面明确说明此调用方无法代为完成;负责 Agent 须从原执行提交当前证明,之后刷新 @@ -178,15 +189,17 @@ App 在 **Goal 设置 → 任务所有权** 复用同一操作:选择协作认 CLI 与 App 共用注册信息校验和 TS 迁移 owner;飞书普通操作消费相同 canonical 状态, 本阶段没有飞书策略编辑器,也不新建 capability 设置项。此批不关闭 provider 默认与 -D1–D3;先完成 Host 生命周期、 -逐 Goal 备份迁移,再删除 legacy 执行分支和无调用方的 Python 业务逻辑。旧格式解析 -只保留在迁移/历史回执恢复边界。 +D1–D3;候选已拒绝 legacy 正常执行,并不证明 Host 生命周期、逐 Goal 备份迁移 +及安装采用已经完成。旧格式解析只保留在迁移/历史回执恢复边界。 ## Recover a canonical Todo edit with retained lease history -A canonical Todo can retain a released or expired lease even in `legacy` mode. -In `legacy` and `hard_lease`, that record preserves execution lineage: `todo update` -still requires a current active owner proof. In `soft_claim`, an open agent Todo's +A canonical Todo can retain a released or expired lease in historical `legacy` +mode. That mode requires a reviewed migration before normal work, even with a +retained active proof. Release the exact old execution first, then use the existing +backup/plan/migrate flow; never acquire another legacy execution. In `hard_lease`, +the retained record preserves execution lineage: `todo update` requires current +active owner proof. In `soft_claim`, an open agent Todo's current claim owner may edit text/notes or clear those fields without proof when its retained lease is released and held by the same owner. The transaction keeps the lease, epoch and historical receipts unchanged and still checks actor, @@ -202,6 +215,7 @@ under a fresh operation and current revision. Other execution fences remain: | Observation | Recovery | | --- | --- | +| Historical `legacy` policy, with or without a retained lease | Release any retained execution using its exact CAS, then review and back up a migration to `soft_claim` or `hard_lease`. Historical acceptance grants no current execution. | | Active lease held by the current claim owner; missing/stale proof | Inspect the lease and retry using its current proof. Do not acquire a competing execution. | | Released/expired lease; current owner is eligible and acquisition passes the current mode, acceptance and scope checks | Inspect the version, acquire a short lease with a fresh key, update using the returned proof, then release it. | | Active foreign holder, divergent claim, or absent claim | Reconcile ownership through its lifecycle; never borrow another holder's proof. | @@ -227,7 +241,8 @@ todo_done: --reason ''` 暂停。此显式 wait The recovery descriptor uses the standalone `loopx task-lease acquire` command. Combined `todo claim --task-lease-idempotency-key` is restricted to `hard_lease` -and is not the recovery route for `legacy`. Acquire uses `--owner`, a **fresh** +and is unavailable in historical `legacy` mode. After reviewed migration, +acquire uses `--owner`, a **fresh** `--idempotency-key`, `--expected-version` from `task-lease inspect`, a bounded `--ttl-seconds`, and any projected `--write-scope` values. Retry the original update with `--task-lease-idempotency-key` and `--task-lease-expected-version` @@ -241,8 +256,12 @@ the configured mode, promote a provider, or waive acceptance. ### 保留租约历史时的更新恢复 -canonical Todo 在 `legacy` 模式下也可能保留 released/expired lease。legacy 与 -hard_lease 仍要求当前有效 owner proof。soft_claim 下,open Agent Todo 的当前 +历史 `legacy` policy 即使保留 active lease,也不能执行新的 Todo mutation 或借原回执 +恢复当前执行资格。先按原 key/version 精确 release,再通过既有 plan/backup/migrate +审核升级到 soft_claim 或 hard_lease;不改写旧回执、不丢弃 lease history,也不自动迁移活跃 Goal。 + +canonical Todo 在历史 `legacy` 模式下也可能保留 released/expired lease, +须先审核迁移;hard_lease 仍要求当前有效 owner proof。soft_claim 下,open Agent Todo 的当前 claim owner 可不带 proof 编辑文本/备注或清空这些字段,前提是保留的 lease 已释放 且同属本人。事务保留 lease、epoch 和原回执,仍核验 actor、excluded、bound 和 provider CAS;这不授权 ownership、planning、工作要求或完成变更。未释放的 active @@ -253,8 +272,8 @@ lease 即使自然过期,也不适用。显式旧 proof 或部分 proof 仍拒 有效的本主租约应 inspect 后使用当前 proof 重试;已释放或过期的租约,只有当前 claim owner 满足相同的 acquire 准入规则时,才提示“inspect version → 用新 key -申请短 lease → 带新 proof 更新 → release”。legacy 必须用独立 `task-lease acquire`, -不能用仅限 hard_lease 的合并式 claim+lease。 +申请短 lease → 带新 proof 更新 → release”。legacy 下独立 acquire 与合并式 +claim+lease 均不可执行,必须先审核迁移;合并式 claim+lease 仍仅限 hard_lease。 异主有效 lease、claim 不一致、不允许执行的 Todo、验收阻塞或 scope 冲突不会得到不可执行的 acquire 建议;修改已租用工作的要求或状态须走对应 lifecycle。 diff --git a/docs/reference/local-authority-provider-selection.md b/docs/reference/local-authority-provider-selection.md index 445144f1c4..ff8b7d06d2 100644 --- a/docs/reference/local-authority-provider-selection.md +++ b/docs/reference/local-authority-provider-selection.md @@ -186,8 +186,9 @@ layout or responsive navigation is introduced. -To disable future canonical creation, preview and apply the same v1 document -with `canonical_creation=false`. Removing the preference restores the candidate +In 2.0, `canonical_creation=false` preserves an inspectable preference but +refuses new Goal creation. Enable canonical creation and review the preview +before retrying. Removing the preference restores the candidate SQLite/hard-lease default for future new Goals; removal is **not** opt-out. To remove the whole preference: @@ -247,7 +248,8 @@ CLI 使用上面的完整 JSON 和 preview/apply/inspect/bootstrap 命令;App 后的存储丢失须恢复完整备份,不能重新创建空库;通用 force 不能重建。原回执在 后续写入后仍可读回,重试不得丢失或重复 Todo。 -显式设置 `canonical_creation=false` 才关闭之后的新建;remove 删除偏好会恢复候选 +2.0 中,显式 `canonical_creation=false` 保留可查看的偏好,但拒绝之后的新建; +须启用并审核配置,再重试创建。remove 删除偏好会恢复候选 SQLite/hard-lease 默认,不能用于关闭。两者均不会迁回已有数据、 删除 fence 或重新开放旧 writer。既有 Goal 升级仍需备份、停止写入、结算租约和 审核计划;反向迁移须保留新增写入。受支持的旧备份、格式和原回执恢复能力保留。 @@ -319,7 +321,7 @@ every Goal and not removal of every Python module: | --- | --- | | Unpromoted Markdown authority | Retain inspection and explicit upgrade/recovery; reject normal mutation before any write or new execution grant, with an actionable supported migration route. Do not silently initialize an empty canonical store. | | Canonical File or SQLite | Keep the recorded provider and either supported `soft_claim` or `hard_lease` policy. New unconfigured Goals default to SQLite; existing File Goals do not have to switch. Missing providers remain errors. | -| Canonical store with `legacy` ownership policy | Review a claim-preserving policy migration to `soft_claim` or `hard_lease` before normal execution. Changing policy does not require promoting the store again or switching its provider. | +| Canonical store with `legacy` ownership policy | Normal ownership mutations and execution proofs return `handoff_mode_retired`; old receipt recovery and exact release remain available. Review a claim-preserving policy migration to `soft_claim` or `hard_lease`. This does not require another storage promotion or provider switch. | | Target-only v0 or v1 creation disabled | Keep decoding for inspection and reviewed configuration upgrade. Require an explicit compatible creation choice before creating a writable Goal; do not erase the preference or reinterpret disabled as enabled. | | Historical ownership inputs, source captures and backups | Recognize them in declared migration/recovery contracts. Historical leases, receipts and a restored source do not grant new execution authority. Preserve supported original-operation recovery and permanent Markdown display. | @@ -529,6 +531,40 @@ D2 capacity/soak qualification; missing or failed D2 evidence stays visible. A microbenchmark failure alone does not block an unrelated, proven internal deletion, and passing those deletions does not certify the release default. +### Retirement proposal reconciliation + +The contributor-led [#6396](https://github.com/loopx-project/loopx/pull/6396) +and [supplement #2](https://github.com/Duang777/loopx/pull/2) carry the useful +contracts from [#6366](https://github.com/loopx-project/loopx/pull/6366). +Reconciliation follows supported behavior, rather than preserving every old +module, response alias or test filename: + +| Contract | Current owner and regression evidence | +| --- | --- | +| Normal writer cutoff and deletion | Canonical mutation/creation/Monitor adapters; cold-source import and writer-isolation tests. Markdown mutation producers stay retired. | +| Imported completion, succession and policy repair | Typed terminal/update owners; shared provider conformance and actual CLI consumer smokes in the supplement. | +| Claim/lease ownership and atomic completion | `test_canonical_handoff_invariants.py`, canonical mutation/claim-transfer tests and provider conformance. Metadata cannot implement the retired two-write handoff. | +| Original-operation recovery | Fixed 1.x lease and team-plan artifacts guard exact replay, prepared matching effects, original release, lost close response and preservation of later state. Fresh legacy verification/grants remain retired. | +| Current ownership policy | Only `soft_claim` and `hard_lease` admit current work. Imported `legacy` records and original receipts cannot grant execution; reviewed backup/plan/migrate and exact release remain available. | +| Retired lease helpers | Delete the unused Python lease writer/factory, generation/CAS helpers, owner-validation chain, and source Todo projection. The typed transaction remains the decision owner; retained JSON reading stays read-only. Release corruption/CAS assertions run through the actual native adapter with fixed 1.x records. | +| Reviewed App creation and configuration | Chat binds the accepted operation to the reviewed provider revision and Todo identity; stale preview rejects, corrected preview can retry. Settings preserve a disabled preference and explain the required upgrade. | +| Mutation-oracle strength and packaging | The fence oracle observes actual exclusive-open contention, including delayed startup. Existing prerelease bundle and installed-package checks retain their independent scope. | + +The absorption does not qualify a release, contributor adoption, sustained D2 +operation or an unobserved Host. Full current-head CI and the combined adopted +candidate retain their own acceptance. Historical output aliases and tests +whose only subject was a deleted producer are superseded by the typed current +contract and its supported recovery tests. + +Retirement removes internal Python helper exports with no shipped caller or +registered import contract; it does not remove CLI/API lease operations, the +historical wire vocabulary or receipt decoding. Test fixtures describe source +records directly instead of keeping a production writer solely to construct +fixtures. Owner eligibility remains covered at its typed owner and by real +canonical claim/lease transactions. Historical generation checks belong to +native recovery/release, including corrupt boolean generations and unchanged +bytes after rejection. + ### 2.0 退役边界 正常写入现在只接受 canonical authority;旧 writer、producer 和无调用内部桥接 diff --git a/docs/reference/protocols/auto-research-role-profile-v0.md b/docs/reference/protocols/auto-research-role-profile-v0.md index 4d17f3d005..38566eb70e 100644 --- a/docs/reference/protocols/auto-research-role-profile-v0.md +++ b/docs/reference/protocols/auto-research-role-profile-v0.md @@ -105,6 +105,20 @@ public/private boundary checks before another agent can run it. target is not met and a role-declared successor condition is satisfied, the worker must create or link that successor instead of closing the lane. +For failure continuation, the successor's title and `unblocks_todo_id` share +the failed evidence Todo from `research_failure_continuation_v0.source_todo_id`. +Several summaries of that lineage therefore create or reuse one identical +successor without rebinding its original parent. A historical successor may +retain its original summary parent when the complete canonical records prove +that summary belongs to the same failed evidence lineage; all other intent +fields still pass the native duplicate check. Each completed summary keeps +its own `successor_todo_ids` link to that successor. Other role handoffs still +bind the selected source Todo. These links grant no execution or claim rights; +the canonical owner continues to reject same-title requests with different +lineage or other intent. Historical-parent reuse binds create to the inspected +provider revision; a concurrent change refuses without rebinding and requires +a fresh read before retry. + ## Resolution Order Every visible auto-research worker should resolve its instructions in this diff --git a/docs/reference/protocols/quota-monitor-observation-receipt-v0.md b/docs/reference/protocols/quota-monitor-observation-receipt-v0.md index c93d5f0443..395f10e124 100644 --- a/docs/reference/protocols/quota-monitor-observation-receipt-v0.md +++ b/docs/reference/protocols/quota-monitor-observation-receipt-v0.md @@ -217,8 +217,19 @@ lease proof. Before downgrading to a version without this protocol, finish v1 pending settlements; older binaries cannot interpret them. Never disable a writer fence to recover by writing an older Markdown projection. -The bounded real-source rehearsal clones read-only source state and adds only -synthetic records in disposable runtimes. It requires an isolated PostgreSQL URL: +The bounded source rehearsal reads a stable Markdown snapshot, including +unreferenced archives, and adds synthetic records only in disposable runtimes. +Use a baseline that supports the same authority format and Monitor reactivation; +all four arms must preserve the full source records and reach identical heads. +The disposable cases use `soft_claim` for observation and `hard_lease` for +execution. These fixture transitions do not qualify policy migration, whole-Goal +recovery, or the contents of a selected canonical store. It requires an isolated +PostgreSQL URL: + +有界演练读取稳定的 Markdown 快照,保留无依赖关系的完整归档;仅在临时运行目录添加 +合成记录。基线须支持相同的权威存储格式及 Monitor 重启语义,四组最终状态必须一致。 +观察使用 `soft_claim`,执行使用 `hard_lease`;样本内切换不证明实际策略迁移、完整 +Goal 恢复或已选 canonical 存储的内容验收。 ```bash LOOPX_TEST_POSTGRES_URL="$DISPOSABLE_POSTGRES_URL" \ diff --git a/examples/auto-research-worker-turn-smoke.py b/examples/auto-research-worker-turn-smoke.py index b6bf588022..650631c766 100644 --- a/examples/auto-research-worker-turn-smoke.py +++ b/examples/auto-research-worker-turn-smoke.py @@ -33,6 +33,11 @@ apply_role_successor_todos, ) from loopx.todos import add_goal_todo, complete_goal_todo # noqa: E402 +from loopx.control_plane.coordination.local_authority import ( # noqa: E402 + LocalCoordinationAuthorityUnavailable, + read_canonical_todos_if_promoted, +) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 from loopx.control_plane.work_items.task_lease import ( # noqa: E402 TaskLeaseError, acquire_task_lease, @@ -173,7 +178,9 @@ def write_summary_state(path: Path) -> None: ) -def write_registry(path: Path, *, project: Path, state_file: Path, runtime_root: Path) -> None: +def write_registry( + path: Path, *, project: Path, state_file: Path, runtime_root: Path, provider: str, +) -> None: path.write_text( json.dumps( { @@ -207,6 +214,7 @@ def write_registry(path: Path, *, project: Path, state_file: Path, runtime_root: + "\n", encoding="utf-8", ) + import_fixture_authority(path, GOAL_ID, provider=provider) def append_real_fixture_evidence(*, registry: Path, runtime_root: Path, temp: Path) -> None: @@ -317,7 +325,7 @@ def append_negative_fixture_evidence( assert_public_safe(appended) -def main() -> int: +def assert_seeded_worker_turns() -> None: with tempfile.TemporaryDirectory() as temp_dir: temp = Path(temp_dir) supervisor = build_auto_research_demo_supervisor_plan( @@ -533,7 +541,7 @@ def main() -> int: successors = seeded_failure_turn["successor_todos"]["successors"] assert len(successors) == 1, seeded_failure_turn assert successors[0]["action_kind"] == "propose_failure_successor", seeded_failure_turn - assert successors[0]["unblocks_todo_id"] == evaluator_seed["todo_id"], seeded_failure_turn + assert successors[0]["unblocks_todo_id"] == executor_seed["todo_id"], seeded_failure_turn assert seeded_failure_turn["completion"]["successor_todo_ids"] == [ successors[0]["todo_id"] ], seeded_failure_turn @@ -562,6 +570,9 @@ def main() -> int: ) assert manual_lease["lease"] is None, manual_lease + + +def assert_evidence_lineage(provider: str) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp = Path(temp_dir) project = temp / "project" @@ -570,7 +581,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) summary_todo = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -618,6 +629,12 @@ def main() -> int: for successor_id in successor_ids: assert successor_id in state_text, state_text assert_public_safe(evaluator_summary) + canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) + by_id = {row["todo_id"]: row for row in canonical["todos"]} + assert by_id[summary_todo_id]["status"] == "done", canonical + assert by_id[summary_todo_id]["claimed_by"] == EVALUATOR_AGENT_ID + assert by_id[successor_ids[0]]["claimed_by"] == CURATOR_AGENT_ID + assert by_id[successor_ids[0]]["unblocks_todo_id"] == summary_todo_id with tempfile.TemporaryDirectory() as temp_dir: temp = Path(temp_dir) @@ -627,7 +644,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) promotion_todo = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -732,7 +749,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) first_failure_evidence = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -808,7 +825,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) retry_evidence = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -892,7 +909,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) self_referential_summary = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -948,7 +965,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) positive_evidence = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -1069,7 +1086,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) failure_evidence = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -1122,7 +1139,7 @@ def main() -> int: failure_turn["frontier"]["frontier"]["selected"]["unblocks_todo_id"] == failure_evidence["todo_id"] ), failure_turn - assert successors[0]["unblocks_todo_id"] == failure_review["todo_id"], failure_turn + assert successors[0]["unblocks_todo_id"] == failure_evidence["todo_id"], failure_turn assert failure_turn["completion"]["successor_todo_ids"] == [successors[0]["todo_id"]], failure_turn state_text = state_file.read_text(encoding="utf-8") assert f"todo_id={failure_review['todo_id']} status=done" in state_text, state_text @@ -1153,7 +1170,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) data_gap_evidence = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -1213,7 +1230,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) retry_evidence = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -1268,7 +1285,7 @@ def main() -> int: retry_turn["evaluation_summary"]["failure_continuation"]["source_todo_id"] == retry_evidence["todo_id"] ), retry_turn - assert successors[0]["unblocks_todo_id"] == retry_review["todo_id"], retry_turn + assert successors[0]["unblocks_todo_id"] == retry_evidence["todo_id"], retry_turn assert retry_turn["completion"]["successor_todo_ids"] == [successors[0]["todo_id"]], retry_turn state_text = state_file.read_text(encoding="utf-8") assert f"todo_id={retry_review['todo_id']} status=done" in state_text, state_text @@ -1282,7 +1299,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) generic_todo = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -1324,7 +1341,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) append_fixture_evidence( registry=registry, runtime_root=runtime_root, @@ -1366,6 +1383,11 @@ def main() -> int: assert target_reached["frontier"]["frontier"]["selected"] is None, target_reached assert_public_safe(target_reached) + for historical_parent in (False, True): + assert_failure_successor_lineage(provider, historical_parent) + + +def assert_failure_successor_lineage(provider: str, historical_parent: bool) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp = Path(temp_dir) project = temp / "project" @@ -1374,7 +1396,7 @@ def main() -> int: registry = temp / "registry.json" runtime_root = temp / "runtime" write_summary_state(state_file) - write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root, provider=provider) failure_evidence = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -1385,7 +1407,7 @@ def main() -> int: claimed_by=EXECUTOR_AGENT_ID, dry_run=False, ) - add_goal_todo( + first_summary = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, role="agent", @@ -1396,7 +1418,7 @@ def main() -> int: unblocks_todo_id=failure_evidence["todo_id"], dry_run=False, ) - add_goal_todo( + second_summary = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, role="agent", @@ -1417,6 +1439,21 @@ def main() -> int: workspace = project / "visible-workspace" workspace.mkdir() + if historical_parent: + # Seed the published pre-cutoff producer intent through native create; + # its parent must survive upgrade even when another summary retries. + historical = apply_role_successor_todos(registry_path=registry, goal_id=GOAL_ID, + source_todo_id=first_summary['todo_id'], text_source_todo_id=failure_evidence['todo_id'], + current_agent_id=EVALUATOR_AGENT_ID, role_id='evaluator_promoter', + action='summarize_evidence', execute=True, + successor_specs=[spec for spec in auto_research_successor_specs_for_action( + role_id='evaluator_promoter', action='summarize_evidence') + if spec['action_kind'] == 'propose_failure_successor'], + decision_summary={'failure_continuation': {'next_outcome': 'propose_failure_successor'}}) + assert historical['successors'][0]['added'] is True + original = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) + original_source = next(row for row in original['todos'] if row['todo_id'] == failure_evidence['todo_id']) + first_turn = run_worker_turn( registry=registry, runtime_root=runtime_root, @@ -1432,6 +1469,56 @@ def main() -> int: first_successor_text = first_successors[0]["todo_command"] assert ".." not in first_successor_text, f"successor text contains ..: {first_successor_text!r}" assert_public_safe(first_turn) + first_snapshot = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) + successor_id = first_successors[0]['todo_id'] + accepted = next(row for row in first_snapshot['todos'] if row['todo_id'] == successor_id) + assert accepted['unblocks_todo_id'] == (first_summary['todo_id'] if historical_parent else failure_evidence['todo_id']) + + # An explicit attempt to rebind this existing successor remains illegal. + # The producer fix must not weaken the canonical duplicate-intent fence. + try: + add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text=accepted['text'], task_class='advancement_task', + action_kind='propose_failure_successor', claimed_by=HYPOTHESIS_AGENT_ID, + unblocks_todo_id=second_summary['todo_id'], dry_run=False) + except LocalCoordinationAuthorityUnavailable as error: + assert error.code == 'todo_semantic_duplicate_conflict', error + else: + raise AssertionError('same-title successor must not acquire another source binding') + assert read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) == first_snapshot + if historical_parent: + from loopx.control_plane.testing.canary_harness import run_json_cli + run_json_cli('todo', 'archive-completed', '--goal-id', GOAL_ID, '--role', 'agent', + '--max-active-done', '0', '--execute', + registry_path=registry, runtime_root=runtime_root) + + if historical_parent: + from unittest.mock import patch + from demo.auto_research import worker_runtime + observed_after_race = None + + def concurrent_read(**kwargs): + nonlocal observed_after_race + snapshot = read_canonical_todos_if_promoted(**kwargs) + add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text='[P2] Independent concurrent planning work.', task_class='advancement_task', + claimed_by=CURATOR_AGENT_ID, dry_run=False) + observed_after_race = read_canonical_todos_if_promoted(**kwargs) + return snapshot + + # Intervene after a real read; both the update and CAS refusal use + # the real provider. No mock supplies the claimed postcondition. + with patch.object(worker_runtime, 'read_canonical_todos_if_promoted', concurrent_read): + try: + run_auto_research_worker_turn(registry_path=registry, + runtime_root_arg=str(runtime_root), goal_id=GOAL_ID, + agent_id=EVALUATOR_AGENT_ID, workspace=workspace, execute=True, + complete_selected_todo=True, lane_count=4, visible_lanes_accepted=True) + except LocalCoordinationAuthorityUnavailable as error: + assert error.code == 'provider_revision_mismatch', error + else: + raise AssertionError('stale historical-lineage proof must refuse') + assert read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) == observed_after_race second_turn = run_worker_turn( registry=registry, @@ -1451,7 +1538,20 @@ def main() -> int: f"second successor should have the same todo_id, got: {second_successor['todo_id']}" ) assert_public_safe(second_turn) + final = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) + rows = {row['todo_id']: row for row in final['todos']} + assert len(final['todos']) == len(original['todos']) + 1 + assert rows[successor_id] == accepted + assert rows[failure_evidence['todo_id']] == original_source + for summary in (first_summary, second_summary): + stored = rows[summary['todo_id']] + assert stored['status'] == 'done' and stored['claimed_by'] == EVALUATOR_AGENT_ID + assert stored['successor_todo_ids'] == [successor_id] +def main() -> int: + assert_seeded_worker_turns() + for provider in ("file", "sqlite"): + assert_evidence_lineage(provider) return 0 diff --git a/examples/blocker-push-runtime-smoke.py b/examples/blocker-push-runtime-smoke.py index f847f6af4a..05d5718973 100644 --- a/examples/blocker-push-runtime-smoke.py +++ b/examples/blocker-push-runtime-smoke.py @@ -217,7 +217,7 @@ def main() -> int: # The bootstrap rule is shared from heartbeat.rules after #4201; assert the # current compact sentence instead of the retired per-shell phrasing. assert "reuse the value on retries" in compact_prompt, prompt - assert "guard; 2 stalls->replan" in compact_prompt, prompt + assert "obey projected replan/stop, never wake counts" in compact_prompt, prompt assert "Exact monitor settlement=no refresh/spend" in compact_prompt, prompt assert "Admitted work: settlement_plan even if artifacts unchanged" in compact_prompt, prompt # The shipped writeback sentence moved to the mixed-language form in the diff --git a/examples/capability-gate-smoke.py b/examples/capability-gate-smoke.py index 9f63b59fc2..bb7a3346fc 100644 --- a/examples/capability-gate-smoke.py +++ b/examples/capability-gate-smoke.py @@ -367,29 +367,38 @@ def main() -> int: "provide or authorize the missing owner-held capability: credentials " "for todo_capability_9" ], owner_with_fallback - unselected = owner_with_fallback["interaction_contract"]["cli_channel"] - assert unselected["selection_required"] is True, owner_with_fallback - assert unselected["next_cli_actions"] == [], owner_with_fallback - assert "{todo_id}" in unselected["selection_command"]["command_args_template"], unselected - selected_fallback = build_quota_should_run( + # A runnable recommendation is not a Turn binding. Keep the owner notice, + # but require explicit selection before projecting write/spend commands. + unbound = owner_with_fallback["interaction_contract"] + assert unbound["agent_channel"]["delivery_allowed"] is False, unbound + assert unbound["cli_channel"]["selection_required"] is True, unbound + assert unbound["cli_channel"]["next_cli_actions"] == [], unbound + assert unbound["cli_channel"]["spend_after_validation"] is False, unbound + assert "--todo-id '{todo_id}'" in unbound["cli_channel"][ + "selection_command" + ]["command_args_template"], unbound + + # This pure projection receives an already-bound receipt as input; real + # receipt acquisition and canonical recovery are covered by CLI smokes. + bound_fallback = build_quota_should_run( status_payload([credentials_todo, p1_docs], registered_agent=AGENT_ID), goal_id=GOAL_ID, agent_id=AGENT_ID, available_capabilities=["shell", "filesystem_write"], scheduler_execution_context=SCHEDULER_CONTEXT, - requested_action_todo_id=p1_docs["todo_id"], + receipt_bound_todo_id=p1_docs["todo_id"], ) - assert selected_fallback["ok"] is True, selected_fallback - assert selected_fallback["selected_todo"]["todo_id"] == p1_docs["todo_id"], selected_fallback - assert selected_fallback["action_selection_qualification"]["state"] == "qualified", selected_fallback - assert selected_fallback["interaction_contract"]["cli_channel"].get("selection_required") is not True, selected_fallback + bound = bound_fallback["interaction_contract"] + assert bound["agent_channel"]["delivery_allowed"] is True, bound + assert bound["cli_channel"]["spend_after_validation"] is True, bound + assert bound["user_channel"] == unbound["user_channel"], bound assert any( "--task-class user_gate" in action and "--target-capability credentials" in action and f"--blocks-agent {AGENT_ID}" in action and "--unblocks-todo-id todo_capability_9" in action - for action in selected_fallback["interaction_contract"]["cli_channel"]["next_cli_actions"] - ), selected_fallback + for action in bound["cli_channel"]["next_cli_actions"] + ), bound_fallback mixed_capability_todo = todo( 8, @@ -401,6 +410,7 @@ def main() -> int: status_payload([mixed_capability_todo]), goal_id=GOAL_ID, available_capabilities=["shell", "filesystem_write"], + scheduler_execution_context=SCHEDULER_CONTEXT, ) mixed_gate = mixed_owner_gate["capability_gate"] assert mixed_gate["action"] == "ask_owner", mixed_owner_gate @@ -431,6 +441,7 @@ def main() -> int: "credentials", "external_evidence_poll", ], + scheduler_execution_context=SCHEDULER_CONTEXT, ) assert mixed_ready["capability_gate"]["action"] == "run", mixed_ready assert mixed_ready["interaction_contract"]["user_channel"]["action_required"] is False diff --git a/examples/control_plane/authority-monitor-poll-rehearsal.py b/examples/control_plane/authority-monitor-poll-rehearsal.py index 7f21b47867..9de8a3896d 100644 --- a/examples/control_plane/authority-monitor-poll-rehearsal.py +++ b/examples/control_plane/authority-monitor-poll-rehearsal.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 -"""Compare Monitor polling and observation updates on a read-only Goal snapshot. +"""Compare current-policy Monitor recovery on a read-only Goal snapshot. Only disposable copies receive synthetic Monitor/lease records. The report is bounded and excludes source text, identifiers, paths and connection strings. +The baseline must support the same Monitor recovery and authority format; +this is a parity rehearsal, not an assertion that an older bug still exists. """ from __future__ import annotations @@ -43,7 +45,7 @@ const {selectLocalSqliteAuthority}=await moduleAt(input.repo,'coordination/local_authority_provider.ts'); const {executeCanonicalTaskLeaseAcquire}=await moduleAt(input.repo,'coordination/task_lease_acquire.ts'); const {coordinationTodoReadModel}=await moduleAt(input.repo,'coordination/coordination_projection.ts'); -const {canonicalAuthoritySha256: digest}=await moduleAt(input.repo,'coordination/authority_store_codec.ts'); +const {canonicalAuthoritySha256: digest,authorityUnicodeCompare}=await moduleAt(input.repo,'coordination/authority_store_codec.ts'); const {engageLegacyCoordinationWriterFence}=await moduleAt(input.repo,'coordination/legacy_writer_fence.ts'); const goal=input.goal_id, target='todo_monitor_rehearsal'; const initial=structuredClone(input.projection); @@ -52,9 +54,9 @@ text:'Observe isolated public changes',archive_state:'active',source_section:'Agent Todo', claimed_by:'agent-a',excluded_agents:[],task_class:'continuous_monitor',target_key:'isolated-watch', cadence:'1h',watch_only:'true',next_due_at:'2026-09-01T00:00:00Z'}); -initial.todos.sort((a,b)=>a.todo_idb.todo_id?1:0); +initial.todos.sort((a,b)=>authorityUnicodeCompare(a.todo_id,b.todo_id)); initial.todo_read_model=coordinationTodoReadModel(initial.todos,initial.todo_read_model.schema_version); -initial.handoff_mode='legacy'; +initial.handoff_mode='soft_claim'; const proof={idempotency_key:'monitor-rehearsal-execution',expected_version:3}; const lease={schema_version:'task_lease_v0',goal_id:goal,todo_id:target,owner:'agent-a', idempotency_key:proof.idempotency_key,version:3,lease_epoch:2,status:'active',write_scopes:[], @@ -98,6 +100,7 @@ expected_shadow_provider_revision:seed.provider_revision}})).status,'applied'); await rm(display); const poll=arm==='baseline'?baseline:current; + const updateTodo=arm==='baseline'?baselineUpdate:update; const request={schema_version:'loopx_coordination_monitor_poll_request_v0',runtime_root:runtime,goal_id:goal, operation_id:'unchanged',actor_agent_id:'agent-a',registered_agents:['agent-a','agent-b'],dry_run:false, observation:{todo_id:target,generated_at:'2026-09-01T00:00:00Z',result_hash:'first',material_change:false},intent:{}}; @@ -105,7 +108,7 @@ assert.equal(firstPoll.status,'applied',JSON.stringify(firstPoll)); const first=await store.loadAuthority(); assert.equal(first.status,'loaded'); compatible[arm]=first.head; assert.equal((await poll(request,dependencies)).status,'replayed'); - const leased={...first.head,handoff_mode:'hard_lease',leases:[...first.head.leases,lease].sort((a,b)=>a.todo_idb.todo_id?1:0)}; + const leased={...first.head,handoff_mode:'hard_lease',leases:[...first.head.leases,lease].sort((a,b)=>authorityUnicodeCompare(a.todo_id,b.todo_id))}; assert.equal((await store.commitAuthority({operation_id:'seed-execution',expected_provider_revision:first.provider_revision, events:[],receipts:[],next_projection:leased})).status,'applied'); const before=await store.loadAuthority(); @@ -152,17 +155,11 @@ planning_intent:{status:'open',no_followup:false},monitor_observation:{generated_at:'2030-01-01T00:00:00Z', result_hash:'second',material_change:true,monitor_effect_id:'reactivate',cadence:'1h'}}; const closed=await store.loadAuthority(); - if(arm==='baseline') { - const unsupported=await baselineUpdate(observation,dependencies); - assert.equal(unsupported.status,'failed'); assert.equal(unsupported.reason_code,'monitor_reactivation_lease_transition_required'); - assert.deepEqual(await store.loadAuthority(),closed); - report[arm]={poll_parity:true,retained_lease_reactivation:'rejected_no_write'}; continue; - } - assert.equal((await update({...observation,dry_run:true},dependencies)).status,'planned'); + assert.equal((await updateTodo({...observation,dry_run:true},dependencies)).status,'planned'); assert.deepEqual(await store.loadAuthority(),closed); - assert.equal((await update({...observation,patch:{text:'mixed edit'}},dependencies)).status,'failed'); + assert.equal((await updateTodo({...observation,patch:{text:'mixed edit'}},dependencies)).status,'failed'); assert.deepEqual(await store.loadAuthority(),closed); - const resumed=await update(observation,dependencies); assert.equal(resumed.status,'applied',JSON.stringify(resumed)); + const resumed=await updateTodo(observation,dependencies); assert.equal(resumed.status,'applied',JSON.stringify(resumed)); assert.equal(resumed.monitor_poll_transition.material_change_generation,2); const afterResume=await store.loadAuthority(); assert.equal(afterResume.status,'loaded'); const resumedTodo=afterResume.head.todos.find(t=>t.todo_id===target); @@ -177,7 +174,7 @@ assert.equal(fresh.status,'applied',JSON.stringify(fresh)); assert.equal(fresh.lease.version,4); assert.equal(fresh.lease.lease_epoch,3); const acquired=await store.loadAuthority(); - assert.equal((await update(observation,dependencies)).status,'replayed'); + assert.equal((await updateTodo(observation,dependencies)).status,'replayed'); assert.deepEqual(await store.loadAuthority(),acquired,'historical replay cannot release the fresh execution'); assert.deepEqual(afterResume.head.todos.filter(t=>input.projection.todos.some(old=>old.todo_id===t.todo_id)),input.projection.todos); const closedAgain=afterResume.head.todos.map(t=>t.todo_id===target?{...t,status:'done',done:true,completed_at:'2030-01-01T01:00:00Z'}:t); @@ -185,15 +182,15 @@ events:[],receipts:[],next_projection:{...acquired.head,todos:closedAgain, todo_read_model:coordinationTodoReadModel(closedAgain,afterResume.head.todo_read_model.schema_version)}}); const final=await store.loadAuthority(); - assert.equal((await update(observation,dependencies)).status,'replayed'); - assert.equal((await update({...observation,operation_id:'stale-new-id'},dependencies)).status,'failed'); + assert.equal((await updateTodo(observation,dependencies)).status,'replayed'); + assert.equal((await updateTodo({...observation,operation_id:'stale-new-id'},dependencies)).status,'failed'); assert.deepEqual(await store.loadAuthority(),final); heads[arm]=final.head; report[arm]={poll_parity:true,retained_lease_reactivation:'applied',fresh_execution_epoch:3,same_hash_reactivation_generation:2, historical_replay_keeps_completion:true,stale_observation:'rejected',non_target_unchanged:true}; } for(const arm of ['file','sqlite','postgresql']) assert.deepEqual(compatible[arm],compatible.baseline); - assert.deepEqual(heads.file,heads.sqlite); assert.deepEqual(heads.file,heads.postgresql); + for(const arm of ['file','sqlite','postgresql']) assert.deepEqual(heads[arm],heads.baseline); process.stdout.write(JSON.stringify({schema_version:'authority_monitor_rehearsal_v0', source_todos:input.projection.todos.length,source_leases:input.projection.leases.length, compatible_head_sha256:digest(compatible.baseline),final_head_sha256:digest(heads.file), @@ -227,7 +224,7 @@ def git(*command): goal = next(g for g in registry["goals"] if g["id"] == args.goal_id) runtime = resolve_runtime_root(registry, None, registry_path=registry_path) _, _, state = resolve_goal_state(registry=registry, goal_id=args.goal_id, project_override=None, state_file_override=None) - projection, snapshot = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime, state_path=state, registry_path=registry_path) + projection, snapshot = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime, state_path=state, registry_path=registry_path, include_all_archived_todos=True) source_digest = hashlib.sha256(json.dumps(snapshot, sort_keys=True).encode()).hexdigest() child = subprocess.run(["node", "--no-warnings", "--experimental-sqlite", "--experimental-strip-types", "--input-type=module", "-e", NODE_REHEARSAL], input=json.dumps({"repo": str(REPOSITORY), "baseline_repo": str(baseline), "goal_id": args.goal_id, "projection": projection}), @@ -239,7 +236,7 @@ def git(*command): with os.fdopen(descriptor, "w") as stream: stream.write(child.stderr) raise SystemExit(f"isolated Monitor rehearsal failed (exit {child.returncode}); no qualification; diagnostics stay private") - after_projection, after_snapshot = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime, state_path=state, registry_path=registry_path) + after_projection, after_snapshot = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime, state_path=state, registry_path=registry_path, include_all_archived_todos=True) if projection != after_projection or snapshot != after_snapshot or registry_path.read_bytes() != registry_bytes: raise SystemExit("live source changed during rehearsal; rerun from a stable snapshot") result = json.loads(child.stdout) diff --git a/examples/control_plane/completion-validation-accountability-smoke.py b/examples/control_plane/completion-validation-accountability-smoke.py index e8a0efd022..8b6e926fcb 100755 --- a/examples/control_plane/completion-validation-accountability-smoke.py +++ b/examples/control_plane/completion-validation-accountability-smoke.py @@ -13,14 +13,15 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from loopx.control_plane.quota.slot_accounting import ( +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.quota.slot_accounting import ( # noqa: E402 build_quota_slot_preview_for_decision, ) -from loopx.control_plane.todos.active_state_todo_parser import ( +from loopx.control_plane.todos.active_state_todo_parser import ( # noqa: E402 parse_active_state_todos, ) -from loopx.state_refresh import refresh_state_run -from loopx.todos import add_goal_todo, complete_goal_todo +from loopx.state_refresh import refresh_state_run # noqa: E402 +from loopx.todos import add_goal_todo, complete_goal_todo # noqa: E402 GOAL_ID = "completion-validation-accountability" AGENT_ID = "codex-author" @@ -71,6 +72,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: ), encoding="utf-8", ) + import_fixture_authority(registry, GOAL_ID) return registry, runtime, state diff --git a/examples/control_plane/control-plane-integrated-canary-smoke.py b/examples/control_plane/control-plane-integrated-canary-smoke.py index 63049aa45b..9bb2b2cd11 100644 --- a/examples/control_plane/control-plane-integrated-canary-smoke.py +++ b/examples/control_plane/control-plane-integrated-canary-smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Bounded canary for the status -> quota -> review-packet Todo read path.""" +"""Bounded File/SQLite canary for status, quota, successor and monitor readback.""" from __future__ import annotations @@ -17,21 +17,23 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.control_plane.testing.canary_harness import ( # noqa: E402 + import_fixture_authority, read_run_index, run_json_cli, write_fixture_registry, ) +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 GOAL_ID = "control-plane-integrated-canary" MONITOR_GOAL_ID = "control-plane-integrated-monitor-canary" -MARKDOWN_GOAL_ID = "control-plane-markdown-continuation-canary" +CONTINUATION_GOAL_ID = "control-plane-continuation-canary" AGENT_ID = "codex-product-capability" PRIMARY_AGENT_ID = "codex-main-control" CANARY_TODO_ID = "todo_integrated_canary" CANARY_TODO_TITLE = "Design bounded status/quota/review-packet/Todo/read-path canary" -SUCCESSOR_TODO_TITLE = "Continue integrated Markdown successor routing canary" -MARKDOWN_CONTINUATION_TITLE = "Continue same-agent markdown continuation through status and quota." +SUCCESSOR_TODO_TITLE = "Continue integrated canonical successor routing canary" +CONTINUATION_TITLE = "Continue same-agent canonical continuation through status and quota." MONITOR_TODO_ID = "todo_integrated_due_monitor" MONITOR_TARGET_KEY = "integrated-due-monitor-watch" VALIDATED_PROGRESS_CLASSIFICATION = "integrated_canary_validated_progress" @@ -73,7 +75,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: return registry_path, state_file, runtime -def write_monitor_fixture(root: Path) -> tuple[Path, Path]: +def write_monitor_fixture(root: Path, *, provider: str) -> tuple[Path, Path]: project = root / "monitor-project" runtime = root / "monitor-runtime" state_file = project / ".codex" / "goals" / MONITOR_GOAL_ID / "ACTIVE_GOAL_STATE.md" @@ -103,13 +105,14 @@ def write_monitor_fixture(root: Path) -> tuple[Path, Path]: registered_agents=[AGENT_ID], quota_allowed_slots=10, ) + import_fixture_authority(registry_path, MONITOR_GOAL_ID, provider=provider) return registry_path, runtime -def write_markdown_continuation_fixture(root: Path) -> tuple[Path, Path, Path]: - project = root / "markdown-project" - runtime = root / "markdown-runtime" - state_file = project / ".codex" / "goals" / MARKDOWN_GOAL_ID / "ACTIVE_GOAL_STATE.md" +def write_continuation_fixture(root: Path, *, provider: str) -> tuple[Path, Path, Path]: + project = root / "continuation-project" + runtime = root / "continuation-runtime" + state_file = project / ".codex" / "goals" / CONTINUATION_GOAL_ID / "ACTIVE_GOAL_STATE.md" registry_path = project / ".loopx" / "registry.json" state_file.parent.mkdir(parents=True) state_file.write_text( @@ -117,9 +120,9 @@ def write_markdown_continuation_fixture(root: Path) -> tuple[Path, Path, Path]: "status: active\n" "updated_at: 2026-06-27T00:00:00+00:00\n" "---\n\n" - "# Control Plane Markdown Continuation Canary\n\n" + "# Control Plane Canonical Continuation Canary\n\n" "## Next Action\n\n" - "- Preserve same-agent markdown successor routing across status and quota.\n\n" + "- Preserve same-agent canonical successor routing across status and quota.\n\n" "## Agent Todo\n\n" "- [ ] [P2] Markdown placeholder that should lose to the claimed P1 todo.\n" " \n" - "- [ ] [P1] Repair a separate repository-writing control-plane task.\n" - f" \n", - encoding="utf-8", + monitor = run_cli( + project, "todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--operation-id", "due-monitor", + "--text", "[P0] Monitor one due repository lifecycle without repository writes.", + "--task-class", "continuous_monitor", "--action-kind", "monitor_repository_lifecycle", + "--task-repository", PROJECT_REPOSITORY, "--claimed-by", PEER_ALPHA, + "--target-key", "repository-lifecycle", "--cadence", "30m", + "--next-due-at", "2020-01-01T00:00:00+00:00", "--expires-at", "2099-01-01T00:00:00+00:00", + registry_path=registry_path, runtime=runtime, + ) + run_cli( + project, "todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--operation-id", "unrelated-repair", + "--text", "[P1] Repair a separate repository-writing control-plane task.", + "--task-class", "advancement_task", "--action-kind", "repair_workspace_route", + "--task-repository", TASK_REPOSITORY, "--required-write-scope", "loopx/**", + "--claimed-by", PEER_ALPHA, + registry_path=registry_path, runtime=runtime, ) monitor_selected = should_run(project, project, runtime, registry_path) @@ -304,7 +328,7 @@ def main() -> None: assert "workspace_guard" not in monitor_selected, monitor_selected assert "boundary_projection_gap" not in monitor_selected, monitor_selected assert monitor_selected["selected_todo"]["todo_id"] == ( - "todo_due_monitor" + monitor["todo_id"] ), monitor_selected assert monitor_selected["selected_todo"]["task_repository"] == ( PROJECT_REPOSITORY diff --git a/examples/control_plane/quota-agent-scoped-user-gate-smoke.py b/examples/control_plane/quota-agent-scoped-user-gate-smoke.py index 25e5bf012f..88c1c56a61 100644 --- a/examples/control_plane/quota-agent-scoped-user-gate-smoke.py +++ b/examples/control_plane/quota-agent-scoped-user-gate-smoke.py @@ -13,6 +13,7 @@ from loopx.control_plane.todos.decision_scope import todo_gate_relation # noqa: E402 from loopx.control_plane.scheduler.execution_context import ( # noqa: E402 + GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, scheduler_execution_context_for_runtime_profile, ) from loopx.control_plane.testing.quota_fixtures import ( # noqa: E402 @@ -46,6 +47,7 @@ def todo_item( role: str = "agent", task_class: str = "advancement_task", claimed_by: str | None = None, + bound_agent: str | None = None, action_kind: str | None = None, blocks_agent: str | None = None, global_gate: bool = False, @@ -61,6 +63,7 @@ def todo_item( "next_due_at": next_due_at, "target_key": target_key, "global_gate": True if global_gate else None, + "bound_agent": bound_agent, }.items() if value is not None } @@ -245,7 +248,7 @@ def other_agent_gate_with_current_user_action_payload() -> dict: role="user", task_class="user_action", action_kind="review_handoff_material", - claimed_by="codex-value-explorer", + bound_agent=VALUE_AGENT, priority="P1", ) value_agent_todo = todo_item( @@ -276,7 +279,7 @@ def assert_other_agent_user_gate_does_not_block_current_agent() -> None: status_payload(), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is True, payload assert payload["normal_delivery_allowed"] is True, payload @@ -308,14 +311,16 @@ def assert_current_user_action_does_not_preserve_other_agent_operator_gate() -> other_agent_gate_with_current_user_action_payload(), goal_id=GOAL_ID, agent_id=VALUE_AGENT, - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["decision"] == "run", payload assert payload["state"] == "eligible", payload assert payload["should_run"] is True, payload assert payload["normal_delivery_allowed"] is True, payload + # A routed user_action is a non-blocking notice while live work proceeds; + # the foreign user_gate must not turn that notice into an execution gate. assert payload["requires_user_action"] is False, payload - assert payload["interaction_contract"]["user_channel"]["notify"] == "NOTIFY", payload + assert "notify_user_on_open_todo" not in payload, payload assert "notify_user_on_gate" not in payload, payload summary = payload["user_todo_summary"] @@ -335,8 +340,11 @@ def assert_current_user_action_does_not_preserve_other_agent_operator_gate() -> contract = payload["interaction_contract"] assert contract["mode"] == "bounded_delivery", contract assert contract["user_channel"]["action_required"] is False, contract - assert contract["user_channel"]["notify"] == "NOTIFY", contract assert contract["user_channel"]["non_blocking"] is True, contract + assert contract["user_channel"]["notify"] == "NOTIFY", contract + assert contract["user_channel"]["actions"] == [ + "Review the value-lane handoff material when convenient." + ], contract assert contract["agent_channel"]["must_attempt"] is True, contract assert contract["agent_channel"]["delivery_allowed"] is True, contract @@ -346,7 +354,7 @@ def assert_other_agent_user_gate_does_not_notify_non_due_monitor_lane() -> None: other_agent_gate_with_non_due_monitor_payload(), goal_id=GOAL_ID, agent_id="codex-value-explorer", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["decision"] == "skip", payload assert payload["effective_action"] == "monitor_quiet_skip", payload @@ -373,7 +381,7 @@ def assert_target_agent_still_blocks_on_its_user_gate() -> None: status_payload(), goal_id=GOAL_ID, agent_id="codex-product-capability", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is False, payload assert payload["normal_delivery_allowed"] is False, payload @@ -394,7 +402,7 @@ def assert_unscoped_user_gate_requires_projection_repair() -> None: status_payload(blocks_agent=None), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["decision"] == "self_repair", payload assert payload["should_run"] is True, payload @@ -418,7 +426,7 @@ def assert_explicit_global_gate_blocks_goal() -> None: status_payload(blocks_agent=None, global_gate=True), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is False, payload assert payload["requires_user_action"] is True, payload @@ -470,7 +478,7 @@ def assert_unrelated_user_gate_allows_feishu_fallback() -> None: unrelated_gate_status_payload(), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is True, payload assert payload["decision"] == "safe_bypass_user_gate_fallback", payload @@ -723,7 +731,7 @@ def assert_exact_todo_gate_only_blocks_target_todo() -> None: exact_todo_gate_status_payload(), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is True, payload assert payload["decision"] == "safe_bypass_user_gate_fallback", payload @@ -751,7 +759,7 @@ def assert_exact_todo_gate_only_blocks_target_todo() -> None: exact_todo_gate_status_payload(include_fallback=False), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert blocked_payload["should_run"] is False, blocked_payload assert blocked_payload["interaction_contract"]["mode"] == "user_gate", blocked_payload @@ -801,7 +809,7 @@ def assert_exact_todo_gate_survives_decision_scope_migration() -> None: exact_todo_gate_with_decision_scope_migration_payload(), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is True, payload assert payload["decision"] == "safe_bypass_user_gate_fallback", payload @@ -830,7 +838,7 @@ def assert_exact_todo_gate_allows_independent_due_monitor() -> None: goal_id=GOAL_ID, agent_id="codex-main-control", available_capabilities=["network", "external_write"], - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is True, payload assert payload["decision"] == "safe_bypass_user_gate_fallback", payload @@ -854,7 +862,7 @@ def assert_exact_todo_gate_allows_independent_due_monitor() -> None: goal_id=GOAL_ID, agent_id="codex-main-control", available_capabilities=["network"], - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert blocked_payload["should_run"] is False, blocked_payload assert "scoped_user_gate_fallback" not in blocked_payload, blocked_payload @@ -901,7 +909,7 @@ def assert_monitor_debt_yields_to_equal_priority_advancement() -> None: goal_id=GOAL_ID, agent_id="codex-main-control", available_capabilities=["network", "external_write"], - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) arbitration = payload["monitor_debt_arbitration"] assert arbitration["active"] is True, arbitration @@ -923,7 +931,7 @@ def assert_monitor_debt_yields_to_equal_priority_advancement() -> None: goal_id=GOAL_ID, agent_id="codex-main-control", available_capabilities=["network", "external_write"], - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert "monitor_debt_arbitration" not in terminal_transition, terminal_transition terminal_fallback = terminal_transition["scoped_user_gate_fallback"] @@ -974,7 +982,7 @@ def assert_decision_scope_overrides_shared_action_kind_tokens() -> None: decision_scope_status_payload(), goal_id=GOAL_ID, agent_id="codex-main-control", - scheduler_execution_context=APP_SCHEDULER_CONTEXT, + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, ) assert payload["should_run"] is True, payload assert payload["decision"] == "safe_bypass_user_gate_fallback", payload diff --git a/examples/control_plane/quota-no-git-status-smoke.py b/examples/control_plane/quota-no-git-status-smoke.py index 6cf51ef55d..ab47e98154 100644 --- a/examples/control_plane/quota-no-git-status-smoke.py +++ b/examples/control_plane/quota-no-git-status-smoke.py @@ -12,6 +12,11 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 + GOAL_ID = "no-git-quota-fixture" AGENT_ID = "codex-benchmark-agent" @@ -30,7 +35,8 @@ def write_fixture(root: Path) -> Path: "## Objective\n\n" "Solve this no-git fixture.\n\n" "## Next Action\n\n" - "- Pick the seeded todo.\n", + "- Pick the seeded todo.\n\n" + "## Agent Todo\n\n", encoding="utf-8", ) registry_path.parent.mkdir(parents=True) @@ -60,6 +66,7 @@ def write_fixture(root: Path) -> Path: + "\n", encoding="utf-8", ) + import_fixture_authority(registry_path, GOAL_ID) return registry_path @@ -100,6 +107,7 @@ def main() -> int: "Solve the no-git quota fixture.", ) assert todo["ok"] is True, todo + before = run_cli(registry_path, "todo", "list", "--goal-id", GOAL_ID) no_git_bin = root / "no-git-bin" no_git_bin.mkdir() @@ -119,6 +127,11 @@ def main() -> int: assert quota.get("status") != "quota_collection_failed", quota assert quota["should_run"] is True, quota assert quota["recommended_action"] == "Solve the no-git quota fixture.", quota + assert quota["selected_todo"]["todo_id"] == todo["todo_id"], quota + after = run_cli(registry_path, "todo", "list", "--goal-id", GOAL_ID, env=env) + assert after["todos"] == before["todos"], after + assert after["authority_read"]["provider_revision"] == before["authority_read"]["provider_revision"], after + assert after["authority_read"]["legacy_fallback_used"] is False, after return 0 diff --git a/examples/control_plane/status-projection-cache-smoke.py b/examples/control_plane/status-projection-cache-smoke.py index 1b81901b4e..dc3eedc8ff 100644 --- a/examples/control_plane/status-projection-cache-smoke.py +++ b/examples/control_plane/status-projection-cache-smoke.py @@ -17,6 +17,7 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.control_plane.runtime.status_projection_cache import write_status_projection_cache # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 GOAL_ID = "status-projection-cache-fixture" AGENT_ID = "codex-main-control" @@ -41,7 +42,8 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: "## Objective\n\n" "Validate opt-in status projection cache behavior.\n\n" "## Next Action\n\n" - "- Exercise the status/quota projection cache.\n", + "- Exercise the status/quota projection cache.\n\n" + "## Agent Todo\n\n", encoding="utf-8", ) public_doc.write_text("Public smoke fixture for status projection cache.\n", encoding="utf-8") @@ -81,6 +83,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: + "\n", encoding="utf-8", ) + import_fixture_authority(registry_path, GOAL_ID) return registry_path, runtime, public_doc @@ -258,6 +261,8 @@ def main() -> int: "agent", "--todo-id", initial_todo_id, + "--agent-id", + AGENT_ID, "--claimed-by", AGENT_ID, "--evidence", diff --git a/examples/control_plane/status-quota-perf-budget-smoke.py b/examples/control_plane/status-quota-perf-budget-smoke.py index 0e07ba7eb8..e5d7c7af2e 100644 --- a/examples/control_plane/status-quota-perf-budget-smoke.py +++ b/examples/control_plane/status-quota-perf-budget-smoke.py @@ -14,6 +14,11 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 + GOAL_ID = "status-quota-perf-budget-fixture" AGENT_ID = "codex-main-control" IGNORED_FILE_COUNT = 1_200 @@ -22,7 +27,7 @@ PRIVATE_DOC_MARKER = "https://" + "la" + "rk" + "office.example/doc" -def write_fixture(root: Path) -> tuple[Path, Path, Path]: +def write_fixture(root: Path, *, import_authority: bool = True) -> tuple[Path, Path, Path]: project = root / "project" runtime = root / "runtime" state_rel = f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" @@ -40,7 +45,12 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: "## Objective\n\n" "Keep status and quota hot paths fast without skipping public scans.\n\n" "## Next Action\n\n" - "- Validate the selected hot-path budget todo.\n", + "- Validate the selected hot-path budget todo.\n\n" + "## Agent Todo\n\n" + + ("" if import_authority else + "- [ ] Inspect the historical status budget fixture.\n" + f" \n"), encoding="utf-8", ) public_doc.write_text("Public smoke fixture for status/quota perf budgets.\n", encoding="utf-8") @@ -90,6 +100,8 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: + "\n", encoding="utf-8", ) + if import_authority: + import_fixture_authority(registry_path, GOAL_ID) return registry_path, project, public_doc @@ -230,10 +242,23 @@ def main() -> int: ) state_text = state_file.read_text(encoding="utf-8") assert " status=open " in state_text, state_text - state_file.write_text( - state_text.replace(" status=open ", " status=stuck ", 1), - encoding="utf-8", - ) + before, _ = run_cli(registry_path, "todo", "list", "--goal-id", GOAL_ID) + tampered = state_text.replace(" status=open ", " status=stuck ", 1) + state_file.write_text(tampered, encoding="utf-8") + after, _ = run_cli(registry_path, "todo", "list", "--goal-id", GOAL_ID) + assert after["todos"] == before["todos"], after + assert after["authority_read"]["provider_revision"] == before["authority_read"]["provider_revision"], after + assert after["authority_read"]["legacy_fallback_used"] is False, after + assert state_file.read_text(encoding="utf-8") == tampered + + # Malformed historical source still has a read-only diagnostic/budget + # contract. It cannot act as the authority of the imported fixture. + registry_path, project, public_doc = write_fixture(root / "legacy-inspection", import_authority=False) + state_file = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" + state_text = state_file.read_text(encoding="utf-8") + assert " status=open " in state_text, state_text + state_file.write_text(state_text.replace(" status=open ", " status=stuck ", 1), encoding="utf-8") + legacy_source = state_file.read_bytes() ( malformed_status_payload, @@ -308,6 +333,7 @@ def main() -> int: QUOTA_BUDGET_SECONDS, "malformed_state", ) + assert state_file.read_bytes() == legacy_source print( "status_quota_perf_budget: " diff --git a/examples/control_plane/task-lease-runtime-smoke.py b/examples/control_plane/task-lease-runtime-smoke.py index 1b0ac95ac7..d15d2aac86 100644 --- a/examples/control_plane/task-lease-runtime-smoke.py +++ b/examples/control_plane/task-lease-runtime-smoke.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Smoke-test the task_lease_v0 runtime and CLI contract.""" +"""Exercise canonical lease, claim handover and completion through the real CLI.""" from __future__ import annotations import json -import re +import os import subprocess import sys import tempfile @@ -14,6 +14,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "tests" / "control_plane")) + +from canonical_authority_fixture import initialize_canonical_authority # noqa: E402 +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection # noqa: E402 GOAL_ID = "task-lease-runtime-goal" TODO_A = "todo_taskleasea" @@ -21,7 +25,7 @@ TODO_C = "todo_taskleasec" -def write_fixture(root: Path) -> tuple[Path, Path]: +def write_fixture(root: Path, provider: str) -> tuple[Path, Path]: project = root / "project" runtime = root / "runtime" registry_path = project / ".loopx" / "registry.json" @@ -32,14 +36,7 @@ def write_fixture(root: Path) -> tuple[Path, Path]: "status: active\n" "updated_at: 2026-01-01T00:00:00+00:00\n" "---\n\n" - "# Active Goal State\n\n" - "## Agent Todo\n\n" - f"- [ ] First independently claimable todo.\n" - f" \n" - f"- [ ] Second independently claimable todo.\n" - f" \n" - f"- [ ] Conflicting write-scope todo.\n" - f" \n", + "# Active Goal State\n\n## Agent Todo\n\n", encoding="utf-8", ) registry_path.parent.mkdir(parents=True) @@ -76,28 +73,43 @@ def write_fixture(root: Path) -> tuple[Path, Path]: + "\n", encoding="utf-8", ) + worker = project.parent / "worker" + for args in [ + ("init", "--initial-branch", "main"), + ("remote", "add", "origin", "https://example.invalid/loopx/lease-fixture.git"), + ("-c", "user.name=LoopX Fixture", "-c", "user.email=fixture@example.invalid", + "commit", "--allow-empty", "-m", "fixture"), + ("worktree", "add", "-b", "worker", str(worker)), + ]: + subprocess.run(["git", "-C", str(project), *args], check=True, capture_output=True, text=True) + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, handoff_mode="hard_lease", leases=[], todos=[ + { + "schema_version": "todo_item_v0", "todo_id": todo_id, "role": "agent", + "status": "open", "done": False, "text": text, "archive_state": "active", + "source_section": "Agent Todo", "index": index, "task_class": "advancement_task", + "action_kind": "validate", "task_domain": "validation", "claimed_by": owner, + "task_repository": "git:example.invalid/loopx/lease-fixture", + "required_write_scopes": [scope], + } + for index, (todo_id, text, owner, scope) in enumerate([ + (TODO_A, "First independently claimable todo.", "codex-main-control", "loopx/**"), + (TODO_B, "Second independently claimable todo.", None, "docs/**"), + (TODO_C, "Conflicting write-scope todo.", None, "loopx/cli_commands/todo.py"), + ], 1) + ], + ) + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state_file, provider=provider) return registry_path, state_file -def set_claimed_by(state_file: Path, *, todo_id: str, owner: str | None) -> None: - lines = state_file.read_text(encoding="utf-8").splitlines() - for index, line in enumerate(lines): - if f"todo_id={todo_id}" not in line: - continue - line = re.sub(r"\s+claimed_by=[^\s<>]+", "", line) - if owner: - line = line.replace(" -->", f" claimed_by={owner} -->") - lines[index] = line - state_file.write_text("\n".join(lines) + "\n", encoding="utf-8") - return - raise AssertionError(f"todo metadata not found: {todo_id}") - - def command( registry_path: Path, *args: str, check: bool = True, ) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PYTHONPATH"] = str(REPO_ROOT) return subprocess.run( [ sys.executable, @@ -109,7 +121,8 @@ def command( "json", *args, ], - cwd=REPO_ROOT, + cwd=registry_path.parents[2] / "worker", + env=env, check=check, text=True, capture_output=True, @@ -119,6 +132,8 @@ def command( def cli( registry_path: Path, *args: str, check: bool = True ) -> subprocess.CompletedProcess[str]: + if args[0] == "acquire": + args = (*args, "--write-worktree", str(registry_path.parents[2] / "worker")) return command(registry_path, "task-lease", *args, check=check) @@ -158,15 +173,9 @@ def inspected_lease(registry_path: Path, todo_id: str) -> dict[str, Any]: ) -def main() -> int: +def exercise_provider(provider: str) -> None: with tempfile.TemporaryDirectory(prefix="loopx-task-lease-smoke-") as tmp: - registry_path, state_file = write_fixture(Path(tmp)) - - set_claimed_by( - state_file, - todo_id=TODO_A, - owner="codex-main-control", - ) + registry_path, _state_file = write_fixture(Path(tmp), provider) first = payload( cli( registry_path, @@ -190,6 +199,10 @@ def main() -> int: assert first["lease"]["version"] == 1, first assert first["lease"]["lease_epoch"] == 1, first assert first["lease"]["acquire_ttl_seconds"] == 120, first + assert first["source_authority"] == provider + "_v0", first + assert first["lease"]["write_workspace"]["repository"] == ( + "git:example.invalid/loopx/lease-fixture" + ), first idempotent = payload( cli( @@ -296,30 +309,21 @@ def main() -> int: assert payload(claim_blocked_transfer)["error_code"] == ( "owner_conflicts_with_claim" ) - set_claimed_by(state_file, todo_id=TODO_A, owner=None) - transferred = payload( - cli( - registry_path, - "transfer", - "--goal-id", - GOAL_ID, - "--todo-id", - TODO_A, - "--owner", - "codex-main-control", - "--idempotency-key", - "turn-1", - "--new-owner", - "codex-side-bypass", - "--new-idempotency-key", - "side-transfer", - "--expected-version", - "2", - ) + assert inspected_lease(registry_path, TODO_A)["lease"] == renewed["lease"] + transfer_args = ( + "transfer", "--goal-id", GOAL_ID, "--todo-id", TODO_A, + "--owner", "codex-main-control", "--idempotency-key", "turn-1", + "--new-owner", "codex-side-bypass", "--new-idempotency-key", "side-transfer", + "--expected-version", "2", "--transfer-claim", ) + transferred = payload(cli(registry_path, *transfer_args)) assert transferred["lease"]["owner"] == "codex-side-bypass", transferred assert transferred["lease"]["version"] == 3, transferred assert transferred["lease"]["lease_epoch"] == 2, transferred + assert transferred["claimed_by"] == "codex-side-bypass", transferred + current = payload(command(registry_path, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", TODO_A)) + assert current["todo"]["claimed_by"] == "codex-side-bypass", current + assert current["authority_read"]["legacy_fallback_used"] is False, current stale_owner = lifecycle_cli( registry_path, @@ -330,9 +334,16 @@ def main() -> int: check=False, ) assert stale_owner.returncode == 1, stale_owner.stdout - assert payload(stale_owner)["error_code"] == "lease_cas_mismatch" + # Atomic claim handover rejects the previous actor before testing its lease key. + assert payload(stale_owner)["error_code"] == "owner_conflicts_with_claim", payload(stale_owner) after_stale_owner = inspected_lease(registry_path, TODO_A) assert after_stale_owner["lease"] == transferred["lease"], after_stale_owner + stale_key = lifecycle_cli( + registry_path, "renew", "codex-side-bypass", "turn-1", 3, check=False, + ) + assert stale_key.returncode == 1, stale_key.stdout + assert payload(stale_key)["error_code"] == "lease_cas_mismatch", payload(stale_key) + assert inspected_lease(registry_path, TODO_A)["lease"] == transferred["lease"] renewed_after_transfer = payload( lifecycle_cli( @@ -458,6 +469,9 @@ def main() -> int: ) assert stale_completion.returncode == 1, stale_completion.stdout assert payload(stale_completion)["error_code"] == "lease_cas_mismatch" + assert inspected_lease(registry_path, TODO_A)["lease"] == next_generation["lease"] + pending = payload(command(registry_path, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", TODO_A)) + assert pending["todo"]["status"] == "open", pending completed = payload( command( @@ -480,7 +494,23 @@ def main() -> int: ) ) assert completed["ok"] is True, completed - assert completed["task_lease_fence"]["lease_epoch"] == 3, completed + current = payload(command(registry_path, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", TODO_A)) + assert current["todo"]["status"] == "done", current + final_lease = inspected_lease(registry_path, TODO_A) + assert final_lease["lease"]["lease_epoch"] == 3, final_lease + assert final_lease["lease"]["status"] == "released" and not final_lease["active"], final_lease + historical = payload(cli(registry_path, *transfer_args)) + assert historical["original_receipt"] == transferred["original_receipt"], historical + assert historical["lease"]["status"] == "active", historical + assert inspected_lease(registry_path, TODO_A)["lease"] == final_lease["lease"] + replayed = payload(command(registry_path, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", TODO_A)) + assert replayed["todo"] == current["todo"], replayed + + +def main() -> int: + os.environ["NODE_OPTIONS"] = os.environ.get("NODE_OPTIONS", "") + " --experimental-sqlite" + for provider in ("file", "sqlite"): + exercise_provider(provider) print("task-lease-runtime-smoke ok") return 0 diff --git a/examples/control_plane/todo-archive-completed-smoke.py b/examples/control_plane/todo-archive-completed-smoke.py index 70d8923454..9b676cb372 100644 --- a/examples/control_plane/todo-archive-completed-smoke.py +++ b/examples/control_plane/todo-archive-completed-smoke.py @@ -17,6 +17,7 @@ completed_todo_archive_warning, completed_todo_count, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 from loopx.status import parse_active_state_todos # noqa: E402 GOAL_ID = "todo-archive-completed-goal" @@ -47,6 +48,7 @@ def state_text() -> str: + "- [ ] [P2] Keep monitor work after completed items visible.\n\n" "## Completed Work Archive\n\n" "- [x] [P1] Previously archived completed work.\n" + " \n" ) @@ -80,7 +82,7 @@ def user_role_state_text() -> str: ) -def write_fixture(root: Path, *, text: str | None = None) -> tuple[Path, Path, Path]: +def write_fixture(root: Path, *, text: str | None = None, import_authority: bool = True) -> tuple[Path, Path, Path]: project = root / "project" runtime = root / "runtime" state_file = ".codex/goals/todo-archive-completed-goal/ACTIVE_GOAL_STATE.md" @@ -119,6 +121,8 @@ def write_fixture(root: Path, *, text: str | None = None) -> tuple[Path, Path, P + "\n", encoding="utf-8", ) + if import_authority: + import_fixture_authority(registry_path, GOAL_ID) return registry_path, runtime, state_path @@ -312,7 +316,7 @@ def main() -> int: assert updated.count("[P1] Completed implementation lane 1.") == 1 assert updated.count("[P1] Completed implementation lane 2.") == 1 assert "excluded_agents=codex-reviewer" in updated - assert "updated_at: 2026-01-01T00:00:00+00:00" not in updated + assert "updated_at: 2026-01-01T00:00:00+00:00" in updated status_after = run_cli(registry_path, runtime, "status") assert status_after["ok"] is True, status_after @@ -438,8 +442,9 @@ def main() -> int: assert lineage_status["ok"] is True, lineage_status assert lineage_status["contract_errors"] == [], lineage_status - state_path.write_text( - archived_lineage + before_authority = run_cli(registry_path, runtime, "todo", "list", "--goal-id", GOAL_ID) + invalid_rows = ( + "" + "- [ ] [P1] Contradictory archived work item.\n" + " \n" @@ -448,11 +453,24 @@ def main() -> int: + "task_class=advancement_task excluded_agents=codex-reviewer -->\n" + "- [x] [P1] Empty-status archived work item.\n" + " \n", - encoding="utf-8", + + "task_class=advancement_task excluded_agents=codex-reviewer -->\n" ) + state_path.write_text(archived_lineage + invalid_rows, encoding="utf-8") invalid_status = run_cli(registry_path, runtime, "status", check=False) + assert invalid_status["ok"] is True, invalid_status + after_authority = run_cli(registry_path, runtime, "todo", "list", "--goal-id", GOAL_ID) + assert after_authority["todos"] == before_authority["todos"] + assert after_authority["authority_read"]["provider_revision"] == before_authority["authority_read"]["provider_revision"] + # These diagnostics still belong to explicit legacy source inspection, + # before import. Display text outside canonical regions grants no work. + source_registry, source_runtime, source_state = write_fixture( + Path(tmp) / "invalid-source", text=state_text() + invalid_rows, + import_authority=False, + ) + before_source = source_state.read_bytes() + invalid_status = run_cli(source_registry, source_runtime, "status", check=False) assert invalid_status["ok"] is False, invalid_status + assert source_state.read_bytes() == before_source assert any( "todo_archived_open" in item and "executor exclusions" in item for item in invalid_status["contract_errors"] diff --git a/examples/control_plane/todo-concurrent-write-lock-smoke.py b/examples/control_plane/todo-concurrent-write-lock-smoke.py index ee88eb10eb..62938adca3 100644 --- a/examples/control_plane/todo-concurrent-write-lock-smoke.py +++ b/examples/control_plane/todo-concurrent-write-lock-smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Smoke-test active-state todo writers under a contended file lock.""" +"""Smoke-test concurrent canonical writes while readable projection is locked.""" from __future__ import annotations @@ -16,14 +16,16 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.file_lock import exclusive_file_lock # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 from loopx.status import parse_active_state_todos # noqa: E402 GOAL_ID = "todo-concurrent-write-lock-goal" -CHILD_ADD_TODO = "Child add should preserve the parent todo written under lock." -PARENT_ADD_TODO = "Parent todo written while child add waits on the lock." +CHILD_ADD_TODO = "Child add should preserve the concurrently committed parent todo." +PARENT_ADD_TODO = "Parent todo committed while child add awaits readable projection." UPDATE_TARGET_TODO = "Update target todo should keep unrelated concurrent additions." -PARENT_UPDATE_TODO = "Parent todo written while child update waits on the lock." +PARENT_UPDATE_TODO = "Parent todo committed while child update awaits readable projection." def write_fixture(root: Path) -> tuple[Path, Path]: @@ -73,6 +75,7 @@ def write_fixture(root: Path) -> tuple[Path, Path]: + "\n", encoding="utf-8", ) + import_fixture_authority(registry_path, GOAL_ID) return registry_path, state_file @@ -118,14 +121,33 @@ def wait_json(process: subprocess.Popen[str]) -> dict[str, Any]: def assert_waiting_on_lock(process: subprocess.Popen[str], label: str) -> None: time.sleep(0.35) - assert process.poll() is None, ( - f"{label} process finished before the parent released the lock" + if process.poll() is not None: + stdout, stderr = process.communicate() + raise AssertionError(f"{label} finished before display delivery: {stdout} {stderr}") + + +def authority(registry_path: Path) -> dict[str, Any]: + registry = json.loads(registry_path.read_text(encoding="utf-8")) + result = read_canonical_todos_if_promoted( + runtime_root=Path(registry["common_runtime_root"]), goal_id=GOAL_ID, ) + assert result is not None and result["legacy_fallback_used"] is False, result + return result -def append_agent_todo(state_file: Path, text: str) -> None: - with state_file.open("a", encoding="utf-8") as file: - file.write(f"- [ ] {text}\n") +def wait_for_committed_texts( + registry_path: Path, texts: set[str], *, blocked_todo_id: str | None = None, +) -> None: + # The five-second display-lock deadline is unchanged. Authority commits + # must be observable independently while both CLI callers await projection. + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + rows = authority(registry_path)["todos"] + if texts <= {row["text"] for row in rows} and (blocked_todo_id is None or + any(row["todo_id"] == blocked_todo_id and row["status"] == "blocked" for row in rows)): + return + time.sleep(0.05) + raise AssertionError("canonical writes did not commit before display delivery") def agent_items(state_file: Path) -> list[dict[str, Any]]: @@ -141,24 +163,24 @@ def main() -> int: with tempfile.TemporaryDirectory(prefix="loopx-todo-lock-smoke-") as tmp: registry_path, state_file = write_fixture(Path(tmp)) + child_add = ("todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--text", CHILD_ADD_TODO, "--operation-id", "child-add") with exclusive_file_lock(state_file): - add_process = start_cli( - registry_path, - "todo", - "add", - "--goal-id", - GOAL_ID, - "--role", - "agent", - "--text", - CHILD_ADD_TODO, - ) + add_process = start_cli(registry_path, *child_add) + parent_add = start_cli(registry_path, "todo", "add", "--goal-id", GOAL_ID, + "--role", "agent", "--text", PARENT_ADD_TODO, + "--operation-id", "parent-add") assert_waiting_on_lock(add_process, "todo add") - append_agent_todo(state_file, PARENT_ADD_TODO) - add_result = wait_json(add_process) - assert add_result["added"] is True, add_result - assert find_item(state_file, CHILD_ADD_TODO), state_file.read_text(encoding="utf-8") - assert find_item(state_file, PARENT_ADD_TODO), state_file.read_text(encoding="utf-8") + wait_for_committed_texts(registry_path, {CHILD_ADD_TODO, PARENT_ADD_TODO}) + assert wait_json(add_process)["added"] is True + assert wait_json(parent_add)["added"] is True + assert find_item(state_file, CHILD_ADD_TODO) + assert find_item(state_file, PARENT_ADD_TODO) + before_replay = authority(registry_path) + run_cli(registry_path, *child_add) + after_replay = authority(registry_path) + assert after_replay["provider_revision"] == before_replay["provider_revision"] + assert after_replay["todos"] == before_replay["todos"] target = run_cli( registry_path, @@ -173,34 +195,34 @@ def main() -> int: ) target_id = target["todo_id"] + child_update = ("todo", "update", "--goal-id", GOAL_ID, "--todo-id", target_id, + "--agent-id", "codex-side-bypass", "--status", "blocked", + "--reason", "waiting-on-concurrent-state", "--claimed-by", + "codex-side-bypass", "--update-operation-id", "child-update") with exclusive_file_lock(state_file): - update_process = start_cli( - registry_path, - "todo", - "update", - "--goal-id", - GOAL_ID, - "--todo-id", - target_id, - "--agent-id", - "codex-side-bypass", - "--status", - "blocked", - "--reason", - "waiting-on-concurrent-state", - "--claimed-by", - "codex-side-bypass", - ) + update_process = start_cli(registry_path, *child_update) + parent_update = start_cli(registry_path, "todo", "add", "--goal-id", GOAL_ID, + "--role", "agent", "--text", PARENT_UPDATE_TODO, + "--operation-id", "parent-during-update") assert_waiting_on_lock(update_process, "todo update") - append_agent_todo(state_file, PARENT_UPDATE_TODO) - update_result = wait_json(update_process) - assert update_result["changed"] is True, update_result + wait_for_committed_texts(registry_path, {PARENT_UPDATE_TODO}, blocked_todo_id=target_id) + assert next(row for row in authority(registry_path)["todos"] + if row["todo_id"] == target_id)["status"] == "blocked" + assert wait_json(update_process)["changed"] is True + assert wait_json(parent_update)["added"] is True updated_target = find_item(state_file, UPDATE_TARGET_TODO) assert updated_target["status"] == "blocked", updated_target assert updated_target["reason"] == "waiting-on-concurrent-state", updated_target assert updated_target["claimed_by"] == "codex-side-bypass", updated_target assert find_item(state_file, PARENT_UPDATE_TODO), state_file.read_text(encoding="utf-8") + before_replay = authority(registry_path) + run_cli(registry_path, *child_update) + after_replay = authority(registry_path) + assert after_replay["provider_revision"] == before_replay["provider_revision"] + assert after_replay["todos"] == before_replay["todos"] + assert len([row for row in after_replay["todos"] if row["text"] == PARENT_UPDATE_TODO]) == 1 + print("todo-concurrent-write-lock-smoke ok") return 0 diff --git a/examples/control_plane/todo-deferred-capacity-cli-smoke.py b/examples/control_plane/todo-deferred-capacity-cli-smoke.py index 5a54de1d64..874e929e17 100644 --- a/examples/control_plane/todo-deferred-capacity-cli-smoke.py +++ b/examples/control_plane/todo-deferred-capacity-cli-smoke.py @@ -87,7 +87,7 @@ def assert_deferred_add_and_capacity_resume() -> None: "--resume-when", CAPACITY_RESUME, ) - assert added["status"] == "deferred", added + assert added["todo"]["status"] == "deferred", added todo_id = added["todo_id"] item = next(item for item in parsed_items(state_file) if item["todo_id"] == todo_id) assert item["status"] == "deferred" and item["done"] is True, item @@ -187,7 +187,7 @@ def assert_deferred_add_and_capacity_resume() -> None: "capacity condition was satisfied", ) assert ( - reopened["status"] == "open" and reopened["resume_when"] is None + reopened["status"] == "applied" and reopened["changed"] is True ), reopened item = next(item for item in parsed_items(state_file) if item["todo_id"] == todo_id) assert item["status"] == "open" and item.get("resume_when") is None, item @@ -260,7 +260,7 @@ def assert_open_to_deferred_update() -> None: "--reason", "short worker capacity is not currently available", ) - assert updated["status"] == "deferred" and updated["status_changed"] is True, updated + assert updated["status"] == "applied" and updated["changed"] is True, updated item = next(item for item in parsed_items(state_file) if item["todo_id"] == todo_id) assert item["status"] == "deferred" and item["resume_when"] == CAPACITY_RESUME, item diff --git a/examples/control_plane/todo-durability-fixture-smoke.py b/examples/control_plane/todo-durability-fixture-smoke.py index 6fc1c3cdbd..7dc2f5ffd8 100644 --- a/examples/control_plane/todo-durability-fixture-smoke.py +++ b/examples/control_plane/todo-durability-fixture-smoke.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import subprocess import sys import tempfile from pathlib import Path @@ -15,6 +14,8 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.status import parse_active_state_todos # noqa: E402 +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority, run_json_cli # noqa: E402 from loopx.rollout_event_log import ( # noqa: E402 append_rollout_event, build_rollout_event, @@ -35,30 +36,14 @@ def run_cli(*args: str, registry_path: Path, runtime: Path) -> dict: - result = subprocess.run( - [ - sys.executable, - "-m", - "loopx.cli", - "--registry", - str(registry_path), - "--runtime-root", - str(runtime), - "--format", - "json", - *args, - ], - cwd=REPO_ROOT, - check=True, - capture_output=True, - text=True, - ) - return json.loads(result.stdout) + return run_json_cli(*args, registry_path=registry_path, runtime_root=runtime, + cwd=REPO_ROOT, include_returncode=False) def state_text() -> str: archived_lines = "".join( f"- [x] [P1] Archived completed work item {index}.\n" + f" \n" for index in range(1, ARCHIVED_DONE_TODOS + 1) ) return ( @@ -273,11 +258,7 @@ def assert_pr_resume_repository_binding() -> None: ], ambiguous -def main() -> int: - assert_pr_resume_repository_binding() - parsed = parse_active_state_todos(state_text(), rollout_events=[pr_merged_event()]) - assert_parseable_agent_todos(parsed["agent_todos"]) - +def assert_lifecycle(provider: str) -> None: with tempfile.TemporaryDirectory(prefix="loopx-todo-durability-") as tmp: registry_path, runtime = write_fixture(Path(tmp)) status_payload = run_cli("status", registry_path=registry_path, runtime=runtime) @@ -329,6 +310,26 @@ def main() -> int: assert "completed_todo_archive_warning" not in guard, guard first_todo_id = guard["agent_todo_summary"]["first_executable_items"][0]["todo_id"] + # Historical inspection above is read-only. Declare the fixture's + # existing claim owners before the reviewed import and normal writes. + registry = json.loads(registry_path.read_text(encoding='utf-8')) + registry['goals'][0]['coordination'] = { + 'agent_model': 'peer_v1', + 'registered_agents': ['fixture-agent', 'codex-product-capability', + 'codex-side-bypass', 'codex-reviewer', 'codex-builder'], + } + registry_path.write_text(json.dumps(registry), encoding='utf-8') + import_fixture_authority(registry_path, GOAL_ID, provider=provider) + original = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert original['source_authority'] == provider + '_v0' + assert original['decision_read_from_provider'] is True and original['legacy_fallback_used'] is False + assert len(original['todos']) == 5 + ARCHIVED_DONE_TODOS + assert sum(row['archive_state'] == 'archive' for row in original['todos']) == ARCHIVED_DONE_TODOS + native_guard = run_cli('quota', 'should-run', '--goal-id', GOAL_ID, + '--agent-id', 'fixture-agent', '--host-surface', 'generic_cli', + '--scheduler-owner', 'none', '--execution-mode', 'interactive', + registry_path=registry_path, runtime=runtime) + assert native_guard['selected_todo']['todo_id'] == first_todo_id lifecycle_result = run_cli( "todo", "complete", @@ -338,6 +339,7 @@ def main() -> int: "agent", "--todo-id", first_todo_id, + '--agent-id', 'fixture-agent', "--evidence", "fixture completed", "--next-agent-todo", @@ -350,18 +352,35 @@ def main() -> int: runtime=runtime, ) next_todo = lifecycle_result["next_todos"][0] - assert next_todo["todo"].startswith("[P1] Continue lifecycle"), lifecycle_result + canonical = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + rows = {row['todo_id']: row for row in canonical['todos']} + assert rows[next_todo['todo_id']]['text'].startswith('[P1] Continue lifecycle') + assert rows[first_todo_id]['status'] == 'done' + assert rows[first_todo_id]['successor_todo_ids'] == [next_todo['todo_id']] + assert len(canonical['todos']) == len(original['todos']) + 1 + for row in original['todos']: + if row['todo_id'] != first_todo_id: + assert rows[row['todo_id']] == row post_lifecycle_status = run_cli("status", registry_path=registry_path, runtime=runtime) post_lifecycle_item = attention_item(post_lifecycle_status) post_agent_todos = post_lifecycle_item["agent_todos"] assert post_agent_todos["open_count"] == 2, post_agent_todos + # The summary retains its documented non-open count, including deferred. assert post_agent_todos["done_count"] == 4, post_agent_todos assert post_agent_todos["deferred_count"] == 2, post_agent_todos assert post_agent_todos["first_open_items"][0]["priority"] == "P1", post_agent_todos assert post_agent_todos["first_open_items"][0]["todo_id"] == next_todo["todo_id"], post_agent_todos assert post_agent_todos["first_open_items"][0]["action_kind"] == "fixture_follow_up", post_agent_todos + + +def main() -> int: + assert_pr_resume_repository_binding() + parsed = parse_active_state_todos(state_text(), rollout_events=[pr_merged_event()]) + assert_parseable_agent_todos(parsed["agent_todos"]) + for provider in ('file', 'sqlite'): + assert_lifecycle(provider) print("todo-durability-fixture-smoke ok") return 0 diff --git a/examples/control_plane/todo-user-gate-scope-smoke.py b/examples/control_plane/todo-user-gate-scope-smoke.py index c3e022c8b0..f2cf7ff69b 100644 --- a/examples/control_plane/todo-user-gate-scope-smoke.py +++ b/examples/control_plane/todo-user-gate-scope-smoke.py @@ -15,10 +15,13 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.contract import check_contract # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 from loopx.control_plane.todos.todo_summary import active_state_todo_attention_item # noqa: E402 from loopx.quota import build_quota_should_run # noqa: E402 from loopx.status import collect_status, parse_active_state_todos # noqa: E402 -from loopx.todos import add_goal_todo, complete_goal_todo, update_goal_todo # noqa: E402 +from loopx.todos import ( # noqa: E402 + add_goal_todo, complete_goal_todo, list_goal_todos, update_goal_todo, +) GOAL_ID = "user-gate-scope-smoke" @@ -26,7 +29,7 @@ SIDE_AGENT = "codex-product-capability" -def write_fixture(root: Path) -> tuple[Path, Path, Path]: +def write_fixture(root: Path, *, import_authority: bool = True) -> tuple[Path, Path, Path]: repo = root / "repo" repo.mkdir() state = repo / "ACTIVE_GOAL_STATE.md" @@ -38,6 +41,8 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: "updated_at: 2026-06-26T00:00:00+00:00", "---", "", + "## User Todo", + "", "## Agent Todo", "", ] @@ -68,6 +73,8 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: ), encoding="utf-8", ) + if import_authority: + import_fixture_authority(registry, GOAL_ID) return repo, state, registry @@ -164,6 +171,128 @@ def assert_user_action_does_not_drive_active_state_attention() -> None: assert gate_item["waiting_on"] == "controller", gate_item + +def read_todo(registry: Path, todo_id: str) -> dict: + view = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, todo_id=todo_id) + assert view["authority_read"]["source_authority"] == "file_v0", view + assert view["authority_read"]["legacy_fallback_used"] is False, view + return view["todo"] + + +def assert_legacy_scope_diagnostics() -> None: + # Historical source validation remains readable before import. It cannot + # serve as a normal writer or alter an already canonical authority. + with tempfile.TemporaryDirectory(prefix="loopx-legacy-gate-diagnostics-") as tmp: + root = Path(tmp) + repo, state, registry = write_fixture(root, import_authority=False) + insert_before_agent_section( + state, + "- [ ] Approve an intentionally unscoped gate.\n" + " \n" + "- [ ] Track an unbound non-blocking user action.\n" + " \n" + "- [ ] Resolve a mismatched gate binding.\n" + f" \n" + "- [ ] Track an intentionally untyped user todo.\n" + " \n" + "- [ ] Approve a gate with an unregistered blocked agent.\n" + " \n" + "- [ ] Approve a gate with conflicting scope.\n" + f" ", + ) + state.write_text( + state.read_text(encoding="utf-8") + + "\n- [ ] Repair a removed continuation value.\n" + + " \n" + + "- [ ] Repair removed agent gate routing.\n" + + f" \n" + + "- [ ] Repair an unknown executor exclusion.\n" + + " \n" + + "- [ ] Repair malformed executor exclusion metadata.\n" + + " \n", + encoding="utf-8", + ) + original = state.read_bytes() + check = check_contract( + registry_path=registry, runtime_root_override=str(root / "runtime"), + scan_roots=[repo], limit=1, + ) + assert check["ok"] is False, check + for todo_id, diagnostic in ( + ("todo_unscoped_gate", "requires bound_agent"), + ("todo_unbound_action", "requires bound_agent"), + ("todo_mismatched_gate_binding", "must bind to blocks_agent"), + ("todo_untyped_user", "requires task_class"), + ("todo_bad_agent", "not registered"), + ("todo_both_scopes", "cannot set both"), + ("todo_removed_continuation", "removed continuation_policy"), + ("todo_removed_agent_gate", "removed blocks_agent routing"), + ("todo_unknown_exclusion", "unregistered agents"), + ("todo_malformed_exclusion", "malformed excluded_agents"), + ): + assert any(todo_id in error and diagnostic in error for error in check["errors"]), check + assert state.read_bytes() == original + + +def assert_imported_scope_repairs() -> None: + with tempfile.TemporaryDirectory(prefix="loopx-imported-gate-repair-") as tmp: + root = Path(tmp) + repo, state, registry = write_fixture(root, import_authority=False) + insert_before_agent_section( + state, + "- [ ] Approve an intentionally unscoped gate.\n" + " \n" + "- [ ] Track an unbound non-blocking user action.\n" + " \n" + "- [ ] Resolve a mismatched gate binding.\n" + f" \n" + "- [ ] Track an intentionally untyped user todo.\n" + " ", + ) + state.write_text( + state.read_text(encoding="utf-8") + + "\n- [ ] Repair removed agent gate routing.\n" + + f" \n", + encoding="utf-8", + ) + import_fixture_authority(registry, GOAL_ID) + for todo_id, role, changes, expected in ( + ("todo_unscoped_gate", "user", {"status": "done"}, {"status": "done"}), + ("todo_unbound_action", "user", {"bound_agent": SIDE_AGENT}, {"bound_agent": SIDE_AGENT}), + ("todo_mismatched_gate_binding", "user", {"bound_agent": SIDE_AGENT}, {"bound_agent": SIDE_AGENT}), + # The caller now declares the missing type/binding explicitly; + # closing old source data does not infer an approval or audience. + ("todo_untyped_user", "user", {"status": "done", "task_class": "user_action", "goal_bound": True}, + {"status": "done", "task_class": "user_action", "goal_bound": True}), + ("todo_removed_agent_gate", "agent", {"clear_blocks_agent": True}, {"blocks_agent": None}), + ): + original = read_todo(registry, todo_id) + source = state.read_bytes() + preview = update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo_id, + role=role, agent_id=PRIMARY_AGENT, dry_run=True, **changes, + ) + assert preview["changed"] is True, preview + assert read_todo(registry, todo_id) == original + assert state.read_bytes() == source + changed = update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo_id, + role=role, agent_id=PRIMARY_AGENT, **changes, + ) + assert changed["changed"] is True, changed + current = read_todo(registry, todo_id) + assert current["text"] == original["text"], current + for key, value in expected.items(): + assert current.get(key) == value, current + repaired_check = check_contract( + registry_path=registry, runtime_root_override=str(root / "runtime"), + scan_roots=[repo], limit=1, + ) + assert repaired_check["ok"] is True, repaired_check + + def main() -> int: assert_user_action_does_not_drive_active_state_attention() @@ -214,9 +343,10 @@ def main() -> int: task_class="user_action", agent_id=SIDE_AGENT, ) + user_action = {**user_action, **read_todo(registry, user_action["todo_id"])} assert user_action["task_class"] == "user_action", user_action assert user_action["bound_agent"] == SIDE_AGENT, user_action - assert user_action["claimed_by"] is None, user_action + assert user_action.get("claimed_by") is None, user_action goal_bound_action = add_goal_todo( registry_path=registry, @@ -227,7 +357,9 @@ def main() -> int: goal_bound=True, dry_run=True, ) - assert goal_bound_action["goal_bound"] is True, goal_bound_action + assert goal_bound_action["status"] == "planned", goal_bound_action + assert goal_bound_action["changed"] is True, goal_bound_action + assert len(list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"]) == 1 other_lane_only_status = collect_status( registry_path=registry, @@ -281,6 +413,7 @@ def main() -> int: task_class="advancement_task", claimed_by=SIDE_AGENT, ) + side_agent_todo = {**side_agent_todo, **read_todo(registry, side_agent_todo["todo_id"])} assert side_agent_todo["claimed_by"] == SIDE_AGENT, side_agent_todo blocked_agent_todo = add_goal_todo( @@ -301,6 +434,9 @@ def main() -> int: bound_agent=SIDE_AGENT, unblocks_todo_id=blocked_agent_todo["todo_id"], ) + approval_before = read_todo(registry, approval["todo_id"]) + target_before = read_todo(registry, blocked_agent_todo["todo_id"]) + source_before = state.read_bytes() assert_raises_message( lambda: complete_goal_todo( registry_path=registry, @@ -311,8 +447,11 @@ def main() -> int: evidence="wrong-lane response attempt", dry_run=True, ), - "response continuation is bound", + "bound_agent_mismatch", ) + assert read_todo(registry, approval["todo_id"]) == approval_before + assert read_todo(registry, blocked_agent_todo["todo_id"]) == target_before + assert state.read_bytes() == source_before approval_completed = complete_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -441,6 +580,7 @@ def main() -> int: task_class="user_gate", agent_id=SIDE_AGENT, ) + scoped = {**scoped, **read_todo(registry, scoped["todo_id"])} assert scoped["blocks_agent"] == SIDE_AGENT, scoped assert scoped["bound_agent"] == SIDE_AGENT, scoped assert f"blocks_agent={SIDE_AGENT}" in state.read_text(encoding="utf-8") @@ -454,6 +594,7 @@ def main() -> int: task_class="user_gate", global_gate=True, ) + global_gate = {**global_gate, **read_todo(registry, global_gate["todo_id"])} assert global_gate["global_gate"] is True, global_gate assert global_gate["goal_bound"] is True, global_gate assert "global_gate=true" in state.read_text(encoding="utf-8") @@ -467,22 +608,6 @@ def main() -> int: task_class="advancement_task", claimed_by=PRIMARY_AGENT, ) - complete_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - todo_id=str(agent_todo["todo_id"]), - role="agent", - claimed_by=PRIMARY_AGENT, - agent_id=PRIMARY_AGENT, - evidence="fixture validation", - next_user_todo="Approve publishing the release notes.", - next_user_task_class="user_gate", - ) - state_text = state.read_text(encoding="utf-8") - assert "Approve publishing the release notes." in state_text, state_text - assert f"blocks_agent={PRIMARY_AGENT}" in state_text, state_text - assert f"bound_agent={PRIMARY_AGENT}" in state_text, state_text - assert_raises_message( lambda: update_goal_todo( registry_path=registry, @@ -496,210 +621,62 @@ def main() -> int: "global_gate is only valid for user_gate todos", ) - clean_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert clean_check["ok"] is True, clean_check - - insert_before_agent_section( - state, - "- [ ] Approve an intentionally unscoped gate.\n" - " ", - ) - bad_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert bad_check["ok"] is False, bad_check - assert any("todo_unscoped_gate" in item for item in bad_check["errors"]), bad_check - - closed = update_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - todo_id="todo_unscoped_gate", - role="user", - agent_id=PRIMARY_AGENT, - status="done", - ) - assert closed["changed"] is True, closed - repaired_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert repaired_check["ok"] is True, repaired_check - - insert_before_agent_section( - state, - "- [ ] Track an unbound non-blocking user action.\n" - " ", - ) - unbound_action_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert unbound_action_check["ok"] is False, unbound_action_check - assert any( - "todo_unbound_action" in item and "requires bound_agent" in item - for item in unbound_action_check["errors"] - ), unbound_action_check - rebound_action = update_goal_todo( + complete_goal_todo( registry_path=registry, goal_id=GOAL_ID, - todo_id="todo_unbound_action", - role="user", + todo_id=str(agent_todo["todo_id"]), + role="agent", + claimed_by=PRIMARY_AGENT, agent_id=PRIMARY_AGENT, - bound_agent=SIDE_AGENT, - ) - assert rebound_action["bound_agent"] == SIDE_AGENT, rebound_action - rebound_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, + evidence="fixture validation", + next_user_todo="Approve publishing the release notes.", + next_user_task_class="user_gate", ) - assert rebound_check["ok"] is True, rebound_check + state_text = state.read_text(encoding="utf-8") + assert "Approve publishing the release notes." in state_text, state_text + assert f"blocks_agent={PRIMARY_AGENT}" in state_text, state_text + assert f"bound_agent={PRIMARY_AGENT}" in state_text, state_text - insert_before_agent_section( - state, - "- [ ] Resolve a mismatched gate binding.\n" - f" ", - ) - mismatched_binding_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert mismatched_binding_check["ok"] is False, mismatched_binding_check - assert any( - "todo_mismatched_gate_binding" in item - and "must bind to blocks_agent" in item - for item in mismatched_binding_check["errors"] - ), mismatched_binding_check - repaired_gate_binding = update_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - todo_id="todo_mismatched_gate_binding", - role="user", - agent_id=PRIMARY_AGENT, - bound_agent=SIDE_AGENT, + goal_bound_action = add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="user", + text="Track a goal-wide owner note.", task_class="user_action", + goal_bound=True, ) - assert repaired_gate_binding["bound_agent"] == SIDE_AGENT, repaired_gate_binding - repaired_binding_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, + goal_bound_readback = read_todo(registry, goal_bound_action["todo_id"]) + assert goal_bound_readback["goal_bound"] is True, goal_bound_readback + assert goal_bound_readback.get("bound_agent") is None, goal_bound_readback + assert goal_bound_readback.get("global_gate") is not True, goal_bound_readback + complete_goal_todo( + registry_path=registry, goal_id=GOAL_ID, + todo_id=goal_bound_action["todo_id"], role="user", agent_id=PRIMARY_AGENT, + evidence="fixture owner note acknowledged", ) - assert repaired_binding_check["ok"] is True, repaired_binding_check + assert read_todo(registry, goal_bound_action["todo_id"])["status"] == "done" - insert_before_agent_section( - state, - "- [ ] Track an intentionally untyped user todo.\n" - " ", - ) - untyped_check = check_contract( + clean_check = check_contract( registry_path=registry, runtime_root_override=str(root / "runtime"), scan_roots=[repo], limit=1, ) - assert untyped_check["ok"] is False, untyped_check - assert any("todo_untyped_user" in item and "requires task_class" in item for item in untyped_check["errors"]), untyped_check - - closed_untyped = update_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - todo_id="todo_untyped_user", - role="user", - agent_id=PRIMARY_AGENT, - status="done", - ) - assert closed_untyped["changed"] is True, closed_untyped + assert clean_check["ok"] is True, clean_check + original = state.read_bytes() + before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID) insert_before_agent_section( state, - "- [ ] Approve a gate with an unregistered blocked agent.\n" - " \n" - "- [ ] Approve a gate with conflicting scope.\n" - f" ", - ) - invalid_scope_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert invalid_scope_check["ok"] is False, invalid_scope_check - assert any("todo_bad_agent" in item and "not registered" in item for item in invalid_scope_check["errors"]), invalid_scope_check - assert any("todo_both_scopes" in item and "cannot set both" in item for item in invalid_scope_check["errors"]), invalid_scope_check - - state.write_text( - state.read_text(encoding="utf-8") - + "\n- [ ] Repair a removed continuation value.\n" - + " \n" - + "- [ ] Repair removed agent gate routing.\n" - + f" \n" - + "- [ ] Repair an unknown executor exclusion.\n" - + " \n" - + "- [ ] Repair malformed executor exclusion metadata.\n" - + " \n", - encoding="utf-8", - ) - hard_cut_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert hard_cut_check["ok"] is False, hard_cut_check - assert any( - "todo_removed_continuation" in item and "removed continuation_policy" in item - for item in hard_cut_check["errors"] - ), hard_cut_check - assert any( - "todo_removed_agent_gate" in item and "removed blocks_agent routing" in item - for item in hard_cut_check["errors"] - ), hard_cut_check - assert any( - "todo_unknown_exclusion" in item and "unregistered agents" in item - for item in hard_cut_check["errors"] - ), hard_cut_check - assert any( - "todo_malformed_exclusion" in item and "malformed excluded_agents" in item - for item in hard_cut_check["errors"] - ), hard_cut_check - - repaired_agent_gate = update_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - todo_id="todo_removed_agent_gate", - role="agent", - agent_id=PRIMARY_AGENT, - clear_blocks_agent=True, - ) - assert repaired_agent_gate["blocks_agent"] is None, repaired_agent_gate - repaired_hard_cut_check = check_contract( - registry_path=registry, - runtime_root_override=str(root / "runtime"), - scan_roots=[repo], - limit=1, - ) - assert not any( - "todo_removed_agent_gate" in item - for item in repaired_hard_cut_check["errors"] - ), repaired_hard_cut_check + "- [ ] An injected projection cannot add a gate.\n" + f" ", + ) + after = list_goal_todos(registry_path=registry, goal_id=GOAL_ID) + assert after["todos"] == before["todos"], after + assert after["authority_read"]["provider_revision"] == before["authority_read"]["provider_revision"], after + assert state.read_bytes() != original + state.write_bytes(original) + + assert_legacy_scope_diagnostics() + assert_imported_scope_repairs() print("todo-user-gate-scope-smoke ok") return 0 diff --git a/examples/custom-runtime-minimal-cli-turn-smoke.py b/examples/custom-runtime-minimal-cli-turn-smoke.py index 76c73ba995..26b42791e2 100755 --- a/examples/custom-runtime-minimal-cli-turn-smoke.py +++ b/examples/custom-runtime-minimal-cli-turn-smoke.py @@ -24,6 +24,7 @@ GOAL_ID = "custom-runtime-min" AGENT_ID = "custom-host-agent" +TURN_ID = "custom-runtime-fixture-turn" MARKER_NAME = "public-marker.txt" MARKER_BODY = "ok-from-custom-runtime\n" ADVANCED_TURN_SMOKE = ( @@ -106,8 +107,10 @@ def write_project_fixture(root: Path) -> tuple[Path, Path, Path]: return project, registry_path, state_file -def assert_mainstream_cli_turn() -> None: +def assert_mainstream_cli_turn(provider: str) -> None: from loopx.status import parse_active_state_todos + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + from loopx.control_plane.testing.canary_harness import import_fixture_authority assert WAKE_TRIGGER, "host must own a wake trigger" assert "quota should-run" in REENTRY_INSTRUCTION @@ -116,6 +119,7 @@ def assert_mainstream_cli_turn() -> None: with tempfile.TemporaryDirectory(prefix="loopx-custom-runtime-min-") as tmp: project, registry_path, state_file = write_project_fixture(Path(tmp)) + import_fixture_authority(registry_path, GOAL_ID, provider=provider) status = run_cli(registry_path, "status", "--scan-root", str(project)) assert status.get("ok") is True, status @@ -160,6 +164,8 @@ def assert_mainstream_cli_turn() -> None: AGENT_ID, "--runtime-profile", "generic_cli", + "--todo-id", todo_id, + "--turn-instance-id", TURN_ID, "--scan-path", str(project), ) @@ -184,12 +190,13 @@ def assert_mainstream_cli_turn() -> None: AGENT_ID, "--agent-id", AGENT_ID, + "--turn-instance-id", TURN_ID, "--evidence", "public marker written under project root", - "--no-follow-up", ) assert completed.get("changed") is True, completed - assert completed.get("no_followup") is True, completed + assert completed["completion_continuation"] == "active_goal", completed + assert completed["successor_todo_ids"] == [], completed summary = parse_active_state_todos(state_file.read_text(encoding="utf-8")) item = next( @@ -198,12 +205,33 @@ def assert_mainstream_cli_turn() -> None: if row.get("todo_id") == todo_id ) assert item.get("done") is True, item + canonical = read_canonical_todos_if_promoted( + runtime_root=Path(tmp) / "runtime", goal_id=GOAL_ID, + ) + stored = next(row for row in canonical["todos"] if row["todo_id"] == todo_id) + assert stored["status"] == "done" and stored["claimed_by"] == AGENT_ID, stored refreshed = run_cli( registry_path, "refresh-state", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, + "--agent-lane", AGENT_ID, + "--progress-scope", "agent_lane", + "--todo-id", todo_id, + "--turn-instance-id", TURN_ID, + "--classification", "validated_progress", + "--delivery-batch-scale", "multi_surface", + "--delivery-outcome", "outcome_progress", + "--delivery-boundary", "in_flight_continuation", + "--progress-result-class", "advanced", + "--progress-surface-id", "custom-runtime-cli", + "--progress-work-item-id", todo_id, + "--progress-hypothesis-id", "marker-terminal-single-debit", + "--progress-probe-kind", "real-cli-artifact-readback", + "--progress-coverage-scope-id", "canonical-cli-turn", + "--progress-evidence-id", "artifact:public-marker", "--no-global-sync", ) assert refreshed.get("ok") is True, refreshed @@ -217,10 +245,12 @@ def assert_mainstream_cli_turn() -> None: GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", todo_id, + "--turn-instance-id", TURN_ID, "--slots", "1", "--source", - "controller", + "heartbeat", "--execute", "--scan-path", str(project), @@ -230,10 +260,25 @@ def assert_mainstream_cli_turn() -> None: after = spent["quota_event"]["after"] assert after["spent_slots"] == 1, spent assert after["allowed_slots"] == 5, spent + closed = run_cli( + registry_path, "todo", "complete", "--goal-id", GOAL_ID, + "--todo-id", todo_id, "--claimed-by", AGENT_ID, "--agent-id", AGENT_ID, + "--turn-instance-id", TURN_ID, + "--evidence", "public marker written under project root", "--no-follow-up", + ) + assert closed["completion_continuation"] == "no_followup", closed + replay = run_cli( + registry_path, "quota", "spend-slot", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", todo_id, + "--turn-instance-id", TURN_ID, "--slots", "1", "--source", "heartbeat", + "--execute", "--scan-path", str(project), + ) + assert replay["appended"] is False and replay["idempotent_replay"] is True, replay def main() -> int: - assert_mainstream_cli_turn() + for provider in ("file", "sqlite"): + assert_mainstream_cli_turn(provider) print("custom-runtime-minimal-cli-turn-smoke ok") return 0 diff --git a/examples/dreaming-proposal-decision-smoke.py b/examples/dreaming-proposal-decision-smoke.py index f368457487..f12326dd49 100644 --- a/examples/dreaming-proposal-decision-smoke.py +++ b/examples/dreaming-proposal-decision-smoke.py @@ -4,36 +4,24 @@ from __future__ import annotations import json -import subprocess import sys import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority, run_json_cli # noqa: E402 + GOAL_ID = "dreaming-decision-fixture" def run_cli(*args: str, registry_path: Path, runtime: Path) -> dict: - result = subprocess.run( - [ - sys.executable, - "-m", - "loopx.cli", - "--registry", - str(registry_path), - "--runtime-root", - str(runtime), - "--format", - "json", - *args, - ], - cwd=REPO_ROOT, - check=True, - capture_output=True, - text=True, - ) - return json.loads(result.stdout) + return run_json_cli(*args, registry_path=registry_path, runtime_root=runtime, + cwd=REPO_ROOT, include_returncode=False) def append_recorded_proposal( @@ -86,7 +74,7 @@ def append_recorded_proposal( f.write(json.dumps(record, sort_keys=True) + "\n") -def write_fixture(root: Path) -> tuple[Path, Path, Path, Path]: +def write_fixture(root: Path, provider: str) -> tuple[Path, Path, Path, Path]: project = root / "project" runtime = root / "runtime" state_file = f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" @@ -139,6 +127,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path, Path]: + "\n", encoding="utf-8", ) + import_fixture_authority(registry_path, GOAL_ID, provider=provider) append_recorded_proposal( runs_dir, generated_at="2026-01-01T00:00:00+00:00", @@ -162,9 +151,10 @@ def assert_no_quota_spend(runtime: Path) -> None: assert "quota_slot_spent" not in text, text -def main() -> int: +def assert_decisions(provider: str) -> None: with tempfile.TemporaryDirectory(prefix="loopx-dreaming-decision-") as tmp: - registry_path, runtime, state_path, index_path = write_fixture(Path(tmp)) + registry_path, runtime, state_path, index_path = write_fixture(Path(tmp), provider) + original = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) approve = run_cli( "dreaming", @@ -200,6 +190,15 @@ def main() -> int: assert approve["side_effects"]["runtime_history_appended"] is True, approve assert approve["side_effects"]["quota_spent"] is False, approve assert_no_quota_spend(runtime) + approved = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert approved['source_authority'] == provider + '_v0' + assert approved['decision_read_from_provider'] is True and approved['legacy_fallback_used'] is False + assert len(approved['todos']) == len(original['todos']) + 1 + promoted = next(row for row in approved['todos'] if row['todo_id'] == todo_id) + assert promoted['text'] == '[P1] Implement the approved dreaming follow-up as normal delivery.' + assert promoted['status'] == 'open' and promoted['task_class'] == 'advancement_task' + original_ids = {row['todo_id'] for row in original['todos']} + assert [row for row in approved['todos'] if row['todo_id'] in original_ids] == original['todos'] before_defer_state = state_path.read_text(encoding="utf-8") before_defer_index = index_path.read_text(encoding="utf-8") @@ -228,6 +227,7 @@ def main() -> int: assert defer["side_effects"]["runtime_history_appended"] is True, defer assert defer["side_effects"]["quota_spent"] is False, defer assert state_path.read_text(encoding="utf-8") == before_defer_state + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) == approved assert index_path.read_text(encoding="utf-8") != before_defer_index assert_no_quota_spend(runtime) @@ -257,6 +257,12 @@ def main() -> int: assert reject["side_effects"]["quota_spent"] is False, reject assert state_path.read_text(encoding="utf-8") == before_reject_state assert index_path.read_text(encoding="utf-8") == before_reject_index + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) == approved + + +def main() -> int: + for provider in ('file', 'sqlite'): + assert_decisions(provider) print("dreaming-proposal-decision-smoke ok") return 0 diff --git a/examples/explore-todo-result-node-linkage-smoke.py b/examples/explore-todo-result-node-linkage-smoke.py index 1df43c0a6d..e7812529e3 100644 --- a/examples/explore-todo-result-node-linkage-smoke.py +++ b/examples/explore-todo-result-node-linkage-smoke.py @@ -18,6 +18,7 @@ from loopx.capabilities.explore.todo_branch_plan import ( # noqa: E402 build_explore_todo_branch_plan, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 from loopx.control_plane.todos.contract import format_todo_metadata_line # noqa: E402 @@ -51,7 +52,7 @@ def run_loopx(registry: Path, *args: str) -> dict[str, object]: return json.loads(completed.stdout) -def write_fixture(root: Path) -> tuple[Path, Path]: +def write_fixture(root: Path, provider: str) -> tuple[Path, Path]: project = root / "project" project.mkdir() state_file = project / "ACTIVE_GOAL_STATE.md" @@ -84,10 +85,11 @@ def write_fixture(root: Path) -> tuple[Path, Path]: ), encoding="utf-8", ) + import_fixture_authority(registry, GOAL_ID, provider=provider) return registry, state_file -def check_todo_lifecycle() -> None: +def check_todo_lifecycle(provider: str) -> None: try: format_todo_metadata_line( todo_id="todo_invalid123", @@ -99,7 +101,7 @@ def check_todo_lifecycle() -> None: raise AssertionError("private path-shaped Explore node refs must fail closed") with tempfile.TemporaryDirectory(prefix="loopx-explore-todo-link-") as tmp: - registry, state_file = write_fixture(Path(tmp)) + registry, state_file = write_fixture(Path(tmp), provider) added = run_loopx( registry, "todo", @@ -118,7 +120,7 @@ def check_todo_lifecycle() -> None: "node_dead", ) todo_id = str(added["todo_id"]) - assert added["explore_result_node_refs"] == ["node_live", "node_dead"], added + assert added["todo"]["explore_result_node_refs"] == ["node_live", "node_dead"], added listed = run_loopx( registry, @@ -148,6 +150,10 @@ def check_todo_lifecycle() -> None: "--no-follow-up", ) assert completed["status"] == "done", completed + terminal = run_loopx(registry, "todo", "list", "--goal-id", GOAL_ID, + "--todo-id", todo_id)["todo"] + assert terminal["status"] == "done" + assert terminal["explore_result_node_refs"] == ["node_live", "node_dead"] state_text = state_file.read_text(encoding="utf-8") assert "explore_result_node_refs=node_live%2Cnode_dead" in state_text, state_text @@ -165,6 +171,11 @@ def check_todo_lifecycle() -> None: assert "Completed Work Archive" in archived, archived assert "explore_result_node_refs=node_live%2Cnode_dead" in archived, archived + archived_item = run_loopx(registry, "todo", "list", "--goal-id", GOAL_ID, + "--todo-id", todo_id)["todo"] + assert archived_item["archive_state"] == "archive", archived_item + assert archived_item["explore_result_node_refs"] == ["node_live", "node_dead"] + second = run_loopx( registry, "todo", @@ -197,7 +208,7 @@ def check_todo_lifecycle() -> None: "--todo-id", str(second["todo_id"]), )["todo"] - assert "explore_result_node_refs" not in cleared, cleared + assert cleared["explore_result_node_refs"] == [], cleared def check_planner_diagnostics() -> None: @@ -283,7 +294,8 @@ def check_planner_diagnostics() -> None: def main() -> int: - check_todo_lifecycle() + for provider in ("file", "sqlite"): + check_todo_lifecycle(provider) check_planner_diagnostics() print("explore-todo-result-node-linkage-smoke ok") return 0 diff --git a/examples/frontstage-pages-workflow-smoke.py b/examples/frontstage-pages-workflow-smoke.py index 20947cb22a..35eedb2b9a 100644 --- a/examples/frontstage-pages-workflow-smoke.py +++ b/examples/frontstage-pages-workflow-smoke.py @@ -22,6 +22,12 @@ def assert_absent(text: str, needle: str) -> None: raise AssertionError(f"workflow must not reference {needle!r}") +def step_source(text: str, name: str) -> str: + marker = f" - name: {name}\n" + assert text.count(marker) == 1, name + return text.split(marker, 1)[1].split("\n - ", 1)[0] + + def _extract_paths_block(text: str, event: str) -> str: """Extract the paths list under a given event (pull_request or push).""" marker = f"\n {event}:" @@ -163,6 +169,11 @@ def main() -> int: "npm run export:frontstage-share -- --base /loopx/ --out-dir ../../../output/frontstage-pages", }, step assert "permissions" not in workflow["jobs"]["build"], "build must not elevate token permissions" + stargazers = step_source(text, "Fetch complete GitHub stargazer history") + assert_contains(stargazers, "if: github.event_name != 'pull_request'") + assert_contains(stargazers, "GH_TOKEN: ${{ secrets.STAR_HISTORY_READ_TOKEN }}") + assert_contains(stargazers, "scripts/fetch-github-stargazers.sh") + assert_absent(stargazers, "GH_TOKEN: ${{ github.token }}") # Detaching a non-published product must preserve its own qualification lane. python_tests = (WORKFLOW.parent / "python-tests.yml").read_text(encoding="utf-8") diff --git a/examples/full-public-smokes-workflow-smoke.py b/examples/full-public-smokes-workflow-smoke.py index 486a68bf21..ca3c576675 100644 --- a/examples/full-public-smokes-workflow-smoke.py +++ b/examples/full-public-smokes-workflow-smoke.py @@ -49,7 +49,7 @@ def main() -> int: "--jobs \"${SMOKE_JOBS}\"", "--no-execute", "--json", - 'python -m pip install --disable-pip-version-check "jsonschema>=4.23,<5"', + "python -m pip install --disable-pip-version-check --editable '.[test]'", "actions/upload-artifact@", "$RUNNER_TEMP/loopx-smoke-results/full-public-shard-${{ matrix.shard }}.json", "${{ runner.temp }}/loopx-smoke-results/full-public-shard-${{ matrix.shard }}.json", diff --git a/examples/install-local-smoke.py b/examples/install-local-smoke.py index 34699eb2ef..1120ba7f43 100644 --- a/examples/install-local-smoke.py +++ b/examples/install-local-smoke.py @@ -833,7 +833,7 @@ def main() -> int: ) assert "```sh\nLOOPX_TURN=\n" in payload["task_body"], payload assert "not a command-prefix assignment" in payload["task_body"], payload - assert "guard; 2 stalls->replan" in payload["task_body"], payload + assert "obey projected replan/stop, never wake counts" in payload["task_body"], payload assert "Exact monitor settlement=no refresh/spend" in payload["task_body"], payload assert "Admitted work: settlement_plan even if artifacts unchanged" in payload["task_body"], payload assert "no-change=surface_only/no spend" not in payload["task_body"], payload diff --git a/examples/issue-fix-maintainer-correction-smoke.py b/examples/issue-fix-maintainer-correction-smoke.py index 9ef6bb1079..19a53ab67b 100644 --- a/examples/issue-fix-maintainer-correction-smoke.py +++ b/examples/issue-fix-maintainer-correction-smoke.py @@ -18,6 +18,9 @@ normalise_issue_fix_maintainer_correction_input, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.todos import list_goal_todos # noqa: E402 + GOAL_ID = "issue-fix-maintainer-correction-smoke" AGENT_ID = "issue-fix-agent" @@ -43,7 +46,7 @@ def run_cli(registry: Path, args: list[str]) -> dict[str, object]: return json.loads(result.stdout) -def write_fixture(root: Path) -> tuple[Path, Path, Path]: +def write_fixture(root: Path, provider: str) -> tuple[Path, Path, Path]: project = root / "project" project.mkdir() state_file = project / "ACTIVE_GOAL_STATE.md" @@ -57,6 +60,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: registry.write_text( json.dumps( { + "common_runtime_root": str(root / "runtime"), "goals": [ { "id": GOAL_ID, @@ -72,6 +76,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: ), encoding="utf-8", ) + import_fixture_authority(registry, GOAL_ID, provider=provider) metadata = root / "pr.json" metadata.write_text( json.dumps( @@ -107,10 +112,10 @@ def base_correction(**overrides: object) -> dict[str, object]: return payload -def main() -> int: +def check_corrections(provider: str) -> None: with tempfile.TemporaryDirectory(prefix="loopx-maintainer-correction-") as tmpdir: root = Path(tmpdir) - project, registry, metadata = write_fixture(root) + project, registry, metadata = write_fixture(root, provider) correction = correction_file(root, "actionable.json", base_correction()) common = [ "issue-fix", @@ -237,6 +242,14 @@ def main() -> int: assert quiet["todo_write"]["skip_reason"] == "unchanged_monitor_quiet", quiet assert quiet["todo_write_performed"] is False, quiet + native = list_goal_todos(registry_path=registry, goal_id=GOAL_ID) + assert native["authority_read"]["decision_read_from_provider"] is True, native + assert native["authority_read"]["legacy_fallback_used"] is False, native + patches = [item for item in native["todos"] + if item.get("action_kind") == "issue_fix_maintainer_correction_patch"] + assert len(patches) == 1, patches + assert patches[0]["claimed_by"] == AGENT_ID, patches + for unsafe in ( base_correction(raw_comment_body="raw body must not enter the contract"), base_correction(source_ref="https://localhost/reviews/42"), @@ -250,6 +263,11 @@ def main() -> int: else: raise AssertionError(f"unsafe correction input was accepted: {unsafe}") + + +def main() -> int: + for provider in ("file", "sqlite"): + check_corrections(provider) print("issue-fix-maintainer-correction-smoke: ok") return 0 diff --git a/examples/issue-fix-pr-lifecycle-smoke.py b/examples/issue-fix-pr-lifecycle-smoke.py index 854faf692b..e47bf3feae 100644 --- a/examples/issue-fix-pr-lifecycle-smoke.py +++ b/examples/issue-fix-pr-lifecycle-smoke.py @@ -21,6 +21,8 @@ ISSUE_FIX_PR_LIFECYCLE_MONITOR_SCHEMA_VERSION, build_issue_fix_pr_lifecycle_monitor_packet, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.todos import list_goal_todos # noqa: E402 from loopx.domain_packs.issue_fix import ( # noqa: E402 default_issue_fix_domain_state_ledger_path, issue_fix_pr_lifecycle_ledger_key, @@ -122,7 +124,7 @@ def assert_packet_shape(packet: dict[str, Any]) -> None: assert_public_safe(packet) -def main() -> int: +def check_lifecycle(provider: str) -> None: merged = build_issue_fix_pr_lifecycle_monitor_packet( url="https://github.com/huangruiteng/loopx/pull/1715", provider_payload={ @@ -350,6 +352,8 @@ def main() -> int: "## Agent Todo\n\n", encoding="utf-8", ) + import_fixture_authority(registry_path, "example-goal", provider=provider) + ledger = default_issue_fix_domain_state_ledger_path( project=project, goal_id="example-goal", @@ -552,8 +556,7 @@ def main() -> int: mutation_authority = reconciled_gate["todo_completion"][ "mutation_authority" ] - assert mutation_authority["mode"] == "registered_peer_actor", reconciled_gate - assert mutation_authority["actor_agent_id"] == "codex-test", reconciled_gate + assert mutation_authority["authority_mode"] == "registered_peer_actor", reconciled_gate assert reconciled_gate["rollout_event"]["appended"] is True, reconciled_gate assert "status=done" in state_file.read_text(encoding="utf-8") gate_projection = parse_active_state_todos( @@ -786,6 +789,18 @@ def main() -> int: material_rows ) + native = list_goal_todos(registry_path=registry_path, goal_id="example-goal") + assert native["authority_read"]["decision_read_from_provider"] is True, native + assert native["authority_read"]["legacy_fallback_used"] is False, native + rows = {item["todo_id"]: item for item in native["todos"]} + for todo_id in ("todo_merge_gate_1716", "todo_review_action_1716"): + assert rows[todo_id]["status"] == "done", rows[todo_id] + assert rows[todo_id]["last_actor_agent_id"] == "codex-test", rows[todo_id] + + +def main() -> int: + for provider in ("file", "sqlite"): + check_lifecycle(provider) print("issue-fix-pr-lifecycle-smoke: ok") return 0 diff --git a/examples/issue-fix-pr-review-reconcile-smoke.py b/examples/issue-fix-pr-review-reconcile-smoke.py index 040d4fc599..36075c0fcb 100644 --- a/examples/issue-fix-pr-review-reconcile-smoke.py +++ b/examples/issue-fix-pr-review-reconcile-smoke.py @@ -24,9 +24,10 @@ from loopx.capabilities.issue_fix.pr_review_ack import ( # noqa: E402 record_issue_fix_pr_review_ack, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 from loopx.heartbeat_prequota import run_heartbeat_pre_quota # noqa: E402 from loopx.status import parse_active_state_todos # noqa: E402 -from loopx.todos import update_goal_todo # noqa: E402 +from loopx.todos import list_goal_todos, update_goal_todo # noqa: E402 PR_URL = "https://github.com/huangruiteng/loopx/pull/1716" @@ -89,7 +90,7 @@ def reconcile_without_provider( ) -def main() -> int: +def check_reconciliation(provider: str) -> None: with tempfile.TemporaryDirectory( prefix="loopx-issue-fix-pr-review-reconcile-" ) as tmpdir: @@ -142,6 +143,8 @@ def main() -> int: encoding="utf-8", ) + import_fixture_authority(registry_path, "example-goal", provider=provider) + missing = reconcile_without_provider( registry_path=registry_path, runtime_root=runtime_root, @@ -330,7 +333,18 @@ def main() -> int: assert cli_payload["quota_spend_required"] is False, cli_payload assert todo(state_file, "todo_other_provider")["status"] == "open" + native = list_goal_todos(registry_path=registry_path, goal_id="example-goal") + assert native["authority_read"]["decision_read_from_provider"] is True, native + assert native["authority_read"]["legacy_fallback_used"] is False, native + rows = {item["todo_id"]: item for item in native["todos"]} + assert rows["todo_github_review"]["status"] == "done" + assert rows["todo_other_provider"]["status"] == "open" + + +def main() -> int: + for provider in ("file", "sqlite"): + check_reconciliation(provider) print("issue-fix-pr-review-reconcile-smoke: ok") return 0 diff --git a/examples/lark-goal-channel-human-gate-delivery-smoke.py b/examples/lark-goal-channel-human-gate-delivery-smoke.py index d2f4a344eb..3faffa02e5 100644 --- a/examples/lark-goal-channel-human-gate-delivery-smoke.py +++ b/examples/lark-goal-channel-human-gate-delivery-smoke.py @@ -13,6 +13,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from loopx.extensions.bundled import bundled_extension_manifest +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.testing.canary_harness import import_fixture_authority from loopx.extensions.lark.goal_channel_contracts import ( GOAL_CHANNEL_BINDING_SCHEMA_VERSION, read_goal_channel_binding, @@ -296,12 +298,14 @@ def write_project(root: Path) -> tuple[Path, Path]: return registry_path, binding_path -def main() -> None: +def assert_gate_delivery(provider: str) -> None: with tempfile.TemporaryDirectory(prefix="loopx-goal-channel-human-gate-") as raw: registry_path, binding_path = write_project(Path(raw)) bin_dir, fake_state_path = write_fake_lark_cli(Path(raw)) project = registry_path.parent.parent runtime_root = Path(raw) / "runtime" + import_fixture_authority(registry_path, GOAL_ID, provider=provider) + before = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) first_refresh = run_refresh( registry_path=registry_path, runtime_root=runtime_root, @@ -350,6 +354,11 @@ def main() -> None: == send_count ) assert gate_update["changed"] is True, gate_update + after = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) + gate = next(row for row in after["todos"] if row["todo_id"] == GATE_TODO_ID) + assert "after reviewing the diff" in gate["text"], gate + assert gate["status"] == "open" and gate["task_class"] == "user_gate", gate + assert after["provider_revision"] != before["provider_revision"] assert third["status"] == "sent_verified", third third_key = third["notification"]["idempotency_key"] assert third_key != first_key @@ -375,5 +384,10 @@ def main() -> None: print("lark-goal-channel-human-gate-delivery-smoke ok") +def main() -> None: + for provider in ("file", "sqlite"): + assert_gate_delivery(provider) + + if __name__ == "__main__": main() diff --git a/examples/loopx-chat-actions-smoke.py b/examples/loopx-chat-actions-smoke.py index 328971c8c6..63b921ef5e 100644 --- a/examples/loopx-chat-actions-smoke.py +++ b/examples/loopx-chat-actions-smoke.py @@ -24,6 +24,8 @@ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler # noqa: E402 from loopx.chat_store import ChatSessionStore # noqa: E402 from loopx.history import collect_history # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 def preview_request( @@ -194,6 +196,7 @@ def write_registry_fixture(root: Path) -> tuple[Path, Path]: + "\n", encoding="utf-8", ) + import_fixture_authority(registry, "goal-one") return registry, state @@ -965,6 +968,8 @@ def assert_http_action_api(root: Path) -> None: # A decision outcome is only valid on a User gate; the canonical owner # rejects it before any proposal is stored. + before_wrong_role = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id="goal-one") + before_wrong_role_display = state_path.read_bytes() code, misdirected_decision = request_json( f"{base_url}/api/actions/preview", method="POST", @@ -977,7 +982,12 @@ def assert_http_action_api(root: Path) -> None: }, ) assert code == 400, misdirected_decision - assert "decision_outcome is only valid" in misdirected_decision["error"], misdirected_decision + assert misdirected_decision["error_code"] == "invalid_action_preview", misdirected_decision + assert misdirected_decision["error"] == "Todo does not have the requested role", misdirected_decision + assert not any(proposal.get("idempotency_key") == "http-misdirected-decision" + for proposal in service.store.list()) + assert state_path.read_bytes() == before_wrong_role_display + assert read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id="goal-one") == before_wrong_role for index, (action_kind, params, gate_kind) in enumerate( [ diff --git a/examples/loopx-chat-contract-smoke.py b/examples/loopx-chat-contract-smoke.py index 24a95d5a2b..766e8cfa75 100644 --- a/examples/loopx-chat-contract-smoke.py +++ b/examples/loopx-chat-contract-smoke.py @@ -19,13 +19,16 @@ build_todo_review_preview, parse_agent_response, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 +from loopx.control_plane.todos.provider_projection import project_current_canonical_todos # noqa: E402 GOAL_ID = "loopx-chat-contract-smoke" TODO_TEXT = "[P0] Add a reviewable Goal Studio interaction." -def write_fixture(root: Path) -> tuple[Path, Path, Path]: +def write_fixture(root: Path, *, todo_anchor: bool = True) -> tuple[Path, Path, Path]: project = root / "project" runtime = root / "runtime" state_file = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" @@ -39,7 +42,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: "## Objective\n\n" "Keep LoopX Chat reviewable.\n\n" "## Next Action\n\n" - "- Review the next bounded proposal.\n", + "- Review the next bounded proposal.\n" + ("\n## Agent Todo\n" if todo_anchor else ""), encoding="utf-8", ) registry_path = project / ".loopx" / "registry.json" @@ -69,6 +72,7 @@ def write_fixture(root: Path) -> tuple[Path, Path, Path]: + "\n", encoding="utf-8", ) + import_fixture_authority(registry_path, GOAL_ID) return registry_path, state_file, project @@ -174,6 +178,7 @@ def main() -> None: assert "tool narration" not in malformed["message"], malformed original = state_file.read_text(encoding="utf-8") + authority_before = read_canonical_todos_if_promoted(runtime_root=root / "runtime", goal_id=GOAL_ID) preview = build_todo_review_preview( registry_path=registry_path, goal_id=GOAL_ID, @@ -191,6 +196,7 @@ def main() -> None: "action_kind": "loopx_chat_proposal", }, preview assert state_file.read_text(encoding="utf-8") == original + assert read_canonical_todos_if_promoted(runtime_root=root / "runtime", goal_id=GOAL_ID) == authority_before assert "state_file" not in json.dumps(preview), preview assert "project" not in json.dumps(preview), preview @@ -213,6 +219,8 @@ def main() -> None: assert str(project) not in json.dumps(no_write_receipt), no_write_receipt else: raise AssertionError("stale preview must be rejected") + assert state_file.read_text(encoding="utf-8") == original + assert read_canonical_todos_if_promoted(runtime_root=root / "runtime", goal_id=GOAL_ID) == authority_before applied = apply_todo_review_preview( registry_path=registry_path, @@ -222,7 +230,15 @@ def main() -> None: ) assert applied["ok"] is True, applied assert applied["applied"] is True, applied - assert applied["todo"]["todo_id"] == preview["todo"]["todo_id"], applied + # Canonical creation allocates its id at commit; preview locks the + # intent and provider revision, not a dry-run's provisional id. + for field in ("goal_id", "text", "status", "task_class", "action_kind"): + assert applied["todo"][field] == preview["todo"][field], applied + committed = read_canonical_todos_if_promoted(runtime_root=root / "runtime", goal_id=GOAL_ID) + assert committed["decision_read_from_provider"] is True and committed["legacy_fallback_used"] is False + assert len(committed["todos"]) == 1, committed + assert committed["todos"][0]["todo_id"] == applied["todo"]["todo_id"], committed + assert committed["todos"][0]["text"] == TODO_TEXT, committed receipt = applied["receipt"] assert receipt["schema_version"] == "loopx_chat_todo_receipt_v0", receipt assert receipt["receipt_id"], receipt @@ -236,6 +252,14 @@ def main() -> None: assert str(project) not in json.dumps(receipt), receipt assert state_file.read_text(encoding="utf-8").count(TODO_TEXT) == 1 + # Display edits cannot replace the committed record or create a + # duplicate. Restore the document before another explicit write. + display = state_file.read_text(encoding="utf-8") + state_file.write_text(display.replace(TODO_TEXT, "Spoofed display proposal"), encoding="utf-8") + assert read_canonical_todos_if_promoted(runtime_root=root / "runtime", goal_id=GOAL_ID) == committed + + state_file.write_text(display, encoding="utf-8") + retry_preview = build_todo_review_preview( registry_path=registry_path, goal_id=GOAL_ID, @@ -254,6 +278,37 @@ def main() -> None: assert retry_receipt["already_exists"] is True, retry_receipt assert retry_receipt["todo_id"] == receipt["todo_id"], retry_receipt assert state_file.read_text(encoding="utf-8").count(TODO_TEXT) == 1 + assert read_canonical_todos_if_promoted(runtime_root=root / "runtime", goal_id=GOAL_ID) == committed + + # An anchorless historical document is retained as a separate display + # recovery case. A successful canonical commit cannot repair its prose + # implicitly or turn display failure into a second business write. + recovery_root = root / "display-recovery" + recovery_registry, recovery_state, _ = write_fixture(recovery_root, todo_anchor=False) + original_narrative = recovery_state.read_text(encoding="utf-8") + recovery_preview = build_todo_review_preview(registry_path=recovery_registry, goal_id=GOAL_ID, text=TODO_TEXT) + recovery_applied = apply_todo_review_preview(registry_path=recovery_registry, goal_id=GOAL_ID, + text=TODO_TEXT, preview_id=recovery_preview["preview_id"]) + assert recovery_applied["applied"] is True, recovery_applied + assert recovery_state.read_text(encoding="utf-8") == original_narrative + accepted = read_canonical_todos_if_promoted(runtime_root=recovery_root / "runtime", goal_id=GOAL_ID) + assert len(accepted["todos"]) == 1, accepted + try: + project_current_canonical_todos(registry_path=recovery_registry, + runtime_root=recovery_root / "runtime", goal_id=GOAL_ID) + except ValueError as error: + assert "no projection anchor" in str(error), error + else: + raise AssertionError("an anchorless document must refuse implicit projection") + assert recovery_state.read_text(encoding="utf-8") == original_narrative + recovery_state.write_text(original_narrative + "\n## Agent Todo\n", encoding="utf-8") + delivered = project_current_canonical_todos(registry_path=recovery_registry, + runtime_root=recovery_root / "runtime", goal_id=GOAL_ID, + expected_provider_revision=accepted["provider_revision"]) + assert delivered["status"] == "delivered" and delivered["narrative_preserved"] is True, delivered + assert recovery_state.read_text(encoding="utf-8").startswith(original_narrative) + assert recovery_state.read_text(encoding="utf-8").count(TODO_TEXT) == 1 + assert read_canonical_todos_if_promoted(runtime_root=recovery_root / "runtime", goal_id=GOAL_ID) == accepted print("loopx-chat-contract-smoke: ok") diff --git a/examples/loopx-chat-server-smoke.py b/examples/loopx-chat-server-smoke.py index bec354db11..882e3b041f 100644 --- a/examples/loopx-chat-server-smoke.py +++ b/examples/loopx-chat-server-smoke.py @@ -19,6 +19,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 + GOAL_ID = "loopx-chat-server-smoke" SECOND_GOAL_ID = "loopx-chat-server-second-goal" TODO_TEXT = "[P0] Implement the approved Goal Studio review card." @@ -169,6 +173,8 @@ def write_fixture(root: Path) -> tuple[Path, Path]: + "\n", encoding="utf-8", ) + for goal_id in (GOAL_ID, SECOND_GOAL_ID): + import_fixture_authority(registry, goal_id) return registry, state_file diff --git a/examples/loopx-turn-path-delta-acceptance-smoke.py b/examples/loopx-turn-path-delta-acceptance-smoke.py index e0863556ea..3431108f53 100644 --- a/examples/loopx-turn-path-delta-acceptance-smoke.py +++ b/examples/loopx-turn-path-delta-acceptance-smoke.py @@ -16,6 +16,10 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.cli import main as cli_main # noqa: E402 +from loopx.control_plane.coordination.local_authority import ( # noqa: E402 + read_canonical_todos_if_promoted, +) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 GOAL_ID = "loopx-turn-path-delta-fixture" @@ -112,6 +116,8 @@ def _write_host(root: Path) -> Path: mode = sys.argv[1] marker = pathlib.Path({MARKER!r}) marker.write_text(mode, encoding="utf-8") +with marker.with_suffix(".calls").open("a", encoding="utf-8") as calls: + calls.write(mode + "\\n") material_packet = {{ "agent_id": {AGENT_ID!r}, "state": "active", @@ -181,6 +187,8 @@ def _run_turn( workspace: Path, host: Path, mode: str, + expect_canonical_refusal: bool = False, + resume_turn_key: str | None = None, ) -> dict[str, Any]: output = io.StringIO() with contextlib.redirect_stdout(output): @@ -200,8 +208,10 @@ def _run_turn( GOAL_ID, "--agent-id", AGENT_ID, - "--turn-instance-id", - f"path-delta-{mode}", + *( + ["--resume-turn-key", resume_turn_key] + if resume_turn_key else ["--turn-instance-id", f"path-delta-{mode}"] + ), "--project", str(workspace), "--host-adapter-command-json", @@ -215,6 +225,14 @@ def _run_turn( ] ) payload = json.loads(output.getvalue()) + if expect_canonical_refusal: + assert exit_code != 0, payload + assert payload["reason_code"] == "canonical_authority_required", payload + assert payload["write_admitted"] is False, payload + assert payload["journal_observation"]["recorded_effects"]["host_invoked"] is True, payload + assert payload["journal_observation"]["recorded_effects"]["quota_spent"] is False, payload + assert payload["journal_observation"]["recovery_decision"]["reinvoke_host"] is False, payload + return payload assert exit_code == 0, payload assert payload["status"] == "committed", payload assert payload["validation"]["status"] == "passed", payload @@ -226,19 +244,44 @@ def _run_records(runtime: Path) -> list[dict[str, Any]]: return [json.loads(line) for line in index.read_text(encoding="utf-8").splitlines()] -def main() -> int: - with tempfile.TemporaryDirectory(prefix="loopx-turn-path-delta-") as directory: +def assert_canonical_turn_path_delta(provider: str) -> None: + with tempfile.TemporaryDirectory(prefix=f"loopx-turn-path-delta-{provider}-") as directory: root = Path(directory) project, runtime, workspace, registry = _write_fixture(root) host = _write_host(root) - _run_turn( + state = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" + source_bytes = state.read_bytes() + refused = _run_turn( + registry=registry, + runtime=runtime, + project=project, + workspace=workspace, + host=host, + mode="material", + expect_canonical_refusal=True, + ) + # The Host has returned, but no Todo write/spend is admitted. Import + # the complete stopped fixture and recover that original Turn. + assert state.read_bytes() == source_bytes + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) is None + calls = (project / MARKER).with_suffix(".calls") + assert calls.read_text(encoding="utf-8").splitlines() == ["material"] + import_fixture_authority(registry, GOAL_ID, provider=provider) + original = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert original is not None + assert original["source_authority"] == f"{provider}_v0", original + assert {item["todo_id"] for item in original["todos"]} == {TODO_ID}, original + recovered = _run_turn( registry=registry, runtime=runtime, project=project, workspace=workspace, host=host, mode="material", + resume_turn_key=refused["resume_turn_key"], ) + assert recovered["resume_turn_key"] == refused["resume_turn_key"], recovered + assert calls.read_text(encoding="utf-8").splitlines() == ["material"] _run_turn( registry=registry, runtime=runtime, @@ -247,6 +290,19 @@ def main() -> int: host=host, mode="routine", ) + assert calls.read_text(encoding="utf-8").splitlines() == ["material", "routine"] + canonical = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert canonical is not None + assert canonical["source_authority"] == f"{provider}_v0", canonical + assert {item["todo_id"] for item in canonical["todos"]} == {TODO_ID}, canonical + before, after = original["todos"][0], canonical["todos"][0] + for field in ("todo_id", "text", "role", "status", "claimed_by", + "task_class", "action_kind", "priority", "successor_todo_ids"): + assert after.get(field) == before.get(field), (field, before, after) + mutable_result_fields = {"note", "evidence", "updated_at", "last_actor_agent_id"} + assert {k: v for k, v in after.items() if k not in mutable_result_fields} == { + k: v for k, v in before.items() if k not in mutable_result_fields + }, (before, after) records = _run_records(runtime) material = next( @@ -266,6 +322,10 @@ def main() -> int: assert "agent_vision" not in routine, routine assert routine["vision_checkpoint"]["decision"] == "unchanged_with_reason", routine + +def main() -> int: + for provider in ("file", "sqlite"): + assert_canonical_turn_path_delta(provider) print("loopx-turn-path-delta-acceptance-smoke ok") return 0 diff --git a/examples/macos-dashboard-launchagent-status-smoke.py b/examples/macos-dashboard-launchagent-status-smoke.py index a005124053..32ebd20062 100644 --- a/examples/macos-dashboard-launchagent-status-smoke.py +++ b/examples/macos-dashboard-launchagent-status-smoke.py @@ -388,10 +388,12 @@ def main() -> int: "#!/usr/bin/env bash\n" "if [[ \"$*\" == *\"--format json doctor\"* ]]; then\n" " if [[ -n \"${FAKE_RUNTIME_IDENTITY:-}\" ]]; then\n" - " printf '{\"service_runtime_identity\":%s}\\n' \"$FAKE_RUNTIME_IDENTITY\"\n" + " printf '{\"service_runtime_identity\":%s,\"python\":{\"executable\":%s}}\\n' \"$FAKE_RUNTIME_IDENTITY\" " + + shlex.quote(json.dumps(sys.executable)) + "\n" " exit 0\n" " fi\n" - " printf '%s\\n' '{\"release_manifest\":{\"manifest\":{\"release_id\":\"current-release\",\"package\":{\"version\":\"0.5.3\"},\"source\":{\"git_commit\":\"current-revision\"}}}}'\n" + " printf '{\"release_manifest\":{\"manifest\":{\"release_id\":\"current-release\",\"package\":{\"version\":\"0.5.3\"},\"source\":{\"git_commit\":\"current-revision\"}}},\"python\":{\"executable\":%s}}\\n' " + + shlex.quote(json.dumps(sys.executable)) + "\n" " exit 0\n" "fi\n" "echo loopx \"$@\"\n", @@ -456,6 +458,12 @@ def main() -> int: assert f"export CODEX_HOME={(home / '.codex').resolve()};" in default_chat_plist, default_chat_plist assert "export LOOPX_PYTHON=" in default_plist, default_plist assert "export LOOPX_PYTHON=" in default_chat_plist, default_chat_plist + # The selected console distribution owns its interpreter; another + # Python version on PATH must not replace this current doctor fact. + for plist in (status_plist, chat_plist): + shell = plistlib.loads(plist.read_bytes())["ProgramArguments"][2] + words = [word.removesuffix(";") for word in shlex.split(shell)] + assert f"LOOPX_PYTHON={sys.executable}" in words, shell assert "/loopx --registry" in default_plist, default_plist assert "/loopx-canary" not in default_plist, default_plist assert not (home / "Library" / "LaunchAgents" / "com.loopx.dashboard.plist").exists(), "retired dashboard LaunchAgent should not be installed" diff --git a/examples/multi-agent-role-successor-todos-smoke.py b/examples/multi-agent-role-successor-todos-smoke.py index a65d483f1c..8b2682e3cf 100644 --- a/examples/multi-agent-role-successor-todos-smoke.py +++ b/examples/multi-agent-role-successor-todos-smoke.py @@ -18,6 +18,8 @@ apply_role_successor_todos, first_successor_followup, ) +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 GOAL_ID = "multi-agent-successor-smoke" @@ -34,7 +36,8 @@ def write_minimal_state(path: Path) -> None: "", "## Agent Todo", "", - "- [ ] [P0] Run source action. ", + "- [ ] [P0] Run source action.", + " ", "", ] ), @@ -92,6 +95,9 @@ def main() -> int: runtime_root = root / "runtime" write_minimal_state(state_file) write_registry(registry, project=project, state_file=state_file, runtime_root=runtime_root) + import_fixture_authority(registry, GOAL_ID) + before = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) + display_before = state_file.read_bytes() preview = apply_role_successor_todos( registry_path=registry, @@ -139,6 +145,8 @@ def main() -> int: ) assert skipped["needed"] is False, skipped assert skipped["reason"] == "no_second_candidate", skipped + assert read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) == before + assert state_file.read_bytes() == display_before executed = apply_role_successor_todos( registry_path=registry, @@ -158,6 +166,24 @@ def main() -> int: state_text = state_file.read_text(encoding="utf-8") assert "claimed_by=agent-b" in state_text, state_text assert f"unblocks_todo_id={SOURCE_TODO_ID}" in state_text, state_text + after = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) + assert after["source_authority"] == "file_v0", after + records = {todo["todo_id"]: todo for todo in after["todos"]} + assert len(records) == 2, records + assert records[added["todo_id"]]["claimed_by"] == "agent-b", records + assert records[added["todo_id"]]["unblocks_todo_id"] == SOURCE_TODO_ID, records + original_source = next(todo for todo in before["todos"] if todo["todo_id"] == SOURCE_TODO_ID) + assert records[SOURCE_TODO_ID] == original_source, records + replay = apply_role_successor_todos( + registry_path=registry, goal_id=GOAL_ID, source_todo_id=SOURCE_TODO_ID, + current_agent_id="agent-a", role_id="evidence_runner", action="run_dev_eval", + successor_specs=[successor_spec()], decision_summary={"dev_promotion_candidate_count": 1}, + execute=True, + ) + assert replay["successors"][0]["already_exists"] is True, replay + assert replay["successors"][0]["todo_id"] == added["todo_id"], replay + assert read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) == after + display_after = state_file.read_bytes() try: apply_role_successor_todos( @@ -175,6 +201,8 @@ def main() -> int: assert "successor target_agent_id 'missing-agent' is not registered" in str(exc), exc else: raise AssertionError("missing successor target agent should fail closed") + assert read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=GOAL_ID) == after + assert state_file.read_bytes() == display_after print("multi-agent-role-successor-todos-smoke ok") return 0 diff --git a/examples/project/bootstrap-force-preserve-todos-smoke.py b/examples/project/bootstrap-force-preserve-todos-smoke.py index 9a28448ded..110b28e261 100644 --- a/examples/project/bootstrap-force-preserve-todos-smoke.py +++ b/examples/project/bootstrap-force-preserve-todos-smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Smoke-test canonical force refusal and explicit File-preserving reconnects.""" +"""Smoke-test canonical reconnect preservation and refusal of retired writes.""" from __future__ import annotations @@ -56,9 +56,11 @@ def main() -> int: assert canonical_state.read_bytes() == state_before assert (canonical_project / ".loopx/registry.json").read_bytes() == registry_before - # Existing force/Markdown migration assertions belong to the supported - # explicit File creation path, not the canonical default writer. - runtime = Path(tmp) / "file-runtime" + for provider in ("file", "sqlite"): + assert_canonical_reconnect(Path(tmp) / provider, provider) + + # The old opt-out no longer authorizes new Markdown Goal writers. + runtime = Path(tmp) / "unpromoted-runtime" configuration = runtime / "machine/configuration.json" configuration.parent.mkdir(parents=True) configuration.write_text(json.dumps({ @@ -71,81 +73,77 @@ def main() -> int: project = Path(tmp) / "project" project.mkdir() - initial = run_cli( - "--runtime-root", str(runtime), "bootstrap", - "--project", - str(project), - "--goal-id", - GOAL_ID, - "--objective", - "Exercise force reconnect preservation.", - "--no-global-sync", - ) - assert initial["ok"] is True, initial - assert initial["state_action"] == "created", initial - assert initial["storage_target"] == { - "schema_version": "loopx_new_goal_storage_target_v0", "provider": "file", - }, initial - state_file = Path(initial["state_file"]) - assert state_file == (project / ".loopx/goals/bootstrap-preserve-fixture/ACTIVE_GOAL_STATE.md").resolve() - - todo_state = "# Active Goal State\n\n## Agent Todo\n\n- [ ] Preserve this during reconnect.\n" - state_file.write_text(todo_state, encoding="utf-8") - - preserved = run_cli( - "--runtime-root", str(runtime), "bootstrap", - "--project", - str(project), - "--goal-id", - GOAL_ID, - "--objective", - "Exercise force reconnect preservation.", - "--write-scope", - "src/**", - "--force", - "--preserve-todos", - "--no-global-sync", - ) - assert preserved["ok"] is True, preserved - assert preserved["state_action"] == "kept-existing-preserve-todos", preserved - warning = preserved["force_bootstrap_warning"] - assert warning["will_replace_active_state"] is False, warning - assert warning["preserve_todos_requested"] is True, warning - assert "configure-goal --write-scope" in warning["recommended_scope_migration"], warning - migrated_state = state_file.read_text(encoding="utf-8") - assert "- [ ] Preserve this during reconnect." in migrated_state - assert "## User Todo / Owner Review Reading Queue" in migrated_state - assert preserved["todo_source_migration"] == { - "schema_version": "todo_source_section_migration_v0", - "added_roles": ["user"], - "applied": True, - }, preserved - assert goal_from_registry(project)["coordination"]["write_scope"] == ["src/**"] - - replaced = run_cli( + refused = run_cli( "--runtime-root", str(runtime), "bootstrap", "--project", str(project), "--goal-id", GOAL_ID, "--objective", - "Exercise force reconnect replacement warning.", - "--write-scope", - "docs/**", - "--force", + "Do not revive the retired Markdown writer.", "--no-global-sync", + check=False, ) - assert replaced["ok"] is True, replaced - assert replaced["state_action"] == "replaced", replaced - warning = replaced["force_bootstrap_warning"] - assert warning["will_replace_active_state"] is True, warning - assert warning["preserve_todos_requested"] is False, warning - assert "Preserve this during reconnect." not in state_file.read_text(encoding="utf-8") - assert goal_from_registry(project)["coordination"]["write_scope"] == ["docs/**"] + assert refused["ok"] is False, refused + assert refused["error_code"] == "canonical_authority_required", refused + assert not (project / ".loopx/registry.json").exists() + assert not (project / f".loopx/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md").exists() print("bootstrap-force-preserve-todos-smoke ok") return 0 +def assert_canonical_reconnect(root: Path, provider: str) -> None: + runtime = root / "runtime" + configuration = runtime / "machine/configuration.json" + configuration.parent.mkdir(parents=True) + configuration.write_text(json.dumps({ + "schema_version": "loopx_machine_configuration_v0", "namespaces": { + "goal_storage": {"schema_version": "loopx_goal_storage_defaults_v1", + "new_goal_provider": provider, "canonical_creation": True, + "new_goal_handoff_mode": "soft_claim"}, + }, + }), encoding="utf-8") + project = root / "project" + project.mkdir() + bootstrap = ( + "--runtime-root", str(runtime), "bootstrap", "--project", str(project), + "--goal-id", GOAL_ID, "--objective", "Preserve canonical Todos on reconnect.", + "--no-global-sync", + ) + initial = run_cli(*bootstrap) + assert initial["ok"] and initial["state_action"] == "created", initial + assert initial["storage_target"]["provider"] == provider, initial + state = Path(initial["state_file"]) + registry = project / ".loopx/registry.json" + prefix = ("--runtime-root", str(runtime), "--registry", str(registry)) + text = "[P1] Preserve canonical work — 保留任务与原始归属。" + added = run_cli(*prefix, "todo", "add", "--goal-id", GOAL_ID, + "--role", "agent", "--task-class", "advancement_task", + "--action-kind", "smoke", "--text", text) + assert added["added"] is True, added + read = ("todo", "list", "--goal-id", GOAL_ID) + before = run_cli(*prefix, *read) + assert before["authority_read"]["source_authority"] == f"{provider}_v0", before + assert before["authority_read"]["decision_read_from_provider"] is True, before + assert before["authority_read"]["legacy_fallback_used"] is False, before + stored = run_cli(*prefix, *read, "--todo-id", added["todo_id"])["todo"] + assert stored["text"] == text and stored["status"] == "open", stored + for force_args in (("--force",), ("--force", "--preserve-todos")): + state_before, registry_before = state.read_bytes(), registry.read_bytes() + refused = run_cli(*bootstrap, *force_args, check=False) + assert refused["ok"] is False, refused + assert "cannot rebuild existing state" in json.dumps(refused), refused + assert state.read_bytes() == state_before + assert registry.read_bytes() == registry_before + assert run_cli(*prefix, *read) == before + reconnected = run_cli(*bootstrap) + assert reconnected["ok"] is True, reconnected + assert reconnected["state_action"] == "kept-existing", reconnected + assert run_cli(*prefix, *read) == before + assert text in state.read_text(encoding="utf-8") + assert goal_from_registry(project)["coordination"]["storage_target"]["provider"] == provider + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/examples/project/goal-rollout-event-log-smoke.py b/examples/project/goal-rollout-event-log-smoke.py index 08323d4549..d5748768dc 100644 --- a/examples/project/goal-rollout-event-log-smoke.py +++ b/examples/project/goal-rollout-event-log-smoke.py @@ -21,6 +21,7 @@ rollout_event_log_path, summarize_rollout_events, ) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 def assert_public_safe_text(text: str) -> None: @@ -347,86 +348,113 @@ def main() -> None: assert_public_safe_text(log_path.read_text(encoding="utf-8")) - registry_path, cli_runtime_root, project, cli_goal_id = write_smoke_project(tmp_root / "auto") - claim_payload = run_loopx_cli( - "--registry", - str(registry_path), - "--runtime-root", - str(cli_runtime_root), - "--format", - "json", - "todo", - "claim", - "--goal-id", - cli_goal_id, - "--todo-id", - "todo_auto_rollout", - "--claimed-by", - "codex-main-control", - ) - assert claim_payload["rollout_event"]["event_kind"] == "todo_claim", claim_payload - - refresh_payload = run_loopx_cli( - "--registry", - str(registry_path), - "--runtime-root", - str(cli_runtime_root), - "--format", - "json", - "refresh-state", - "--goal-id", - cli_goal_id, - "--classification", - "automatic_rollout_logging_smoke", - "--delivery-batch-scale", - "implementation", - "--delivery-outcome", - "outcome_progress", - "--agent-id", - "codex-main-control", - "--agent-lane", - "smoke", - "--no-global-sync", - ) - assert refresh_payload["rollout_event"]["event_kind"] == "refresh_state", refresh_payload + for provider in ("file", "sqlite"): + assert_cli_events(tmp_root / "auto" / provider, provider) - should_run_payload = run_loopx_cli( - "--registry", - str(registry_path), - "--runtime-root", - str(cli_runtime_root), - "--format", - "json", - "quota", - "should-run", - "--goal-id", - cli_goal_id, - "--agent-id", - "codex-main-control", - "--scan-root", - str(project), - "--limit", - "1", - allow_failure=True, - ) - assert should_run_payload["rollout_event"]["event_kind"] == ( - "quota_should_run" - ), should_run_payload - - auto_events = load_rollout_events(rollout_event_log_path(cli_runtime_root, cli_goal_id)) - auto_kinds = [event["event_kind"] for event in auto_events] - assert auto_kinds == [ - "todo_claim", - "refresh_state", - "quota_should_run", - ], auto_kinds - latest_event = auto_events[-1] - assert_boundary(latest_event) - auto_log_text = rollout_event_log_path(cli_runtime_root, cli_goal_id).read_text( - encoding="utf-8" - ) - assert_public_safe_text(auto_log_text) +def assert_cli_events(root: Path, provider: str) -> None: + registry_path, cli_runtime_root, project, cli_goal_id = write_smoke_project(root) + claim_args = ( + "--registry", str(registry_path), "--runtime-root", str(cli_runtime_root), + "--format", "json", "todo", "claim", "--goal-id", cli_goal_id, + "--todo-id", "todo_auto_rollout", "--claimed-by", "codex-main-control", + ) + state = project / "ACTIVE_GOAL_STATE.md" + before_state, before_registry = state.read_bytes(), registry_path.read_bytes() + refused = run_loopx_cli(*claim_args, allow_failure=True) + assert refused["ok"] is False, refused + assert refused["error_code"] == "canonical_authority_required", refused + assert state.read_bytes() == before_state + assert registry_path.read_bytes() == before_registry + import_fixture_authority(registry_path, cli_goal_id, provider=provider) + events_before = load_rollout_events(rollout_event_log_path(cli_runtime_root, cli_goal_id)) + claim_payload = run_loopx_cli( + "--registry", + str(registry_path), + "--runtime-root", + str(cli_runtime_root), + "--format", + "json", + "todo", + "claim", + "--goal-id", + cli_goal_id, + "--todo-id", + "todo_auto_rollout", + "--claimed-by", + "codex-main-control", + ) + assert claim_payload["rollout_event"]["event_kind"] == "todo_claim", claim_payload + readback = run_loopx_cli( + "--registry", str(registry_path), "--runtime-root", str(cli_runtime_root), + "--format", "json", "todo", "list", "--goal-id", cli_goal_id, + "--todo-id", "todo_auto_rollout", + ) + assert readback["authority_read"]["source_authority"] == f"{provider}_v0", readback + assert readback["authority_read"]["legacy_fallback_used"] is False, readback + assert readback["todo"]["claimed_by"] == "codex-main-control", readback + + refresh_payload = run_loopx_cli( + "--registry", + str(registry_path), + "--runtime-root", + str(cli_runtime_root), + "--format", + "json", + "refresh-state", + "--goal-id", + cli_goal_id, + "--classification", + "automatic_rollout_logging_smoke", + "--delivery-batch-scale", + "implementation", + "--delivery-outcome", + "outcome_progress", + "--agent-id", + "codex-main-control", + "--agent-lane", + "smoke", + "--no-global-sync", + ) + assert refresh_payload["rollout_event"]["event_kind"] == "refresh_state", refresh_payload + + should_run_payload = run_loopx_cli( + "--registry", + str(registry_path), + "--runtime-root", + str(cli_runtime_root), + "--format", + "json", + "quota", + "should-run", + "--goal-id", + cli_goal_id, + "--agent-id", + "codex-main-control", + "--scan-root", + str(project), + "--limit", + "1", + allow_failure=True, + ) + assert should_run_payload["rollout_event"]["event_kind"] == ( + "quota_should_run" + ), should_run_payload + + auto_events = load_rollout_events(rollout_event_log_path(cli_runtime_root, cli_goal_id)) + assert auto_events[:len(events_before)] == events_before + auto_kinds = [event["event_kind"] for event in auto_events[len(events_before):]] + assert auto_kinds == [ + "todo_claim", + "refresh_state", + "quota_should_run", + ], auto_kinds + latest_event = auto_events[-1] + assert_boundary(latest_event) + auto_log_text = rollout_event_log_path(cli_runtime_root, cli_goal_id).read_text( + encoding="utf-8" + ) + assert_public_safe_text(auto_log_text) if __name__ == "__main__": main() diff --git a/examples/project/goal-vision-refresh-state-budget-smoke.py b/examples/project/goal-vision-refresh-state-budget-smoke.py index 5a3af07ffe..73de3068c9 100644 --- a/examples/project/goal-vision-refresh-state-budget-smoke.py +++ b/examples/project/goal-vision-refresh-state-budget-smoke.py @@ -11,6 +11,16 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.coordination.local_authority import ( # noqa: E402 + LocalCoordinationAuthorityUnavailable, + read_canonical_todos_if_promoted, +) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.todos.mutation_api import add_goal_todo # noqa: E402 + GOAL_ID = "goal-vision-budget-fixture" AGENT_ID = "research-curator" @@ -184,10 +194,27 @@ def write_json(path: Path, value: dict) -> None: path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") -def main() -> int: +def assert_vision_budget(provider: str) -> None: with tempfile.TemporaryDirectory(prefix="loopx-goal-vision-budget-") as tmp: root = Path(tmp) registry_path, runtime, project = write_fixture(root) + state_path = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" + # A source fixture must be imported before any normal Todo writer. + source_before = state_path.read_bytes() + create_args = dict(registry_path=registry_path, goal_id=GOAL_ID, role="agent", + text="Validate the scoped delivery evidence.", task_class="advancement_task", + claimed_by=AGENT_ID, agent_id=AGENT_ID) + try: + add_goal_todo(**create_args) + except LocalCoordinationAuthorityUnavailable as error: + assert error.code == "canonical_authority_required", error + else: + raise AssertionError("unmigrated fixture must not admit a normal Todo write") + assert state_path.read_bytes() == source_before + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) is None + import_fixture_authority(registry_path, GOAL_ID, provider=provider) + original = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert original["source_authority"] == provider + "_v0" valid_path = root / "valid-vision.json" invalid_path = root / "invalid-vision.json" drift_path = root / "drift-vision.json" @@ -603,10 +630,14 @@ def main() -> int: assert "limit is 80" in long_todo["error"], long_todo assert "suggested compact value" in long_todo["error"], long_todo - from loopx.todos import add_goal_todo - add_goal_todo(registry_path=registry_path, goal_id=GOAL_ID, role="agent", - text="Validate the scoped delivery evidence.", task_class="advancement_task", - claimed_by=AGENT_ID) + created = add_goal_todo(**create_args) + canonical = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert len(canonical["todos"]) == len(original["todos"]) + 1 + rows = {row["todo_id"]: row for row in canonical["todos"]} + assert rows[created["todo_id"]]["claimed_by"] == AGENT_ID + assert rows[created["todo_id"]]["text"] == create_args["text"] + for row in original["todos"]: + assert rows[row["todo_id"]] == row local_next_action = "/Users/example/private/raw-task-note" private_next_action_result = payload( run_cli( @@ -689,6 +720,11 @@ def main() -> int: assert "vision_summary" in invalid["error"], invalid assert "suggested compact value" in invalid["error"], invalid + + +def main() -> int: + for provider in ("file", "sqlite"): + assert_vision_budget(provider) print("goal-vision-refresh-state-budget-smoke ok") return 0 diff --git a/examples/project/next-action-projection-contract-smoke.py b/examples/project/next-action-projection-contract-smoke.py index 314987dce5..75223875a1 100644 --- a/examples/project/next-action-projection-contract-smoke.py +++ b/examples/project/next-action-projection-contract-smoke.py @@ -8,10 +8,17 @@ import sys import tempfile -from loopx.control_plane.scheduler.execution_context import GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT -from loopx.quota import build_quota_should_run -from loopx.state_refresh import refresh_state_run -from loopx.status import collect_status +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted # noqa: E402 +from loopx.control_plane.scheduler.execution_context import GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority, run_json_cli # noqa: E402 +from loopx.control_plane.todos.mutation_api import complete_goal_todo # noqa: E402 +from loopx.quota import build_quota_should_run # noqa: E402 +from loopx.state_refresh import refresh_state_run # noqa: E402 +from loopx.status import collect_status # noqa: E402 GOAL_ID = "next-action-projection-goal" ACTIVE_NEXT_ACTION = "Shared compatibility guidance." @@ -93,9 +100,18 @@ def assert_state_next_action(path: Path, expected: str) -> None: -def main() -> None: +def assert_bound_projection(provider: str) -> None: with tempfile.TemporaryDirectory(prefix="loopx-bound-recommendation-") as directory: registry, runtime, project, state = write_fixture(Path(directory)) + import_fixture_authority(registry, GOAL_ID, provider=provider) + original = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert original["source_authority"] == provider + "_v0" + assert {row["todo_id"] for row in original["todos"]} == {"todo_primary", "todo_side"} + # Finish the import's display delivery before checking that a task + # step does not change the already-current Markdown projection. + run_json_cli("todo", "project-markdown", "--goal-id", GOAL_ID, + "--provider-revision", original["provider_revision"], "--execute", + registry_path=registry, runtime_root=runtime, cwd=REPO_ROOT) before = state.read_bytes() common = dict(registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, project=project, state_file=None, classification="state_refreshed", @@ -133,7 +149,7 @@ def main() -> None: command = [sys.executable, "-m", "loopx.cli", "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "status", "--goal-id", GOAL_ID, "--agent-id", "codex-side-bypass"] - readback = subprocess.run(command, capture_output=True, text=True, timeout=30) + readback = subprocess.run(command, cwd=REPO_ROOT, capture_output=True, text=True, timeout=30) assert readback.returncode == 0, readback.stdout + readback.stderr item = next(item for item in json.loads(readback.stdout)["attention_queue"]["items"] if item["goal_id"] == GOAL_ID) projected = item.get("agent_lane_next_action") or item["project_asset"]["agent_lane_next_action"] @@ -141,14 +157,37 @@ def main() -> None: assert projected["next_action_basis"] == lane["next_action_basis"] assert "next_action_basis" not in item # the selected route owns the basis markdown = subprocess.run([*command[:command.index("--format")], "--format", "markdown", - *command[command.index("--format") + 2:]], capture_output=True, text=True, timeout=30) + *command[command.index("--format") + 2:]], cwd=REPO_ROOT, capture_output=True, text=True, timeout=30) assert markdown.returncode == 0 and "next_step: " + step in markdown.stdout - # A different selected task does not inherit the old experiment. + # Display metadata cannot complete the authoritative selected task. state.write_text(state.read_text().replace("todo_side status=open", "todo_side status=done")) + assert state.read_bytes() != before + spoofed = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[project], limit=2) + spoofed_decision = build_quota_should_run(spoofed, goal_id=GOAL_ID, agent_id="codex-side-bypass", + scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT) + assert spoofed_decision["agent_lane_next_action"]["todo_id"] == "todo_side" + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) == original + state.write_bytes(before) + + # Complete through the real owner: another selected task must not + # inherit the original task's retained recommendation receipt. + complete_goal_todo(registry_path=registry, goal_id=GOAL_ID, + todo_id="todo_side", agent_id="codex-side-bypass", evidence="Scoped fixture work verified.") + completed = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + before_rows = {row["todo_id"]: row for row in original["todos"]} + after_rows = {row["todo_id"]: row for row in completed["todos"]} + assert after_rows["todo_side"]["status"] == "done" + assert after_rows["todo_side"]["claimed_by"] == before_rows["todo_side"]["claimed_by"] + assert after_rows["todo_primary"] == before_rows["todo_primary"] changed = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[project], limit=2) next_decision = build_quota_should_run(changed, goal_id=GOAL_ID, agent_id="codex-side-bypass", scheduler_execution_context=GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT) assert step not in next_decision["interaction_contract"]["agent_channel"]["primary_action"] + + +def main() -> None: + for provider in ("file", "sqlite"): + assert_bound_projection(provider) print("next-action-projection-contract-smoke ok") diff --git a/examples/project/project-agent-adoption-smoke.py b/examples/project/project-agent-adoption-smoke.py index c5316418e2..482a144802 100644 --- a/examples/project/project-agent-adoption-smoke.py +++ b/examples/project/project-agent-adoption-smoke.py @@ -20,6 +20,11 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 + GOAL_ID = "adoption-main-control" USER_TODO = "Review the P0 owner-risk checklist before approving delivery." AGENT_TODO = "Run the read-only map dry-run after the operator approval is recorded." @@ -86,7 +91,7 @@ def write_planned_fixture(root: Path) -> Path: return registry_path -def run_cli(root: Path, registry_path: Path, *args: str) -> dict: +def run_cli(root: Path, registry_path: Path, *args: str, check: bool = True) -> dict: result = subprocess.run( [ sys.executable, @@ -101,7 +106,7 @@ def run_cli(root: Path, registry_path: Path, *args: str) -> dict: *args, ], cwd=REPO_ROOT, - check=True, + check=check, text=True, capture_output=True, ) @@ -135,8 +140,8 @@ def attention_item(status_payload: dict) -> dict: return items[0] -def main() -> int: - with tempfile.TemporaryDirectory(prefix="loopx-project-agent-adoption-") as tmp: +def assert_adoption(provider: str) -> None: + with tempfile.TemporaryDirectory(prefix=f"loopx-project-agent-adoption-{provider}-") as tmp: root = Path(tmp) registry_path = write_planned_fixture(root) project = root / "project" @@ -179,24 +184,31 @@ def main() -> int: assert first_quota["agent_todo_summary"]["open_count"] == 1, first_quota assert first_quota["selected_todo"]["text"] == AGENT_TODO, first_quota - todo_payload = run_cli( - root, - registry_path, - "todo", - "add", - "--goal-id", - GOAL_ID, - "--role", - "user", - "--task-class", - "user_gate", - "--global-gate", - "--text", - USER_TODO, + add_gate = ( + "todo", "add", "--goal-id", GOAL_ID, "--role", "user", + "--task-class", "user_gate", "--global-gate", "--text", USER_TODO, ) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + before_state, before_registry = state.read_bytes(), registry_path.read_bytes() + refused = run_cli(root, registry_path, *add_gate, check=False) + assert refused["ok"] is False, refused + assert refused["error_code"] == "canonical_authority_required", refused + assert state.read_bytes() == before_state + assert registry_path.read_bytes() == before_registry + import_fixture_authority(registry_path, GOAL_ID, provider=provider) + imported = run_cli(root, registry_path, "todo", "list", "--goal-id", GOAL_ID) + assert imported["authority_read"]["source_authority"] == f"{provider}_v0", imported + assert imported["authority_read"]["legacy_fallback_used"] is False, imported + assert len(imported["todos"]) == 1, imported + original = imported["todos"][0] + assert original["text"] == AGENT_TODO and original["status"] == "open", original + todo_payload = run_cli(root, registry_path, *add_gate) assert todo_payload["ok"] is True, todo_payload assert todo_payload["added"] is True, todo_payload - assert todo_payload["section"] == "User Todo / Owner Review Reading Queue", todo_payload + gate_id = todo_payload["todo_id"] + gate = run_cli(root, registry_path, "todo", "list", "--goal-id", GOAL_ID, + "--todo-id", gate_id)["todo"] + assert gate["text"] == USER_TODO and gate["global_gate"] is True, gate status_after_todo = run_cli(root, registry_path, "status", "--scan-root", str(project)) item_after_todo = attention_item(status_after_todo) @@ -267,6 +279,10 @@ def main() -> int: assert "不要从旧聊天或旧 packet 拼当前状态" in packet["project_agent_handoff"], packet assert "【用户本地 Gate 记录草稿】" not in packet["packet"], packet + +def main() -> int: + for provider in ("file", "sqlite"): + assert_adoption(provider) print("project-agent-adoption-smoke ok") return 0 diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index d8c9586845..b6b63402bc 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -183,8 +183,9 @@ def remove_native_update_maintenance(source: str) -> str: pattern="^exact legacy release honors an engaged promotion fence without writes$")) CASES.append(Case("lifecycle_fence_guard_skipped", ( ("loopx/control_plane/work_items/task_lease_lifecycle.ts", replacement( - " await requireLegacyCoordinationPrimaryWriteAllowed(request!.runtime_root, request!.goal_id);\n", - "")), + " await requireLegacyCoordinationPrimaryWriteAllowed(request.runtime_root, request.goal_id);\n" + " const decision = retainedReleaseDecision(request, existing, at);", + " const decision = retainedReleaseDecision(request, existing, at);")), ), "tests/control_plane_ts/task_lease_lifecycle.test.ts", pattern="^exact legacy release honors an engaged promotion fence without writes$")) diff --git a/examples/value-connectors-finance-probe-doc-smoke.py b/examples/value-connectors-finance-probe-doc-smoke.py index 2f08cc4974..e5bf0f992e 100644 --- a/examples/value-connectors-finance-probe-doc-smoke.py +++ b/examples/value-connectors-finance-probe-doc-smoke.py @@ -44,17 +44,30 @@ def main() -> int: "finance_market_snapshot", ) assert completed.returncode == 0, completed.stderr - payload = json.loads(completed.stdout) + packet = json.loads(completed.stdout) + assert packet["ok"] is True, packet if command == "source-map": - migration = payload["source_profiles"][0]["migration"] + profiles = packet["source_profiles"] elif command == "install-check": - migration = payload["checks"][0]["migration"] + profiles = packet["checks"] else: - migration = payload - assert migration["status"] == "migrated_to_extension", payload - assert migration["replacement_extension_id"] == "loopx-finance-value-discovery", payload - assert migration["replacement_capability_id"] is None, payload - assert migration["truth_contract"]["legacy_connector_executes_finance"] is False, payload + profiles = None + if profiles is not None: + assert len(profiles) == 1, profiles + profile = profiles[0] + assert profile["connector_id"] == "finance_market_snapshot", profile + assert profile["status"] == "migrated_to_extension", profile + migration = profile["migration"] + else: + migration = packet + assert migration["schema_version"] == "value_connector_extension_migration_v0", migration + assert migration["status"] == "migrated_to_extension", migration + assert migration["replacement_extension_id"] == "loopx-finance-value-discovery", migration + assert migration["replacement_capability_id"] is None, migration + truth = migration["truth_contract"] + assert truth["legacy_connector_executes_finance"] is False, truth + assert truth["external_reads_performed"] is False, truth + assert truth["external_writes_performed"] is False, truth doc = " ".join(DOC.read_text(encoding="utf-8").lower().split()) for marker in ( diff --git a/examples/visible-governance-slice-smoke.py b/examples/visible-governance-slice-smoke.py deleted file mode 100644 index d37c32b13d..0000000000 --- a/examples/visible-governance-slice-smoke.py +++ /dev/null @@ -1,477 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke-test the ``visible_governance_slice_v0`` governance projection. - -Builds a synthetic multi-agent fixture with soft claims, a task lease, -decision scopes, and a user gate, then projects the full governance slice -and validates every section. All assertions are public-safe -- no raw -paths, credentials, or private payloads. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -from loopx.control_plane.testing.canary_harness import ( # noqa: E402 - run_json_cli, - write_fixture_registry, -) -from loopx.quota import build_quota_should_run # noqa: E402 -from loopx.status import collect_status # noqa: E402 -from loopx.todos import ( # noqa: E402 - add_goal_todo, - complete_goal_todo, - list_goal_todos, -) -from loopx.visible_governance import ( # noqa: E402 - build_visible_governance_slice, - render_visible_governance_markdown, -) - - -GOAL_ID = "governance-slice-smoke" -AGENT_ALPHA = "codex-alpha" -AGENT_BETA = "codex-beta" - - -def _assert_public_safe_payload(payload: dict, *, label: str) -> None: - """Verify a payload contains no raw paths, URLs, or credential markers.""" - text = json.dumps(payload, sort_keys=True, ensure_ascii=False) - assert "/Users/" not in text, f"{label}: absolute path leak" - assert "https://" not in text, f"{label}: URL leak" - assert "pass" + "word" not in text.lower(), f"{label}: credential leak" - assert "api_" + "key" not in text.lower(), f"{label}: API key leak" - assert "C:\\" not in text, f"{label}: Windows path leak" - - -def _write_fixture(tmp_path: Path) -> tuple[Path, Path, Path]: - """Create a multi-agent fixture with claims, gates, and a task lease.""" - project = tmp_path / "project" - runtime = tmp_path / "runtime" - registry = project / ".loopx" / "registry.json" - state = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" - state.parent.mkdir(parents=True) - state.write_text( - "---\nstatus: active\n---\n\n" - "# Active Goal State\n\n" - "## Agent Todo\n\n" - "- [ ] Alpha advancement task\n" - "- [ ] Beta advancement task\n\n" - "## User Todo\n\n" - "- [ ] Review and approve the release scope\n", - encoding="utf-8", - ) - write_fixture_registry( - project=project, - runtime_root=runtime, - registry_path=registry, - goal_id=GOAL_ID, - domain="loopx-platform", - adapter_kind="harness_self_improvement", - registered_agents=[AGENT_ALPHA, AGENT_BETA], - quota_allowed_slots=10, - ) - return registry, runtime, state - - -def _add_agent_todo( - registry: Path, - *, - text: str, - claimed_by: str, - task_class: str = "advancement_task", - required_decision_scopes: list[str] | None = None, -) -> dict: - """Add an agent todo, optionally with required decision scopes. - - Scopes use the ``kind:granularity:scope_key`` string format - (e.g. ``"release:action:governance-bridge"``). - """ - kwargs: dict = { - "registry_path": registry, - "goal_id": GOAL_ID, - "role": "agent", - "text": text, - "task_class": task_class, - "claimed_by": claimed_by, - } - if required_decision_scopes: - kwargs["required_decision_scopes"] = required_decision_scopes - return add_goal_todo(**kwargs) - - -def _add_user_gate( - registry: Path, - *, - text: str, - blocks_agent: str | None = None, - unblocks_todo_id: str | None = None, - decision_scope: str | None = None, -) -> dict: - """Add a user gate, optionally linked to an agent todo or scope. - - ``decision_scope`` uses the ``kind:granularity:scope_key`` string format. - """ - kwargs: dict = { - "registry_path": registry, - "goal_id": GOAL_ID, - "role": "user", - "text": text, - "task_class": "user_gate", - } - if blocks_agent: - kwargs["blocks_agent"] = blocks_agent - if unblocks_todo_id: - kwargs["unblocks_todo_id"] = unblocks_todo_id - if decision_scope: - kwargs["decision_scope"] = decision_scope - return add_goal_todo(**kwargs) - - -def _add_user_action( - registry: Path, - *, - text: str, - bound_agent: str | None = None, -) -> dict: - """Add a non-blocking user action.""" - kwargs: dict = { - "registry_path": registry, - "goal_id": GOAL_ID, - "role": "user", - "text": text, - "task_class": "user_action", - } - if bound_agent: - kwargs["bound_agent"] = bound_agent - return add_goal_todo(**kwargs) - - -def main() -> None: - import tempfile - - print("=== Visible Governance Slice Smoke ===\n") - - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - registry, runtime, state = _write_fixture(tmp_path) - - # ── 1. Create agent todos with claims ── - alpha_todo = _add_agent_todo( - registry, - text="Alpha builds the core governance bridge.", - claimed_by=AGENT_ALPHA, - required_decision_scopes=["direction:action:governance-bridge"], - ) - beta_todo = _add_agent_todo( - registry, - text="Beta reviews the integration surface.", - claimed_by=AGENT_BETA, - ) - print( - f" [OK] Agent todos: alpha={alpha_todo['todo_id']}, " - f"beta={beta_todo['todo_id']}" - ) - - # ── 2. Create a user gate that covers alpha's required scope ── - gate = _add_user_gate( - registry, - text="Approve the governance bridge release scope.", - blocks_agent=AGENT_ALPHA, - decision_scope="direction:goal:*", - ) - print(f" [OK] User gate: {gate['todo_id']} (goal-scoped, blocks alpha)") - - # ── 3. Create a non-blocking user action ── - action = _add_user_action( - registry, - text="Review the dashboard budget projection.", - bound_agent=AGENT_ALPHA, - ) - print(f" [OK] User action: {action['todo_id']}") - - # ── 4. Acquire a task lease for alpha ── - lease_result = run_json_cli( - "task-lease", - "acquire", - "--goal-id", - GOAL_ID, - "--todo-id", - alpha_todo["todo_id"], - "--owner", - AGENT_ALPHA, - "--idempotency-key", - "smoke-lease-key-001", - "--ttl-seconds", - "2700", - "--write-scope", - "governance-bridge/*", - registry_path=registry, - runtime_root=runtime, - ) - assert lease_result["ok"] is True, f"lease acquire failed: {lease_result}" - assert lease_result["acquired"] is True - print(f" [OK] Task lease acquired for alpha on {alpha_todo['todo_id']}") - - # ── 5. Collect status ── - status_payload = collect_status( - registry_path=registry, - runtime_root_override=str(runtime), - scan_roots=[Path(__file__).resolve()], - limit=20, - ) - assert isinstance(status_payload, dict) - print(" [OK] Status collected") - - # ── 6. Build quota should_run for alpha ── - decision = build_quota_should_run( - status_payload, - goal_id=GOAL_ID, - agent_id=AGENT_ALPHA, - ) - assert isinstance(decision, dict) - assert "effective_action" in decision - print(f" [OK] Quota should_run built: effective={decision['effective_action']}") - - # ── 7. Build visible governance slice ── - gov_slice = build_visible_governance_slice( - status_payload, - goal_id=GOAL_ID, - agent_id=AGENT_ALPHA, - include_task_leases=True, - ) - assert gov_slice["ok"] is True, f"slice not ok: {gov_slice}" - assert gov_slice["schema_version"] == "visible_governance_slice_v0" - assert gov_slice["goal_id"] == GOAL_ID - assert gov_slice["agent_id"] == AGENT_ALPHA - - # ── 8. Validate every section ── - - # claims - claims = gov_slice["claims"] - assert isinstance(claims, dict) - assert claims["total_claims"] >= 2, f"expected >= 2 claims, got {claims}" - assert claims["claiming_agent_count"] >= 2 - assert claims["current_agent_claims"] >= 1 - assert AGENT_ALPHA in claims["by_agent"] - assert AGENT_BETA in claims["by_agent"] - for c in claims["claims"]: - assert "todo_id" in c - assert "claimed_by" in c - print( - f" [OK] Claims: {claims['total_claims']} claims " - f"across {claims['claiming_agent_count']} agents" - ) - - # leases - leases = gov_slice["leases"] - assert isinstance(leases, dict) - assert leases["active_leases"] >= 1 - assert leases["current_agent_active_leases"] >= 1 - assert leases["nature"] == "optional runtime fence, not authority" - for lease_entry in leases["leases"]: - assert "todo_id" in lease_entry - assert "owner" in lease_entry - assert "expires_at" in lease_entry - print( - f" [OK] Leases: {leases['active_leases']} active, " - f"nature='{leases['nature']}'" - ) - - # quota - quota = gov_slice["quota"] - assert isinstance(quota, dict) - for key in ("should_run", "effective_action", "state"): - assert key in quota, f"quota missing {key}" - print(f" [OK] Quota: should_run={quota['should_run']}, action={quota['effective_action']}") - - # scheduler hints - sched = gov_slice["scheduler_hints"] - if sched is not None: - assert isinstance(sched, dict) - for key in ("action", "cadence_class", "arbitration"): - assert key in sched, f"scheduler_hints missing {key}" - print(f" [OK] Scheduler hints: cadence={sched['cadence_class']}") - else: - print(" [OK] Scheduler hints: None (no agent-lane context)") - - # decision scopes - scopes = gov_slice["decision_scopes"] - assert isinstance(scopes, dict) - consistency = scopes["required_scope_consistency"] - assert isinstance(consistency, dict) - assert "status" in consistency - print( - f" [OK] Decision scopes: consistency={consistency['status']}, " - f"checked={consistency['checked_agent_todo_count']} todos" - ) - - # authority boundary (implementation availability, not per-Goal promotion) - boundary = gov_slice["authority_boundary"] - assert isinstance(boundary, list) - assert len(boundary) >= 5, f"expected >= 5 RFC entries, got {len(boundary)}" - expected_shipped = { - "3 -- State Classification": True, - "4 -- Coordination Ledger Shape": False, - "5.1 -- claim_work command": False, - "7 -- Receipt Retention": True, - "8 -- Local vs Shared Mode": True, - "Appendix B -- handoff_mode": True, - "9 -- Offline/Local Boundaries": False, - } - by_section = {entry["rfc_section"]: entry for entry in boundary} - for section, expect_shipped in expected_shipped.items(): - assert section in by_section, f"missing RFC section: {section}" - entry = by_section[section] - assert "proposal" in entry - assert "shipped_equivalent" in entry - assert "gap" in entry - assert entry["shipped"] is expect_shipped, ( - f"{section}: shipped={entry['shipped']!r}, " - f"expected {expect_shipped} after native prototype retirement" - ) - claim_work = by_section["5.1 -- claim_work command"] - claim_text = ( - f"{claim_work['shipped_equivalent']} {claim_work['gap']}".lower() - ) - assert "retired" in claim_text and "todo_claim.ts" in claim_text - assert "not current execution authority" in claim_text - shipped_count = sum(1 for e in boundary if e["shipped"]) - assert shipped_count == sum( - 1 for value in expected_shipped.values() if value - ), f"unexpected shipped count: {shipped_count}" - print( - f" [OK] Authority boundary: {len(boundary)} RFC sections, " - f"{shipped_count} implemented, " - f"{len(boundary) - shipped_count} original proposals not shipped" - ) - - # ── Negative: active lease is never write authority ── - assert leases["active_leases"] >= 1 - assert leases["nature"] == "optional runtime fence, not authority" - lease_blob = json.dumps(leases, sort_keys=True).lower() - for forbidden in ( - "write authority", - "runtime authority", - "browser write", - "write api", - ): - assert forbidden not in lease_blob, ( - f"leases projection must not present '{forbidden}'" - ) - # The coverage-only claim_work reference must not imply lease authority. - assert claim_work["shipped"] is False - assert "optional runtime fence" in leases["nature"] - print( - " [OK] Negative: active leases remain fence-only " - "(not write authority / no browser write API)" - ) - - # ── 9. Public safety scan ── - _assert_public_safe_payload(gov_slice, label="governance-slice") - print(" [OK] Public safety scan passes") - - # ── 10. include_task_leases=False ── - no_lease_slice = build_visible_governance_slice( - status_payload, - goal_id=GOAL_ID, - agent_id=AGENT_ALPHA, - include_task_leases=False, - ) - assert no_lease_slice["leases"] is None - print(" [OK] include_task_leases=False omits leases section") - - # ── 11. Missing goal_id ── - bad_slice = build_visible_governance_slice(status_payload, goal_id="") - assert bad_slice["ok"] is False - assert "error" in bad_slice - print(" [OK] Empty goal_id returns error") - - # ── 12. agent_id=None works ── - no_agent_slice = build_visible_governance_slice( - status_payload, - goal_id=GOAL_ID, - agent_id=None, - ) - assert no_agent_slice["ok"] is True - assert no_agent_slice["agent_id"] is None - print(" [OK] agent_id=None produces valid slice") - - # ── 13. Markdown rendering ── - markdown = render_visible_governance_markdown(gov_slice) - assert "Visible Governance Slice" in markdown - assert GOAL_ID in markdown - assert AGENT_ALPHA in markdown - assert "Claims (Soft Ownership)" in markdown - assert "Task Leases (Optional Runtime Fence)" in markdown - assert "Quota Decision" in markdown - assert "Scheduler Hints" in markdown - assert "Decision Scopes" in markdown - assert "Authority Boundary" in markdown - assert "optional runtime fence, not authority" in markdown - assert "Proposals shipped" in markdown - assert "Stage-2" in markdown or "Stage 2" in markdown or "additive" in markdown - assert "not write authority" in markdown.lower() or ( - "optional fence, not write authority" in markdown.lower() - ) - # Verify no private markers in rendered markdown - assert "/Users/" not in markdown - assert "https://" not in markdown - assert "C:\\" not in markdown - print(" [OK] Markdown render includes all sections") - print(" [OK] Markdown is public-safe") - print(" [OK] Markdown keeps Stage-2 / lease-authority boundary explicit") - - # ── 14. Complete the gate and verify standing authority ── - complete_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - todo_id=gate["todo_id"], - agent_id=AGENT_ALPHA, - decision_outcome="approve", - ) - status2 = collect_status( - registry_path=registry, - runtime_root_override=str(runtime), - scan_roots=[Path(__file__).resolve()], - limit=20, - ) - gov_slice2 = build_visible_governance_slice( - status2, - goal_id=GOAL_ID, - agent_id=AGENT_ALPHA, - ) - standing = gov_slice2["decision_scopes"]["standing_decision_authority"] - if standing is not None: - assert standing["active_count"] >= 1 - print( - f" [OK] Standing authority after gate approval: " - f"{standing['active_count']} active receipts" - ) - else: - print(" [OK] Standing authority: None (gate scoped to different items)") - - # ── 15. Verify the slice is read-only (no mutations in builder) ── - todos_before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID) - _recheck = build_visible_governance_slice( - status2, - goal_id=GOAL_ID, - agent_id=AGENT_BETA, - include_task_leases=True, - ) - todos_after = list_goal_todos(registry_path=registry, goal_id=GOAL_ID) - assert todos_before == todos_after, ( - "build_visible_governance_slice must not mutate todos" - ) - print(" [OK] Slice builder is read-only (no mutations)") - - print("\n=== All smoke checks passed ===") - - -if __name__ == "__main__": - main() diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index df00f6677a..c1850581a5 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -100,7 +100,7 @@ def capability_configuration_editor( options=["file", "sqlite"], required=True, description="Fixed at creation. Existing Goals need a separate backed-up migration."), _field("canonical_creation", "Create canonical authority", "boolean", required=True, - description="Enabled by default for new Goals. Disabled retains the post-promotion target behavior."), + description="Required for new Goals in 2.0. Enable before creating a Goal; import existing Markdown Goals separately."), _field("new_goal_handoff_mode", "New Goal execution policy", "select", options=["soft_claim", "hard_lease"], required=True, description="Used only with canonical creation. Agents inherit the Goal policy; this grants no tool permissions.")], diff --git a/loopx/capabilities/issue_fix/cli.py b/loopx/capabilities/issue_fix/cli.py index 65d8ad868a..69c9caa772 100644 --- a/loopx/capabilities/issue_fix/cli.py +++ b/loopx/capabilities/issue_fix/cli.py @@ -1356,14 +1356,15 @@ def handle_issue_fix_command( or todo_write.get("metadata_updated") or todo_write.get("status_changed") ) + created_todo = todo_write["todo"] payload["todo_write"] = { "schema_version": "issue_fix_maintainer_correction_todo_write_v0", "write_performed": todo_changed, "already_exists": bool(todo_write.get("already_exists")), "todo_id": todo_write.get("todo_id"), "role": todo_write.get("role"), - "claimed_by": todo_write.get("claimed_by"), - "blocks_agent": todo_write.get("blocks_agent"), + "claimed_by": created_todo.get("claimed_by"), + "blocks_agent": created_todo.get("blocks_agent"), "path_recorded": False, } payload["todo_write_performed"] = todo_changed diff --git a/loopx/chat.py b/loopx/chat.py index 12ec2880ec..23f63e9aee 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -710,8 +710,8 @@ def _compact_todo_payload(payload: dict[str, Any], *, applied: bool) -> dict[str def _todo_preview_fingerprint(payload: dict[str, Any]) -> str: compact = _compact_todo_payload(payload, applied=False) if payload.get("decision_read_from_provider") is True: - # A create dry-run allocates a proposed operation identity without a - # write. Its random Todo id is not the reviewed intent or source head. + # Bind the transport operation to reviewed intent and provider head. + # The provider allocates Todo identity from that operation separately. compact["todo"].pop("todo_id") return _stable_digest( { @@ -761,7 +761,7 @@ def _todo_no_write_receipt( "outcome": "preview_stale", "write_attempted": write_attempted, **({"current_preview_id": _todo_preview_fingerprint(current_preview)} - if not write_attempted else {}), + if not write_attempted and state_revision is None else {}), "state_revision": state_revision or _todo_revision(current_preview), } receipt["receipt_id"] = _stable_digest(receipt) @@ -776,6 +776,7 @@ def _add_review_todo( priority: str | None = None, dry_run: bool, expected_provider_revision: str | None = None, + operation_id: str | None = None, ) -> dict[str, Any]: return add_goal_todo( registry_path=registry_path, @@ -786,11 +787,41 @@ def _add_review_todo( task_class="advancement_task", action_kind=CHAT_TODO_ACTION_KIND, dry_run=dry_run, + **({"operation_id": operation_id} if operation_id is not None else {}), **({"expected_provider_revision": expected_provider_revision} if expected_provider_revision is not None else {}), ) +def _bound_todo_review_preview( + *, registry_path: Path, goal_id: str, text: str, priority: str | None, +) -> dict[str, Any]: + payload = _add_review_todo( + registry_path=registry_path, goal_id=goal_id, text=text, priority=priority, dry_run=True, + ) + if payload.get("decision_read_from_provider") is not True: + return payload + # Reuse the existing canonical operation contract instead of predicting a + # Todo id here. Both dry-runs are read-only; the second refuses a changed + # provider head. Confirmation uses this same operation and revision. + try: + return _add_review_todo( + registry_path=registry_path, goal_id=goal_id, text=text, priority=priority, dry_run=True, + operation_id="chat-todo:" + _todo_preview_fingerprint(payload), + expected_provider_revision=_todo_revision(payload), + ) + except LocalCoordinationAuthorityUnavailable as exc: + if exc.code != "provider_revision_mismatch": + raise + raise TodoReviewPreviewConflict( + "stale todo preview; preview the proposal again before applying", + receipt=_todo_no_write_receipt( + goal_id=goal_id, current_preview=payload, + state_revision=exc.payload.get("current_provider_revision"), + ), + ) from exc + + def build_todo_review_preview( *, registry_path: Path, @@ -798,12 +829,8 @@ def build_todo_review_preview( text: str, priority: str | None = None, ) -> dict[str, Any]: - payload = _add_review_todo( - registry_path=registry_path, - goal_id=goal_id, - text=text, - priority=priority, - dry_run=True, + payload = _bound_todo_review_preview( + registry_path=registry_path, goal_id=goal_id, text=text, priority=priority, ) compact = _compact_todo_payload(payload, applied=False) compact["preview_id"] = _todo_preview_fingerprint(payload) @@ -818,12 +845,8 @@ def apply_todo_review_preview( priority: str | None = None, preview_id: str, ) -> dict[str, Any]: - current_preview = _add_review_todo( - registry_path=registry_path, - goal_id=goal_id, - text=text, - priority=priority, - dry_run=True, + current_preview = _bound_todo_review_preview( + registry_path=registry_path, goal_id=goal_id, text=text, priority=priority, ) if not preview_id or preview_id != _todo_preview_fingerprint(current_preview): raise TodoReviewPreviewConflict( @@ -840,7 +863,8 @@ def apply_todo_review_preview( text=text, priority=priority, dry_run=False, - **({"expected_provider_revision": _todo_revision(current_preview)} + **({"expected_provider_revision": _todo_revision(current_preview), + "operation_id": current_preview["operation_id"]} if current_preview.get("decision_read_from_provider") is True else {}), ) except LocalCoordinationAuthorityUnavailable as exc: diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 32966f1898..a7e6c1df06 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -35,7 +35,6 @@ effect_runtime_request_scope, effect_runtime_result, EffectRuntimeRemoteError, ) from .control_plane.coordination.local_authority import local_authority_is_promoted -from .control_plane.todos.handoff_mode import show_goal_handoff_mode from .control_plane.turn_driver.journal_store import ( load_turn_journal, turn_journal_path, @@ -1561,20 +1560,16 @@ def _acquire_delegation_lease( ) -> dict: """Acquire the promoted hard lease before worker or completion effects.""" - if not local_authority_is_promoted( - runtime_root=self.root, - goal_id=self.goal_id, - ): - if row.get("task_lease", {}).get("required") is True: - raise ValueError("delegation canonical authority disappeared; reconcile the original execution") - row["task_lease"] = {"required": False, "handoff_mode": "legacy"} - self._fenced_write(path, row) - return row["task_lease"] - handoff_mode = show_goal_handoff_mode( - registry_path=self.registry, - runtime_root_arg=str(self.root), - goal_id=self.goal_id, - )["handoff_mode"] + from .control_plane.work_items.task_lease import inspect_task_lease + + observed = inspect_task_lease(registry_path=self.registry, runtime_root=self.root, + goal_id=self.goal_id, todo_id=binding["todo_id"]) + support = observed.get("execution_policy") + if not isinstance(support, dict) or type(support.get("supported")) is not bool: + raise RuntimeError("lease inspection omitted typed execution-policy support") + if not support["supported"]: + raise ValueError(str(support.get("reason") or "current execution policy is unavailable")) + handoff_mode = observed["handoff_mode"] if handoff_mode != "hard_lease": if row.get("task_lease", {}).get("required") is True: raise ValueError("delegation authority mode changed; reconcile the original execution") @@ -1733,9 +1728,10 @@ def _execute(self, path: Path, row: dict, binding: dict) -> None: self._write_delegation_bootstrap(row, binding) self._acquire_delegation_lease(path, row, binding) self._observe(path, row, "running") - if row.get("task_lease", {}).get("required") is True and "lease" not in row["task_lease"]: - # Upgrade a still-current operation record by replaying its - # original claim, not by inventing a replacement execution. + elif row["status"] == "running": + # Resumption rechecks typed policy support even for old records + # that said no lease was required. Hard mode replays its original + # claim proof; it cannot invent a replacement execution. self._acquire_delegation_lease(path, row, binding) if row["status"] == "running": self._raise_if_stop_requested(path) diff --git a/loopx/control_plane/coordination/handoff_mode_policy.ts b/loopx/control_plane/coordination/handoff_mode_policy.ts index c768eca892..89ee2b30a6 100644 --- a/loopx/control_plane/coordination/handoff_mode_policy.ts +++ b/loopx/control_plane/coordination/handoff_mode_policy.ts @@ -1,4 +1,4 @@ -/** One quiescence rule for the legacy adapter and canonical mode transaction. */ +/** One policy owner: two current execution modes, historical mode decoding and migration. */ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject, requireStringArray, requireStringLiteral} from "../runtime_decode.ts"; @@ -8,6 +8,14 @@ export const HANDOFF_MODE_PLAN_SCHEMA = "loopx_handoff_mode_plan_request_v0"; import type {PersistedHandoffMode} from "./handoff_mode_facts.ts"; +export const HANDOFF_MODE_RETIRED_REASON = "Legacy ownership is historical only. Release retained execution, then review handoff-mode plan-migration/migrate to soft_claim or hard_lease; policy migration does not require another storage promotion."; + +/** Historical mode is readable and releasable, never present-tense execution. + * Receipt recovery runs before fresh admission; readback must not turn it into a grant. */ +export function retiredHandoffModeRejection(mode: unknown): "handoff_mode_retired" | null { + return mode === "legacy" ? "handoff_mode_retired" : null; +} + export function decideHandoffMode(previous: PersistedHandoffMode, requested: HandoffMode, claims: number, leases: number): JsonObject { if (requested === "legacy") return {outcome: "rejected", code: "handoff_mode_retired", diff --git a/loopx/control_plane/coordination/lease_acquisition_proof.ts b/loopx/control_plane/coordination/lease_acquisition_proof.ts index 0dd9082ea8..280888f173 100644 --- a/loopx/control_plane/coordination/lease_acquisition_proof.ts +++ b/loopx/control_plane/coordination/lease_acquisition_proof.ts @@ -6,7 +6,7 @@ import type {AuthorityStore, AuthorityStoreLoadResult} from "./authority_store.t import {canonicalAuthorityObject} from "./authority_store_codec.ts"; import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "./coordination_projection.ts"; import {canonicalTaskLease, canonicalTaskLeaseAcquireFacts} from "./task_lease_state.ts"; -import {HANDOFF_MODES} from "./handoff_mode_policy.ts"; +import {HANDOFF_MODES, retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {requireStringLiteral} from "../runtime_decode.ts"; import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts"; import {leaseEpoch, leaseVersion, leaseIsActive} from "../work_items/task_lease_acquire.ts"; @@ -48,10 +48,11 @@ export async function currentLeaseAcquisitionProof(store: Auth const current = facts.current; const details = {handoff_mode: mode, original_receipt: result.original_receipt, current_provider_revision: head.provider_revision, current_cursor: head.cursor}; - const rejection = mode === "soft_claim" ? "handoff_mode_forbids_lease" + const rejection = retiredHandoffModeRejection(mode) ?? (mode === "soft_claim" ? "handoff_mode_forbids_lease" : input.required_handoff_mode !== undefined && mode !== input.required_handoff_mode ? "claim_lease_requires_hard_lease" - : leaseOwnerRejection(facts.todo, input.owner, input.registered_agents); - if (rejection) return failed(rejection, `current authority rejects lease acquire replay: ${rejection}`, details); + : leaseOwnerRejection(facts.todo, input.owner, input.registered_agents)); + if (rejection) return failed(rejection, rejection === "handoff_mode_retired" ? HANDOFF_MODE_RETIRED_REASON + : `current authority rejects lease acquire replay: ${rejection}`, details); if (!current || !leaseIsActive(current, input.now) || current.owner !== input.owner || current.idempotency_key !== input.idempotency_key || leaseEpoch(current) !== leaseEpoch(original) || leaseVersion(current) < leaseVersion(original)) { diff --git a/loopx/control_plane/coordination/task_lease_acquire.ts b/loopx/control_plane/coordination/task_lease_acquire.ts index f2335e7150..792f38c165 100644 --- a/loopx/control_plane/coordination/task_lease_acquire.ts +++ b/loopx/control_plane/coordination/task_lease_acquire.ts @@ -80,7 +80,9 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra const decision = decideTaskLeaseAcquire({handoff_mode: mode!, registered_agents: input.registered_agents, ...facts, command: input}); if (decision.outcome === "rejected" || decision.outcome === "conflict") { - return failed(decision.code, `canonical task lease acquire rejected: ${decision.code}`, { + return failed(decision.code, decision.code === "handoff_mode_retired" + ? "Legacy ownership cannot grant new execution; release retained leases and review handoff-mode plan-migration/migrate before acquiring again." + : `canonical task lease acquire rejected: ${decision.code}`, { ...(decision.code === "write_scope_conflict" ? {recommended_action: "Coordinate shared writes with the listed holders. For isolated code edits, use your own separate Git worktree and acquire with --write-worktree; file overlaps become integration advisories. A known same-checkout collision remains rejected. Never take over a foreign lease or use code-edit mode for shared runtime state."} : {}), handoff_mode: mode, expected_version: input.expected_version, actual_version: leaseVersion(facts.current), diff --git a/loopx/control_plane/coordination/task_lease_lifecycle.ts b/loopx/control_plane/coordination/task_lease_lifecycle.ts index f9c1f600bc..0719fd9b13 100644 --- a/loopx/control_plane/coordination/task_lease_lifecycle.ts +++ b/loopx/control_plane/coordination/task_lease_lifecycle.ts @@ -11,7 +11,7 @@ import {planLeaseClaimTransfer} from "./lease_claim_transfer.ts"; import {decideTaskLeaseLifecycle, materializeTaskLeaseLifecycle, type TaskLeaseLifecycleDecisionOperation, type TaskLeaseLifecycleDecisionCommand} from "../work_items/task_lease_lifecycle_decision.ts"; import {requireStringLiteral} from "../runtime_decode.ts"; -import {HANDOFF_MODES} from "./handoff_mode_policy.ts"; +import {HANDOFF_MODES, retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {leaseEpoch, leaseIsActive, leaseVersion, normalizeAgent, normalizeGoalId, normalizeIdempotencyKey, normalizeOwner, normalizeTodoId, normalizeTtl, utcIsoformat, TaskLeaseAcquireError, type LeaseRecord} from "../work_items/task_lease_acquire.ts"; @@ -112,6 +112,9 @@ export async function executeCanonicalTaskLeaseLifecycle(store: AuthorityStore, const current = await store.loadAuthority(); if (current.status !== "loaded") throw new AuthorityStoreProtocolError("current lease authority unavailable"); validateCoordinationTodoReadModel(current.head, input.goal_id); + const retired = retiredHandoffModeRejection(current.head.handoff_mode ?? "legacy"); + if (retired !== null) return {...failed(retired, HANDOFF_MODE_RETIRED_REASON), + original_receipt: result.original_receipt}; const dependency = todoExecutionDependencyRejection( indexCoordinationProjection(current.head, input.goal_id).todos, input.todo_id, input.now); return dependency === null ? result : {...failed(dependency.code, dependency.reason), @@ -151,7 +154,9 @@ export async function executeCanonicalTaskLeaseLifecycle(store: AuthorityStore, ...(lease.write_repository == null ? {} : {write_repository: String(lease.write_repository)}), acquire_ttl_seconds: null} : null, command}); if (decision.outcome === "rejected" || decision.outcome === "conflict") { - return {...failed(decision.code, `canonical task lease ${input.operation} rejected: ${decision.code}`), + return {...failed(decision.code, decision.code === "handoff_mode_retired" + ? "Legacy ownership cannot renew or transfer execution; release retained leases and review handoff-mode plan-migration/migrate." + : `canonical task lease ${input.operation} rejected: ${decision.code}`), handoff_mode: mode, expected_version: input.expected_version, actual_version: leaseVersion(lease), ...(todo ? {todo_status: todo.status, claimed_by: todo.claimed_by ?? null, excluded_agents: excluded} : {})}; } diff --git a/loopx/control_plane/coordination/task_lease_proof.ts b/loopx/control_plane/coordination/task_lease_proof.ts index 0489444603..bf6ea682c0 100644 --- a/loopx/control_plane/coordination/task_lease_proof.ts +++ b/loopx/control_plane/coordination/task_lease_proof.ts @@ -1,5 +1,6 @@ /** Current nonterminal mutation proof. Admission stays in the lifecycle owner; * this boundary never acquires, renews, releases or transfers a lease. */ +import {retiredHandoffModeRejection} from "./handoff_mode_policy.ts"; import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; @@ -70,6 +71,8 @@ export function evaluateCanonicalTaskLeaseProof(input: { actor_agent_id: string | null; registered_agents: readonly string[]; lease_idempotency_key: string | null; lease_expected_version: number | null; now: Date; }) { + const retired = retiredHandoffModeRejection(input.handoff_mode); + if (retired !== null) return {outcome: "rejected", code: retired}; const {lease} = input; if (!(input.now instanceof Date) || !Number.isFinite(input.now.valueOf())) { throw new EffectRuntimeRequestError("lease proof requires a valid transaction clock"); diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index 2ecd77edaa..1bebb93f6c 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -1,3 +1,4 @@ +import {retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; import {currentLeaseAcquisitionProof} from "./lease_acquisition_proof.ts"; import {canonicalTaskLeaseAcquireFacts} from "./task_lease_state.ts"; @@ -526,6 +527,9 @@ async function executeClaimAttempt( if (!["legacy", "soft_claim", "hard_lease"].includes(handoffMode)) { return failure("invalid_handoff_mode", "canonical projection has an invalid handoff mode"); } + const retired = retiredHandoffModeRejection(handoffMode); + if (retired !== null) return failure(retired, HANDOFF_MODE_RETIRED_REASON, + {todo_id: input.todo_id, handoff_mode: handoffMode}, "decision_rejection"); if (leaseRequest !== null && handoffMode !== "hard_lease") { return failure( "claim_lease_requires_hard_lease", diff --git a/loopx/control_plane/coordination/todo_lifecycle_decision.ts b/loopx/control_plane/coordination/todo_lifecycle_decision.ts index 8f732ccdde..1a0d63bc71 100644 --- a/loopx/control_plane/coordination/todo_lifecycle_decision.ts +++ b/loopx/control_plane/coordination/todo_lifecycle_decision.ts @@ -27,7 +27,7 @@ export const COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA = "loopx_coordination_terminal_fence_request_v0"; export const COORDINATION_TERMINAL_FENCE_RESULT_SCHEMA = "loopx_coordination_terminal_fence_result_v0"; -import {HANDOFF_MODES, type HandoffMode} from "./handoff_mode_policy.ts"; +import {HANDOFF_MODES, retiredHandoffModeRejection, type HandoffMode} from "./handoff_mode_policy.ts"; const OUTCOMES = ["approve", "reject", "cancel"] as const; const AUTHORITY_ACTIONS = ["complete", "reassign", "supersede", "update"] as const; const EXECUTOR_RECLAIM_ACTION = "reclaim"; @@ -79,7 +79,7 @@ interface LifecycleDecisionRequest { readonly command: LifecycleCommand; readonly handoff_mode: HandoffMode; readonly registered_agents: readonly string[]; - readonly lifecycle_grants: readonly LifecycleGrant[]; + readonly resolve_lifecycle_grants: () => readonly LifecycleGrant[]; readonly todo: TodoFact; readonly decision_target: TodoFact | null; readonly lease: LeaseFact | null; @@ -233,7 +233,7 @@ function lifecycleGrants( agent_id: agentId, actions: [...new Set(actions)], requires_reason: requireBoolean( - grant.requires_reason, + grant.requires_reason === undefined ? true : grant.requires_reason, `lifecycle_grants[${index}].requires_reason`, ), }; @@ -276,14 +276,22 @@ function decodeRequest(value: unknown, kind: "terminal" | "mutation" = "terminal ); const actor = optionalAgent(request.actor_agent_id, "actor_agent_id"); const internalReclaim = kind === "mutation" && request.authority_action === EXECUTOR_RECLAIM_ACTION; + // The executor-only action is never a public delegation, even if unused. + if (!internalReclaim && Array.isArray(request.lifecycle_grants) && + request.lifecycle_grants.some(grant => grant !== null && typeof grant === "object" && + Array.isArray(grant.actions) && grant.actions.some((action: unknown) => + typeof action === "string" && action.trim().toLowerCase() === EXECUTOR_RECLAIM_ACTION))) { + throw new EffectRuntimeRequestError("executor reclaim is not a public lifecycle grant"); + } + const reclaimGrants = internalReclaim ? executorReclaimGrant(request, actor) : null; const outcome = kind === "terminal" ? optionalString(request.decision_outcome, "decision_outcome") : null; const decoded = { command: requireStringLiteral(request.command, kind === "terminal" ? COMMANDS : MUTATION_COMMANDS, "command"), handoff_mode: requireStringLiteral(request.handoff_mode, HANDOFF_MODES, "handoff_mode"), registered_agents: registeredAgents, - lifecycle_grants: internalReclaim ? executorReclaimGrant(request, actor) - : lifecycleGrants(request.lifecycle_grants ?? [], registeredAgents), + resolve_lifecycle_grants: () => reclaimGrants ?? + lifecycleGrants(request.lifecycle_grants ?? [], registeredAgents), todo: request.todo === null ? null : todoFact(request.todo, "todo"), decision_target: kind !== "terminal" || request.decision_target === null || request.decision_target === undefined ? null @@ -377,6 +385,8 @@ function authority(request: LifecycleDecisionRequest): | { mode: string; ownershipGate: CoordinationTodoTerminalDecisionResult["ownership_gate"] } | CoordinationTodoTerminalDecisionResult { const { todo, actor_agent_id: actor, registered_agents: registered } = request; + const retired = retiredHandoffModeRejection(request.handoff_mode); + if (retired !== null) return result("rejected", retired); if (registered.length <= 1) { if (actor !== null) { const rejection = registeredTodoMutationRejection(todo, actor, registered); @@ -395,7 +405,9 @@ function authority(request: LifecycleDecisionRequest): const rejection = registeredTodoMutationRejection(todo, actor, registered); if (rejection !== null && rejection !== "claim_owner_mismatch") return result("rejected", rejection); if (rejection === "claim_owner_mismatch") { - const grant = request.lifecycle_grants.find((candidate) => candidate.agent_id === actor); + // Delegation is consulted only after ordinary actor/owner authorization. + // Unrelated invalid grants cannot disable the owner or exact user gate. + const grant = request.resolve_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"); @@ -433,6 +445,8 @@ function terminalFence( authorityMode: string | null, requireActiveWhenFenceSupplied = true, ): CoordinationTodoTerminalDecisionResult { + const retired = retiredHandoffModeRejection(request.handoff_mode); + if (retired !== null) return result("rejected", retired); const lease = request.lease; const timeActive = lease !== null && lease.present && lease.active; const effective = timeActive && ownerEligible(request, lease.owner); diff --git a/loopx/control_plane/coordination/todo_monitor_cycle.ts b/loopx/control_plane/coordination/todo_monitor_cycle.ts index c62571cac8..fd8f42516f 100644 --- a/loopx/control_plane/coordination/todo_monitor_cycle.ts +++ b/loopx/control_plane/coordination/todo_monitor_cycle.ts @@ -2,7 +2,7 @@ * Admission and observation validation run first; all effects join their CAS. */ import {evaluateCoordinationTodoMutationDecision, COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA} from "./todo_lifecycle_decision.ts"; import {evaluateCanonicalTaskLeaseProof, type TaskLeaseProof} from "./task_lease_proof.ts"; -import {HANDOFF_MODES} from "./handoff_mode_policy.ts"; +import {HANDOFF_MODES, HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {requireStringLiteral} from "../runtime_decode.ts"; import type {JsonObject} from "../effect_program.ts"; import type {CoordinationProjectionMutation} from "./coordination_projection.ts"; @@ -62,7 +62,8 @@ export function monitorMutationRejection(input: { }); if (decision.outcome !== "apply") { return reject(decision.code === "claim_owner_mismatch" ? "update_owner_mismatch" : String(decision.code), - "Monitor observation is outside the actor's registered owner/binding scope"); + decision.code === "handoff_mode_retired" ? HANDOFF_MODE_RETIRED_REASON + : "Monitor observation is outside the actor's registered owner/binding scope"); } if (input.operation === "reactivate") { return input.proof === null ? null : reject("monitor_reactivation_execution_proof_not_allowed", @@ -76,7 +77,6 @@ export function monitorMutationRejection(input: { } return executionWait(); } - if (lease === undefined && mode !== "hard_lease" && input.proof === null) return executionWait(); const fence = evaluateCanonicalTaskLeaseProof({todo, lease, handoff_mode: mode, actor_agent_id: actor, registered_agents: registered, now: input.now, lease_idempotency_key: input.proof?.idempotency_key ?? null, diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index d95283c812..9a5656266e 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1,3 +1,4 @@ +import {HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {planUserCompletion, requireCompletionDecisionOutcome} from "../todos/user_completion.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; import { @@ -1035,6 +1036,7 @@ interface UntypedCompletionRepairPlan { function validateUntypedCompletionRepairPolicy( request: JsonObject, input: ResolvedCoordinationTodoTerminalLifecycleInput, + todo: JsonObject, ): void { const allowedFields = new Set([ "schema_version", @@ -1059,7 +1061,9 @@ function validateUntypedCompletionRepairPolicy( if (request.goal_id !== input.goal_id || canonicalAuthoritySha256(policy.registered_agents) !== canonicalAuthoritySha256(input.registered_agents) || - policy.effective_claimed_by !== null || + // An explicit repeat of the stored claim is a source fact, not an edit. + (policy.effective_claimed_by !== null && + policy.effective_claimed_by !== (todo.claimed_by ?? null)) || policy.effective_next_claimed_by !== null || policy.effective_next_excluded_agents.length > 0 || policy.self_merged || @@ -1122,6 +1126,7 @@ function planUntypedCompletionRepair( validateUntypedCompletionRepairPolicy( input.completion_policy_request, input, + todo, ); } if (input.validation_declaration_sha256 != null && @@ -1354,7 +1359,8 @@ export async function executeCoordinationTodoTerminalLifecycle( if (authority.outcome === "rejected" || authority.outcome === "conflict") { return terminalFailure( authority.code, - `canonical Todo terminal lifecycle rejected the request: ${authority.code}`, + authority.code === "handoff_mode_retired" ? HANDOFF_MODE_RETIRED_REASON + : `canonical Todo terminal lifecycle rejected the request: ${authority.code}`, {terminal_decision: authority}, "decision_rejection", ); @@ -1670,6 +1676,9 @@ export async function executeCoordinationTodoTerminalLifecycle( ); } + const terminalDecisionReceipt = authority.authority_mode === "delegated_orchestration_override" + ? {...authority, authority_reason: input.authority_reason} : authority; + if (authority.outcome === "no_change" && (edit === null || !edit.changed) && terminalUpgradeReceipt === null) { const existingSuccessorIds = Array.isArray(todo.successor_todo_ids) ? todo.successor_todo_ids.map((value, index) => @@ -1688,7 +1697,7 @@ export async function executeCoordinationTodoTerminalLifecycle( todo_id: input.todo_id, command: input.command, changed: false, - terminal_decision: authority, + terminal_decision: terminalDecisionReceipt, successor_todo_ids: existingSuccessorIds, generated_successor_todo_ids: [], validation_receipt: null, @@ -1838,7 +1847,7 @@ export async function executeCoordinationTodoTerminalLifecycle( ...(input.decision_outcome === null ? {} : {decision_outcome: input.decision_outcome}), ...(followthrough?.unblock_resume ? {unblock_resume: followthrough.unblock_resume} : {}), ...(followthrough?.decision_scope_resolution ? {decision_scope_resolution: followthrough.decision_scope_resolution} : {}), - terminal_decision: authority, + terminal_decision: terminalDecisionReceipt, successor_todo_ids: successorIds, generated_successor_todo_ids: generatedSuccessorIds, generated_successors: successorCandidates, diff --git a/loopx/control_plane/coordination/todo_update_admission.ts b/loopx/control_plane/coordination/todo_update_admission.ts index 4f2b41a056..979376f3ed 100644 --- a/loopx/control_plane/coordination/todo_update_admission.ts +++ b/loopx/control_plane/coordination/todo_update_admission.ts @@ -1,6 +1,7 @@ /** Admission for Todo edits; terminal completion retains its own lease proof. * Grants may cross a claim owner; * exclusions, bindings and execution lineage remain independent restrictions. */ +import {retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "./handoff_mode_policy.ts"; import {leaseRepositoryRejection} from "../work_items/task_lease_repository.ts"; import {isExploreReferenceAppend} from "../todos/field_update.ts"; import {acceptanceRestoration} from "./todo_acceptance_restoration.ts"; @@ -49,6 +50,8 @@ export function todoUpdateAdmissionRejection( if (typeof mode !== "string" || !["legacy", "soft_claim", "hard_lease"].includes(mode)) { return reject("invalid_handoff_mode", "canonical handoff mode is invalid"); } + const retired = retiredHandoffModeRejection(mode); + if (retired !== null) return {...reject(retired, HANDOFF_MODE_RETIRED_REASON), handoff_mode: mode}; const intent = input.planning_intent ?? {}; const ownershipMutation = ["claimed_by", "clear_claim", "excluded_agents", "bound_agent", "goal_bound", "blocks_agent", "clear_blocks_agent", "global_gate", "clear_global_gate"] diff --git a/loopx/control_plane/testing/canary_harness.py b/loopx/control_plane/testing/canary_harness.py index 724a19227c..3fb37301d4 100644 --- a/loopx/control_plane/testing/canary_harness.py +++ b/loopx/control_plane/testing/canary_harness.py @@ -184,11 +184,11 @@ def read_run_index(runtime_root: Path, goal_id: str) -> list[dict[str, Any]]: ] -def import_fixture_authority(registry_path: Path, goal_id: str) -> None: +def import_fixture_authority(registry_path: Path, goal_id: str, *, provider: str = "file") -> None: """Import a disposable smoke's complete source through the real saved plan. Fixture writers have finished before this call. Historical metadata stays - intact; normal writes afterwards use the canonical File/soft-claim owner. + intact; normal writes afterwards use the selected canonical/soft-claim owner. Existing unpromoted/refusal fixtures opt in explicitly, never by default. """ from ...state_backup import build_state_backup_plan, execute_state_backup_plan @@ -200,6 +200,10 @@ def import_fixture_authority(registry_path: Path, goal_id: str) -> None: runtime = Path(registry["common_runtime_root"]).resolve() # Bind the physical source consistently on hosts whose temp path is a link. goal["repo"] = str(project) + state_file = Path(goal["state_file"]).expanduser() + if not state_file.is_absolute(): + state_file = project / state_file + goal["state_file"] = str(state_file.resolve()) registry_path.write_text(json.dumps(registry), encoding="utf-8") runtime.mkdir(parents=True, exist_ok=True) backup = execute_state_backup_plan(build_state_backup_plan( @@ -214,7 +218,7 @@ def import_fixture_authority(registry_path: Path, goal_id: str) -> None: prepared = run_json_cli( *prefix, "prepare-import", *identity, "--backup-manifest", backup["manifest_path"], - "--provider", "file", "--target-handoff-mode", "soft_claim", + "--provider", provider, "--target-handoff-mode", "soft_claim", registry_path=registry_path, runtime_root=runtime, )["cold_import"] assert prepared["status"] == "prepared", prepared @@ -231,6 +235,6 @@ def import_fixture_authority(registry_path: Path, goal_id: str) -> None: "todo", "list", "--goal-id", goal_id, registry_path=registry_path, runtime_root=runtime, )["authority_read"] - assert readback["source_authority"] == "file_v0", readback + assert readback["source_authority"] == f"{provider}_v0", readback assert readback["decision_read_from_provider"] is True, readback assert readback["legacy_fallback_used"] is False, readback diff --git a/loopx/control_plane/todos/handoff_mode.py b/loopx/control_plane/todos/handoff_mode.py index 353fa78764..9732b2613b 100644 --- a/loopx/control_plane/todos/handoff_mode.py +++ b/loopx/control_plane/todos/handoff_mode.py @@ -1,30 +1,22 @@ """Per-goal handoff mode: which ownership authority governs todo handoffs. -Before promotion the active-state frontmatter declares ``handoff_mode``; afterward -the selected canonical provider owns it. Public show/set route by that authority: - -* absent / ``legacy`` (default): today's dual soft-claim + hard-lease - behavior, byte-for-byte. The known soft-claim/hard-lease split brain stays - open in this mode by design; it is surfaced additively, never silently - repaired. -* ``soft_claim``: the Todo claim is the only ownership record. Task-lease - acquire/renew/transfer are typed-rejected; release and inspect stay allowed - for cleanup and observability of legacy leftovers. -* ``hard_lease``: ownership changes on an existing todo require the acting - agent to hold that todo's time-active task lease, and the completion fence - becomes mandatory for both user-role and agent-role todos. An exact linked - user-gate decision-scope override authorizes only the exact linked decision - transition; it does not bypass the completion fence. The delegated - ``coordination.todo_lifecycle_authority`` override is the one audited door - through the gate. - -Canonical mode, complete Todo/lease quiescence, CAS and replay share one TypeScript -transaction. Stale or missing Markdown and local lease files are not fallback -sources. The legacy mode below remains a frontmatter compatibility contract. - -Unpromoted transitions read complete Markdown Todos and local leases under -their writer mutexes. The same typed quiescence rule governs both paths; -nonempty retired Todo event sources are refused, never treated as empty. +The canonical provider owns the current policy; Markdown remains a historical +source. Current execution uses only ``soft_claim`` or ``hard_lease``: + +* ``legacy`` (including an absent historical field) is decodable for import, + original receipts and exact lease cleanup. It cannot admit new execution or + turn a historical lease receipt into current proof. Existing Goals require + a reviewed, backed-up policy migration; their bytes are never silently changed. +* ``soft_claim`` uses the Todo assignment. New lease acquire/renew/transfer are + rejected; retained release and inspection remain available. +* ``hard_lease`` uses the current execution proof and typed ownership/terminal + rules. Claims, delegated actions and linked user decisions do not independently + grant a lease or bypass the transaction's fence. + +TypeScript owns policy admission, quiescence, migration, CAS and receipt recovery. +Python retains source decoding, transport and public result adaptation. A missing +or stale display never replaces canonical authority; unpromoted writes refuse. + """ from __future__ import annotations diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 1c488e8c15..abc4c89c9f 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -241,6 +241,7 @@ def _normalize_host_path_delta( *, unchanged_reason: str, errors: list[str], + from_journal: bool = False, ) -> tuple[str, dict[str, Any] | None]: raw_path_delta_mode = result.get("path_delta_mode") path_delta_mode = "" @@ -270,7 +271,25 @@ def _normalize_host_path_delta( errors.append("path_delta_mode must be unchanged or material_replan") agent_vision: dict[str, Any] | None = None - if agent_vision_json: + if from_journal and "agent_vision" in result: + # The journal stores the typed owner's decoded packet. Revalidate it + # through that same owner, without treating derived budget diagnostics + # as a new Host wire string or widening the untrusted Host protocol. + try: + if "agent_vision_json" in result: + raise ValueError("cached vision cannot carry both packet forms") + packet = result["agent_vision"] + if not isinstance(packet, dict): + raise TypeError("cached agent_vision must be a JSON object") + envelope = plan.get("turn_envelope") or {} + agent_vision = normalize_goal_vision_packet( + packet, + goal_id=str(envelope.get("goal_id") or ""), + agent_id=str(envelope.get("agent_id") or "") or None, + ) + except (TypeError, ValueError) as exc: + errors.append(f"invalid cached agent_vision: {exc}") + elif agent_vision_json: if len(agent_vision_json) > HOST_AGENT_VISION_JSON_MAX_CHARS: errors.append( "agent_vision_json exceeds " @@ -321,10 +340,13 @@ def validate_loopx_turn_host_result( value: Mapping[str, Any], *, completion_lifecycle_configured: bool = False, + from_journal: bool = False, ) -> dict[str, Any]: result = dict(value) errors: list[str] = [] allowed_fields = HOST_RESULT_FIELDS | subagent.subagent_host_result_fields(plan) + if from_journal: + allowed_fields = allowed_fields | {"agent_vision"} unknown = sorted(set(result) - allowed_fields) if unknown: errors.append("unsupported host result fields: " + ", ".join(unknown)) @@ -415,6 +437,7 @@ def validate_loopx_turn_host_result( result, unchanged_reason=unchanged_reason, errors=errors, + from_journal=from_journal, ) if ( path_delta_mode == "material_replan" @@ -430,6 +453,7 @@ def validate_loopx_turn_host_result( elif ( str(result.get("path_delta_mode") or "").strip() or str(result.get("agent_vision_json") or "").strip() + or (from_journal and "agent_vision" in result) ): errors.append( "non-material host results cannot declare path_delta_mode or agent_vision_json" @@ -851,6 +875,7 @@ def _host_result_stage( usage_goal_id: str = "", ) -> tuple[dict[str, Any] | None, list[str], dict[str, Any] | None]: completed_phases = list(journal.get("completed_phases") or []) + from_journal = "typed_result" in completed_phases result = ( journal.get("host_result") if isinstance(journal.get("host_result"), dict) @@ -929,6 +954,7 @@ def _host_result_stage( plan, result or {}, completion_lifecycle_configured=completion_lifecycle_configured, + from_journal=from_journal, ) if not validation.get("ok"): failure = _host_failure( diff --git a/loopx/control_plane/turn_driver/host_process_group.ts b/loopx/control_plane/turn_driver/host_process_group.ts index 19ea4acc14..6419199ae1 100644 --- a/loopx/control_plane/turn_driver/host_process_group.ts +++ b/loopx/control_plane/turn_driver/host_process_group.ts @@ -7,6 +7,7 @@ const CLEANUP_DEADLINE_MS = 1000; /** Zombies cannot execute; an unknown/non-zombie state must remain live. */ export function hasLiveGroupMembers(snapshot: string, pgid: number): boolean { + if (!snapshot.trim()) throw new Error("invalid managed Host process-group observation"); let live = false; for (const line of snapshot.split("\n")) { if (!line.trim()) continue; @@ -21,6 +22,8 @@ export function hasLiveGroupMembers(snapshot: string, pgid: number): boolean { export async function waitForProcessGroupStop(pgid: number): Promise { const deadline = performance.now() + CLEANUP_DEADLINE_MS; while (true) { + const remaining = Math.ceil(deadline - performance.now()); + if (remaining <= 0) throw new Error("managed Host process group did not stop before cleanup deadline"); try { process.kill(-pgid, 0); } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -29,8 +32,6 @@ export async function waitForProcessGroupStop(pgid: number): Promise { // of death: require the same fresh state observation as a live group. if (code !== "EPERM") throw error; } - const remaining = Math.ceil(deadline - performance.now()); - if (remaining <= 0) throw new Error("managed Host process group did not stop before cleanup deadline"); // POSIX ps exposes only group/state, not argv or environment. Unlike PID // absence alone this also works with orphan zombies awaiting init's reap. const snapshot = await new Promise((resolve, reject) => { @@ -40,6 +41,9 @@ export async function waitForProcessGroupStop(pgid: number): Promise { ? "managed Host process group did not stop before cleanup deadline" : "managed Host process-group observation failed", {cause: error})) : resolve(stdout)); }); + // A callback can arrive after the deadline even without an execFile error. + // Late or empty output must not turn a timed-out observation into absence. + if (performance.now() >= deadline) throw new Error("managed Host process group did not stop before cleanup deadline"); if (!hasLiveGroupMembers(snapshot, pgid)) return; await delay(Math.min(10, Math.max(0, deadline - performance.now()))); } diff --git a/loopx/control_plane/work_items/local_lease_record.py b/loopx/control_plane/work_items/local_lease_record.py index 23887e829e..c7e575f0a9 100644 --- a/loopx/control_plane/work_items/local_lease_record.py +++ b/loopx/control_plane/work_items/local_lease_record.py @@ -1,18 +1,15 @@ -"""Local task-lease record codec and generation normalization. +"""Read retained task-lease JSON and report transport errors. -This module owns the JSON/tombstone shape at the existing file adapter edge. -It does not decide coordination transitions or perform locking. +Current lifecycle decisions, generation validation and writes belong to the +TypeScript owner. Historical readers do not mint or rewrite lease records. """ from __future__ import annotations import json -from datetime import datetime from pathlib import Path from typing import Any -from ..runtime.time import utc_isoformat - TASK_LEASE_SCHEMA_VERSION = "task_lease_v0" @@ -48,174 +45,3 @@ def read_lease(path: Path) -> dict[str, Any] | None: payload={"lease_path": str(path)}, ) return payload - - -def write_lease(path: Path, payload: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temp_path = path.with_name(f".{path.name}.{id(payload)}.tmp") - temp_path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - temp_path.replace(path) - - -def lease_epoch(lease: dict[str, Any] | None) -> int: - """Return the authority-owned generation for one todo's lease lineage.""" - - if not lease: - return 0 - raw_epoch = lease.get("lease_epoch") - if raw_epoch is None: - # A pre-lease_epoch record is the first visible generation. - return 1 - if isinstance(raw_epoch, bool): - # bool is an int subclass: JSON true must not become epoch 1. - raise TaskLeaseError( - "lease epoch must be a positive integer", - code="corrupt_lease", - payload={"lease_epoch": raw_epoch}, - ) - try: - epoch = int(raw_epoch) - except (TypeError, ValueError) as exc: - raise TaskLeaseError( - "lease epoch must be a positive integer", - code="corrupt_lease", - payload={"lease_epoch": raw_epoch}, - ) from exc - if epoch <= 0: - raise TaskLeaseError( - "lease epoch must be a positive integer", - code="corrupt_lease", - payload={"lease_epoch": raw_epoch}, - ) - return epoch - - -def lease_version(lease: dict[str, Any] | None) -> int: - """Normalize the local CAS version without leaking ``ValueError``.""" - - raw_version = (lease or {}).get("version") - if raw_version is None: - return 0 - if isinstance(raw_version, bool): - raise TaskLeaseError( - "lease version must be a non-negative integer", - code="corrupt_lease", - payload={"version": raw_version}, - ) - try: - version = int(raw_version) - except (TypeError, ValueError) as exc: - raise TaskLeaseError( - "lease version must be a non-negative integer", - code="corrupt_lease", - payload={"version": raw_version}, - ) from exc - if version < 0: - raise TaskLeaseError( - "lease version must be a non-negative integer", - code="corrupt_lease", - payload={"version": raw_version}, - ) - return version - - -def lease_acquire_ttl_seconds(lease: dict[str, Any] | None) -> int | None: - """Read the acquire fingerprint only for acquire replay decisions.""" - - raw_ttl = (lease or {}).get("acquire_ttl_seconds") - if raw_ttl is None: - return None - if isinstance(raw_ttl, bool): - raise TaskLeaseError( - "lease acquire_ttl_seconds must be an integer", - code="corrupt_lease", - payload={"acquire_ttl_seconds": raw_ttl}, - ) - try: - return int(raw_ttl) - except (TypeError, ValueError) as exc: - raise TaskLeaseError( - "lease acquire_ttl_seconds must be an integer", - code="corrupt_lease", - payload={"acquire_ttl_seconds": raw_ttl}, - ) from exc - - -def released_lease_payload( - lease: dict[str, Any], - *, - at: datetime, -) -> dict[str, Any]: - released = dict(lease) - released["lease_epoch"] = lease_epoch(lease) - released["status"] = "released" - released["released_at"] = utc_isoformat(at) - released["updated_at"] = utc_isoformat(at) - return released - - -def assert_expected_version( - lease: dict[str, Any] | None, - expected_version: int | None, -) -> None: - if expected_version is None: - return - actual = lease_version(lease) - if actual != expected_version: - raise TaskLeaseError( - f"lease version mismatch: expected {expected_version}, got {actual}", - code="version_mismatch", - payload={"expected_version": expected_version, "actual_version": actual}, - ) - - -def require_expected_version(expected_version: int | None, *, action: str) -> int: - if expected_version is None: - raise TaskLeaseError( - f"task lease {action} requires the current lease version", - code="version_required", - payload={"action": action}, - ) - if isinstance(expected_version, bool): - # bool is an int subclass; JSON true from a tool argument must not - # silently become expected version 1. - raise TaskLeaseError( - f"task lease {action} requires an integer lease version", - code="version_required", - payload={"action": action, "expected_version": expected_version}, - ) - return int(expected_version) - - -def build_lease( - *, - goal_id: str, - todo_id: str, - owner: str, - idempotency_key: str, - write_scopes: list[str], - acquire_ttl_seconds: int, - version: int, - lease_epoch: int, - acquired_at: str, - updated_at: str, - expires_at: str, -) -> dict[str, Any]: - return { - "schema_version": TASK_LEASE_SCHEMA_VERSION, - "goal_id": goal_id, - "todo_id": todo_id, - "owner": owner, - "idempotency_key": idempotency_key, - "write_scopes": write_scopes, - "acquire_ttl_seconds": acquire_ttl_seconds, - "version": version, - "lease_epoch": lease_epoch, - "acquired_at": acquired_at, - "updated_at": updated_at, - "expires_at": expires_at, - "status": "active", - } diff --git a/loopx/control_plane/work_items/task_lease.py b/loopx/control_plane/work_items/task_lease.py index e7e1abd857..e468774f10 100644 --- a/loopx/control_plane/work_items/task_lease.py +++ b/loopx/control_plane/work_items/task_lease.py @@ -5,10 +5,6 @@ from pathlib import Path from typing import Any -from ...agent_registry import ( - registered_agent_ids_from_registry, - require_registered_agent_id, -) from ...history import load_registry from ...paths import resolve_runtime_root from ..runtime.time import now_utc as runtime_now_utc @@ -19,20 +15,12 @@ from ..todos.contract import ( normalize_required_write_scopes, normalize_todo_claimed_by, - normalize_todo_excluded_agents, normalize_todo_id, ) from .local_lease_record import ( TASK_LEASE_SCHEMA_VERSION, TaskLeaseError, - assert_expected_version as assert_expected_version, - build_lease as build_lease, - lease_acquire_ttl_seconds as lease_acquire_ttl_seconds, - lease_epoch as lease_epoch, - lease_version as lease_version, read_lease as read_lease, - require_expected_version as require_expected_version, - write_lease as write_lease, ) DEFAULT_TASK_LEASE_TTL_SECONDS = 45 * 60 @@ -138,138 +126,6 @@ def runtime_root_from_registry( ) -def task_lease_todo_projection( - *, - registry_path: Path, - goal_id: str, - todo_id: str, -) -> dict[str, Any] | None: - """Resolve the canonical Markdown/event todo projection for lease guards.""" - - from ...control_plane.todos.list_readback import list_goal_todos - - try: - payload = list_goal_todos( - registry_path=registry_path, - goal_id=goal_id, - todo_id=todo_id, - ) - except (OSError, ValueError) as exc: - raise TaskLeaseError( - "cannot resolve todo projection for task lease", - code="todo_projection_unavailable", - payload={"goal_id": goal_id, "todo_id": todo_id, "error": str(exc)}, - ) from exc - todo = payload.get("todo") - return dict(todo) if isinstance(todo, dict) else None - - -def task_lease_owner_constraint( - todo: dict[str, Any] | None, - *, - owner: Any, - registered_agents: list[str] | None = None, -) -> dict[str, Any]: - from ..effect_runtime import effect_runtime_result - - normalized_owner = normalize_todo_claimed_by(owner) - registered = registered_agents if registered_agents is not None else ([normalized_owner] if normalized_owner else []) - result = effect_runtime_result("task_lease.owner_eligibility", { - "todo": None if todo is None else { - "status": str(todo.get("status") or "").strip().lower(), - "claimed_by": normalize_todo_claimed_by(todo.get("claimed_by")), - "excluded_agents": normalize_todo_excluded_agents(todo.get("excluded_agents")), - }, - "owner": normalized_owner, "registered_agents": registered, - }) - if not isinstance(result, dict) or result.get("schema_version") != "task_lease_owner_eligibility_v0": - raise RuntimeError("typed lease owner constraint is missing; update the runtime") - constraint = result.get("constraint") - if not isinstance(constraint, dict) or not isinstance(constraint.get("effective"), bool): - raise RuntimeError("typed lease owner constraint shape mismatch") - if constraint["effective"] is False and not isinstance(constraint.get("reason"), str): - raise RuntimeError("typed lease owner rejection omitted its reason") - return constraint - - -def require_registered_task_lease_owner( - *, - registry_path: Path, - goal_id: str, - todo_id: str, - owner: str, -) -> str: - try: - return require_registered_agent_id( - registry_path=registry_path, - goal_id=goal_id, - agent_id=owner, - field="task lease owner", - ) - except ValueError as exc: - raise TaskLeaseError( - str(exc), - code="owner_not_registered", - payload={"goal_id": goal_id, "todo_id": todo_id, "owner": owner}, - ) from exc - - -def require_task_lease_owner_allowed( - *, - registry_path: Path, - goal_id: str, - todo_id: str, - owner: str, -) -> dict[str, Any]: - owner = require_registered_task_lease_owner( - registry_path=registry_path, - goal_id=goal_id, - todo_id=todo_id, - owner=owner, - ) - todo = task_lease_todo_projection( - registry_path=registry_path, - goal_id=goal_id, - todo_id=todo_id, - ) - constraint = task_lease_owner_constraint( - todo, - owner=owner, - registered_agents=registered_agent_ids_from_registry(registry_path, goal_id), - ) - if constraint.get("effective") is not True: - reason = str(constraint.get("reason") or "owner_not_allowed") - if reason == "todo_not_found": - message = "todo is missing from the canonical projection" - elif reason == "todo_not_open": - message = ( - f"task lease requires an open todo; " - f"{todo_id!r} is {constraint.get('todo_status')!r}" - ) - elif reason == "owner_excluded_from_todo": - message = f"task lease owner {owner!r} is excluded from todo {todo_id!r}" - elif reason == "owner_conflicts_with_claim": - message = ( - f"task lease owner {owner!r} conflicts with todo claim " - f"{constraint.get('claimed_by')!r}" - ) - elif reason == "owner_not_registered": - message = f"task lease owner {owner!r} is not registered for goal {goal_id!r}" - else: - message = f"task lease owner {owner!r} is not eligible for todo {todo_id!r}" - raise TaskLeaseError( - message, - code=reason, - payload={ - "goal_id": goal_id, - "todo_id": todo_id, - "owner": owner, - **constraint, - }, - ) - return todo - - def lease_expires_at(lease: dict[str, Any] | None) -> datetime | None: return parse_timestamp((lease or {}).get("expires_at")) diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index b4fc2cf9dd..b66c5781fc 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -1,3 +1,4 @@ +import {HANDOFF_MODES, type HandoffMode} from "../coordination/handoff_mode_vocabulary.ts"; import {LeaseWorktreeObservationError, observeLeaseWorktree, type LeaseWorkspace} from "./task_lease_workspace.ts"; import {isCanonicalTodoId} from "../todos/identity.ts"; import {executeCanonicalTaskLeaseAcquire} from "../coordination/task_lease_acquire.ts"; @@ -386,16 +387,16 @@ export function decodeTaskLeaseAuthority(value: unknown): AuthorityFacts { }; } -export function normalizeHandoffMode(value: unknown): string { +export function normalizeHandoffMode(value: unknown): HandoffMode { const mode = compact(value) || "legacy"; - if (!new Set(["legacy", "soft_claim", "hard_lease"]).has(mode)) { + if (!HANDOFF_MODES.some(candidate => candidate === mode)) { throw new TaskLeaseAcquireError( `unsupported handoff_mode '${mode}'; expected one of: legacy, soft_claim, hard_lease`, "invalid_handoff_mode", - { handoff_mode: mode, supported: ["legacy", "soft_claim", "hard_lease"] }, + { handoff_mode: mode, supported: [...HANDOFF_MODES] }, ); } - return mode; + return mode as HandoffMode; } function decodeRequest(value: unknown): AcquireRequest { diff --git a/loopx/control_plane/work_items/task_lease_acquire_adapter.py b/loopx/control_plane/work_items/task_lease_acquire_adapter.py index dfe42fb58d..7feef5a81c 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_adapter.py +++ b/loopx/control_plane/work_items/task_lease_acquire_adapter.py @@ -18,7 +18,6 @@ from ..goals.legacy_event_source import ( state_event_log_candidates as _state_event_log_candidates, ) -from ..todos.contract import normalize_todo_id from ..todos.handoff_mode import HANDOFF_MODE_LEGACY from .local_lease_record import TASK_LEASE_SCHEMA_VERSION, TaskLeaseError @@ -123,82 +122,22 @@ def _raw_registered_agent_candidates(goal: dict[str, Any]) -> list[Any]: ] -def _compact_task_lease_todo_fact(todo: dict[str, Any]) -> dict[str, Any] | None: - todo_id = normalize_todo_id(todo.get("todo_id")) - if not todo_id: - return None - compacted = { - "todo_id": todo_id, - "status": todo.get("status"), - "claimed_by": todo.get("claimed_by"), - "excluded_agents": todo.get("excluded_agents"), - } - for field in ("role", "task_class", "bound_agent", "blocks_agent"): - if field in todo: - compacted[field] = todo.get(field) - return compacted - - -def _compact_task_lease_todos(projection: dict[str, Any]) -> list[dict[str, Any]]: - todos: list[dict[str, Any]] = [] - for raw_todo in projection.get("todos") or []: - if not isinstance(raw_todo, dict): - continue - compact_todo = _compact_task_lease_todo_fact(raw_todo) - if compact_todo is not None: - todos.append(compact_todo) - return todos - - def _task_lease_authority_projection( *, - registry_path: Path, - goal_id: str, - todo_id: str, goal: dict[str, Any] | None, state_file: Path | None, - project_todos: bool = True, -) -> tuple[Any, list[Any], list[dict[str, Any]], dict[str, Any] | None]: - from ..todos.list_readback import list_goal_todos +) -> tuple[Any, list[Any]]: from ..goals.active_state_metadata import parse_state_frontmatter if goal is None: - return HANDOFF_MODE_LEGACY, [], [], None + return HANDOFF_MODE_LEGACY, [] handoff_mode: Any = HANDOFF_MODE_LEGACY if state_file is not None and state_file.exists(): handoff_mode = parse_state_frontmatter( state_file.read_text(encoding="utf-8") ).get("handoff_mode") - if not project_todos: - return handoff_mode, _raw_registered_agent_candidates(goal), [], None - try: - projection = list_goal_todos( - registry_path=registry_path, - goal_id=goal_id, - ) - except (OSError, ValueError) as exc: - projection_error = { - "code": "todo_projection_unavailable", - "message": "cannot resolve todo projection for task lease", - "payload": { - "goal_id": goal_id, - "todo_id": todo_id, - "error": str(exc), - }, - } - return ( - handoff_mode, - _raw_registered_agent_candidates(goal), - [], - projection_error, - ) - return ( - handoff_mode, - _raw_registered_agent_candidates(goal), - _compact_task_lease_todos(projection), - None, - ) + return handoff_mode, _raw_registered_agent_candidates(goal) def _authority_source_identity( @@ -215,7 +154,6 @@ def _task_lease_authority_snapshot_attempt( registry_path: Path, goal_id: str, todo_id: str, - project_todos: bool = True, ) -> dict[str, Any] | None: registry_receipt_before = _authority_source_receipt("registry", registry_path) registry = load_registry(registry_path) @@ -230,15 +168,8 @@ def _task_lease_authority_snapshot_attempt( if receipts_before[0] != registry_receipt_before: return None - handoff_mode, registered_candidates, todos, projection_error = ( - _task_lease_authority_projection( - registry_path=registry_path, - goal_id=goal_id, - todo_id=todo_id, - goal=goal, - state_file=state_file, - project_todos=project_todos, - ) + handoff_mode, registered_candidates = _task_lease_authority_projection( + goal=goal, state_file=state_file, ) registry_after = load_registry(registry_path) @@ -257,8 +188,8 @@ def _task_lease_authority_snapshot_attempt( return { "handoff_mode": handoff_mode, "registered_agent_candidates": registered_candidates, - "todos": todos, - "todo_projection_error": projection_error, + "todos": [], + "todo_projection_error": None, "source_receipts": receipts_after, } @@ -268,16 +199,14 @@ def task_lease_acquire_authority_facts( registry_path: Path, goal_id: str, todo_id: str, - project_todos: bool = True, ) -> dict[str, Any]: - """Project source-stable facts; inspection can defer Todo parsing to TS demand.""" + """Read source-stable history metadata without reviving Todo admission.""" for _attempt in range(TASK_LEASE_AUTHORITY_SNAPSHOT_ATTEMPTS): facts = _task_lease_authority_snapshot_attempt( registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, - project_todos=project_todos, ) if facts is not None: return facts @@ -551,22 +480,7 @@ def execute_native_task_lease_lifecycle( code="claim_transfer_requires_canonical_authority", ) if registry_path is not None and needs_authority: - try: - authority = _canonical_lease_authority_facts(registry_path, goal_id) - except FileNotFoundError: - # Release is a cleanup operation and may run without its - # optional authority projection. Every operation that can - # authorize a lease mutation remains fail-closed when the - # canonical registry is unavailable. - if needs_authority: - raise - authority = None - except TaskLeaseError: - if needs_authority: - raise - # Release is intentionally usable for cleanup even when its - # optional source projection is unavailable. - authority = None + authority = _canonical_lease_authority_facts(registry_path, goal_id) request: dict[str, Any] = { "schema_version": ( TASK_LEASE_CANONICAL_LIFECYCLE_REQUEST_SCHEMA @@ -677,15 +591,14 @@ def inspect_native_task_lease( for attempt in range(TASK_LEASE_AUTHORITY_SNAPSHOT_ATTEMPTS): canonical = local_authority_is_promoted(runtime_root=runtime_root, goal_id=goal_id) - # TS alone decides whether the retained lease needs current Todo facts. - # Inactive legacy inspection must not parse the full work document. + # Source records are retained history, never a current execution policy. + # No retired Todo projection is needed to report them read-only. authority = _canonical_lease_authority_facts(registry_path, goal_id) if canonical else task_lease_acquire_authority_facts( - registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, project_todos=False, + registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, ) request = { "schema_version": "loopx_task_lease_inspect_request_v0", "source": "canonical" if canonical else "legacy", - "phase": "effective_lease" if canonical else "lease_record", "runtime_root": str(runtime_root.resolve()), "goal_id": goal_id, "todo_id": todo_id, "authority": authority, } @@ -694,19 +607,6 @@ def inspect_native_task_lease( timeout=(CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS if canonical else DEFAULT_REQUEST_TIMEOUT_SECONDS), ) - if isinstance(result, dict) and result.get("todo_projection_required") is True: - if canonical or result.get("schema_version") != TASK_LEASE_SCHEMA_VERSION or result.get("ok") is not True or result.get("action") != "inspect": - raise RuntimeError("native lease inspection requested an invalid source projection") - request["phase"] = "effective_lease" - request["authority"] = task_lease_acquire_authority_facts( - registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, - ) - # Re-read the lease and fence: neither the record nor its expiry is - # assumed unchanged while Python prepares the Todo projection. - result = effect_runtime_result( - "task_lease.inspect.native", request, - timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS, - ) if not isinstance(result, dict) or result.get("schema_version") != TASK_LEASE_SCHEMA_VERSION or result.get("action") != "inspect" or not isinstance(result.get("ok"), bool): raise RuntimeError("native lease inspection result shape mismatch") if result.get("error_code") == "authority_source_changed" and attempt + 1 < TASK_LEASE_AUTHORITY_SNAPSHOT_ATTEMPTS: diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index 478d57e9ef..70f3dc7ddd 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -1,8 +1,10 @@ +import {HANDOFF_MODES} from "../coordination/handoff_mode_vocabulary.ts"; import {leaseWorkspace, sameLeaseWorkspace, sameLeaseCheckout, type LeaseWorkspace} from "./task_lease_workspace.ts"; /** Shared acquire/reclaim admission. IO and durable receipts belong to callers. */ +import {retiredHandoffModeRejection} from "../coordination/handoff_mode_policy.ts"; import {leaseOwnerRejection as ownerRejection} from "./task_lease_eligibility.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; -import {requireJsonObject} from "../runtime_decode.ts"; +import {requireJsonObject, requireStringLiteral} from "../runtime_decode.ts"; import type {JsonObject} from "../effect_program.ts"; import type {TodoFact, LeaseRecord} from "./task_lease_acquire.ts"; import {normalizeTodoRepository} from "../todos/work_requirements.ts"; @@ -268,7 +270,7 @@ function decodeAcquireDecisionInput(value: unknown): AcquireDecisionInput { }; }); return { - handoff_mode: stringValue(input.handoff_mode, "handoff_mode"), + handoff_mode: requireStringLiteral(input.handoff_mode, HANDOFF_MODES, "handoff_mode"), registered_agents: decisionStringArray( input.registered_agents, "registered_agents", @@ -341,6 +343,8 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci if (rejection !== null) { return acquireDecisionResult("rejected", rejection); } + const retired = retiredHandoffModeRejection(input.handoff_mode); + if (retired !== null) return acquireDecisionResult("rejected", retired); const actualVersion = lease !== null && lease.present ? lease.version : 0; if ( command.expected_version !== null && command.expected_version !== actualVersion diff --git a/loopx/control_plane/work_items/task_lease_inspection.ts b/loopx/control_plane/work_items/task_lease_inspection.ts index 985d6a56c4..e5ed6dea57 100644 --- a/loopx/control_plane/work_items/task_lease_inspection.ts +++ b/loopx/control_plane/work_items/task_lease_inspection.ts @@ -11,6 +11,7 @@ import {openLocalAuthorityStoreHandle, localAuthorityOpenFailure, import {decodeTaskLeaseAuthority, leaseIsActive, normalizeGoalId, normalizeTodoId, normalizeHandoffMode, readLease, revalidateAuthoritySources, TaskLeaseAcquireError, TASK_LEASE_SCHEMA_VERSION, type LeaseRecord, type TodoFact} from "./task_lease_acquire.ts"; +import {retiredHandoffModeRejection, HANDOFF_MODE_RETIRED_REASON} from "../coordination/handoff_mode_policy.ts"; import {leaseOwnerConstraint} from "./task_lease_eligibility.ts"; import {leaseRepositoryRejection} from "./task_lease_repository.ts"; import {todoExecutionDependencyRejection} from "../coordination/todo_execution_dependency.ts"; @@ -31,9 +32,7 @@ export async function inspectTaskLease(value: unknown, const input = requireJsonObject(value, "task lease inspection"); if (input.schema_version !== TASK_LEASE_INSPECT_REQUEST || (input.source !== "canonical" && input.source !== "legacy") || - (input.phase !== undefined && input.phase !== "lease_record" && input.phase !== "effective_lease") || - (input.source === "canonical" && input.phase === "lease_record") || - Object.keys(input).some(key => !["schema_version", "source", "goal_id", "todo_id", "runtime_root", "authority", "phase"].includes(key))) { + Object.keys(input).some(key => !["schema_version", "source", "goal_id", "todo_id", "runtime_root", "authority"].includes(key))) { throw new TaskLeaseAcquireError("invalid task lease inspection request", "invalid_inspection_request"); } goalId = normalizeGoalId(input.goal_id); todoId = normalizeTodoId(input.todo_id); @@ -85,13 +84,15 @@ export async function inspectTaskLease(value: unknown, } } const timeActive = leaseIsActive(lease, at); - const needsProjection = !canonical && input.phase === "lease_record" && timeActive; - const ownerConstraint = !timeActive || lease === null || needsProjection ? null + const ownerConstraint = !canonical || !timeActive || lease === null ? null : authority.todo_projection_error !== null ? {effective: false, reason: authority.todo_projection_error.code} : leaseOwnerConstraint(todo, typeof lease.owner === "string" ? lease.owner : null, authority.registered_agents); const repositoryRejection = canonical && ownerConstraint?.effective === true ? leaseRepositoryRejection(todo, lease) : null; - const constraint = repositoryRejection !== null ? {effective: false, reason: repositoryRejection} + const retired = retiredHandoffModeRejection(mode); + const constraint = !canonical ? {effective: false, reason: "canonical_authority_required"} + : retired !== null ? {effective: false, reason: retired} + : repositoryRejection !== null ? {effective: false, reason: repositoryRejection} : dependency !== null && ownerConstraint?.effective === true ? {effective: false, reason: dependency.code} : ownerConstraint; // Both registration and route must still describe the source we inspected. @@ -101,11 +102,13 @@ export async function inspectTaskLease(value: unknown, if (canonicalAuthoritySha256(afterFence) !== canonicalAuthoritySha256(beforeFence)) { throw new TaskLeaseAcquireError("lease authority route changed; retry inspection", "authority_source_changed"); } - if (needsProjection) return {ok: true, schema_version: TASK_LEASE_SCHEMA_VERSION, - action: "inspect", todo_projection_required: true}; return {ok: true, schema_version: TASK_LEASE_SCHEMA_VERSION, action: "inspect", goal_id: goalId, todo_id: todoId, active: timeActive && constraint?.effective === true, lease, lease_path: leasePath, ...evidence, + execution_policy: {supported: canonical && retired === null, execution_authority_granted: false, + ...(!canonical ? {reason_code: "canonical_authority_required", + reason: "Source leases are historical only. Use reviewed canonical import before starting execution."} + : retired !== null ? {reason_code: retired, reason: HANDOFF_MODE_RETIRED_REASON} : {})}, ...(mode === null ? {} : {handoff_mode: mode}), ...(constraint?.effective === false ? {executor_constraint: constraint} : {}), ...(dependency === null ? {} : {resume_condition: dependency.condition})}; diff --git a/loopx/control_plane/work_items/task_lease_lifecycle.ts b/loopx/control_plane/work_items/task_lease_lifecycle.ts index 068c74b37b..26e69600cc 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle.ts @@ -43,22 +43,17 @@ import { normalizeOwner, normalizeTodoId, readLease, - revalidateAuthoritySources, TaskLeaseAcquireError, taskLeaseDirectory, taskLeaseLockPath, taskLeasePath, type AuthorityFacts, type LeaseRecord, - type TodoFact, - type TodoFactField, } from "./task_lease_acquire.ts"; import { decideTaskLeaseLifecycle, - materializeTaskLeaseLifecycle, releasedTaskLeaseRecord as releasedLease, type TaskLeaseLifecycleDecision, - type TaskLeaseLifecycleDecisionInput, } from "./task_lease_lifecycle_decision.ts"; import {decodeTaskLeaseLifecycleRequest, TaskLeaseLifecycleError, TASK_LEASE_LIFECYCLE_OPERATIONS, type LifecycleRequest, type CanonicalLifecycleRequest, type LifecycleErrorInfo, type LifecycleStage, @@ -838,79 +833,7 @@ function errorInfo(error: unknown, fallbackStage: LifecycleStage = "durable_writ }; } -function authorityTodo( - request: LifecycleRequest, -): TodoFact | null { - if (request.authority?.todo_projection_error) { - const error = request.authority.todo_projection_error; - throw new TaskLeaseLifecycleError( - error.message, - error.code, - error.payload, - "validation", - ); - } - const projected = request.authority?.todos.get(request.todo_id) ?? null; - if (request.todo && projected && !todoFactsMatch(request.todo, projected)) { - throw new TaskLeaseLifecycleError( - "caller todo snapshot disagrees with the canonical authority projection", - "authority_todo_mismatch", - { goal_id: request.goal_id, todo_id: request.todo_id }, - "validation", - ); - } - // The authority projection is the only authorization source. A caller - // supplied Todo can elaborate it, but cannot create an otherwise missing - // row or override its state. - return projected; -} -function todoFactsMatch(left: TodoFact, right: TodoFact): boolean { - const provided = left.provided_fields; - // Lifecycle authority depends on the core identity/state fields. They are - // required by the compact caller contract and remain strict even when an - // older producer omitted optional metadata. The latter can be derived by - // the canonical projection, so absence is treated as "unspecified". - const matchesOptional = (field: TodoFactField, equal: boolean): boolean => - provided === undefined || provided.includes(field) ? equal : true; - return left.todo_id === right.todo_id && - left.status === right.status && - left.claimed_by === right.claimed_by && - JSON.stringify(left.excluded_agents) === JSON.stringify(right.excluded_agents) && - matchesOptional("role", (left.role ?? null) === (right.role ?? null)) && - matchesOptional("task_class", (left.task_class ?? null) === (right.task_class ?? null)) && - matchesOptional("bound_agent", left.bound_agent === right.bound_agent) && - matchesOptional("blocks_agent", left.blocks_agent === right.blocks_agent); -} - -function ownerError( - request: LifecycleRequest, - code: string, - todo: TodoFact | null, - owner = request.owner, -): TaskLeaseLifecycleError { - let message: string; - const payload: JsonObject = { - goal_id: request.goal_id, - todo_id: request.todo_id, - owner, - reason: code, - }; - if (code === "todo_not_found") message = "todo is missing from the canonical projection"; - else if (code === "todo_not_open") { - payload.todo_status = todo?.status || "unknown"; - message = `task lease requires an open todo; '${request.todo_id}' is '${payload.todo_status}'`; - } else if (code === "owner_excluded_from_todo") { - payload.excluded_agents = [...(todo?.excluded_agents ?? [])]; - message = `task lease owner '${owner}' is excluded from todo '${request.todo_id}'`; - } else if (code === "owner_conflicts_with_claim") { - payload.claimed_by = todo?.claimed_by ?? null; - message = `task lease owner '${owner}' conflicts with todo claim '${payload.claimed_by}'`; - } else { - message = `task lease owner '${owner}' is not registered for goal '${request.goal_id}'`; - } - return new TaskLeaseLifecycleError(message, code, payload, "validation"); -} function transitionError( request: LifecycleRequest, @@ -1004,23 +927,17 @@ async function persistOperationReceipt( }); } -function ordinaryDecision( +function retainedReleaseDecision( request: LifecycleRequest, lease: LeaseRecord | null, - todo: TodoFact | null, at: Date, ): TaskLeaseLifecycleDecision { - const input: TaskLeaseLifecycleDecisionInput = { + return decideTaskLeaseLifecycle({ + // Release preserves historical cleanup in every decoded mode. It neither + // reads Todo admission nor authorizes a fresh execution lease. handoff_mode: request.authority?.handoff_mode ?? "legacy", - registered_agents: request.authority?.registered_agents ?? [], - todo: todo === null - ? null - : { - todo_id: todo.todo_id, - status: todo.status, - claimed_by: todo.claimed_by, - excluded_agents: todo.excluded_agents, - }, + registered_agents: [], + todo: null, lease: lease === null ? null : { @@ -1031,33 +948,37 @@ function ordinaryDecision( idempotency_key: persistedKey(lease), version: leaseVersion(lease), lease_epoch: leaseEpoch(lease), - write_scopes: Array.isArray(lease.write_scopes) - ? lease.write_scopes.filter((scope): scope is string => - typeof scope === "string" - ) - : [], - // Lifecycle decisions do not depend on the original acquire TTL. - // Legacy records may carry an unrelated malformed value here; keep - // that compatibility field out of the decision instead of turning - // renew/release into a new validation surface. - acquire_ttl_seconds: - typeof lease.acquire_ttl_seconds === "number" && - Number.isSafeInteger(lease.acquire_ttl_seconds) && - lease.acquire_ttl_seconds >= 0 - ? lease.acquire_ttl_seconds - : null, + write_scopes: [], + acquire_ttl_seconds: null, }, command: { - operation: request.operation as "renew" | "transfer" | "release", + operation: "release", owner: request.owner!, idempotency_key: request.idempotency_key!, expected_version: request.expected_version, - ttl_seconds: request.ttl_seconds, - new_owner: request.new_owner, - new_idempotency_key: request.new_idempotency_key, + ttl_seconds: null, + new_owner: null, + new_idempotency_key: null, }, - }; - return decideTaskLeaseLifecycle(input); + }); +} + +function rejectFreshSourceLeaseOperation(request: LifecycleRequest): never { + throw new TaskLeaseLifecycleError( + "Canonical authority is required; back up and migrate this Goal before mutating a task lease", + "canonical_authority_required", + { + goal_id: request.goal_id, + operation: `task_lease_${request.operation}`, + write_admitted: false, + decision_read_from_provider: false, + legacy_fallback_used: false, + recovery: { + action: "migrate_goal_authority", + backup_required: true, + }, + }, +); } async function ordinaryOperation( @@ -1125,80 +1046,42 @@ async function ordinaryOperation( return replay; } - let todo: TodoFact | null = null; + // Prepared-with-matching-bytes and committed receipts above are recovery. + // A missing/ambiguous effect cannot be renewed or transferred after cutoff. if (request.operation !== "release") { - const authority = request.authority; - if (!authority) throw new TaskLeaseLifecycleError("authority is required", "authority_required"); - await revalidateAuthoritySources(authority.source_receipts); - todo = authorityTodo(request); + rejectFreshSourceLeaseOperation(request); } - const decision = ordinaryDecision(request, existing, todo, at); + + await requireLegacyCoordinationPrimaryWriteAllowed(request.runtime_root, request.goal_id); + const decision = retainedReleaseDecision(request, existing, at); if (decision.outcome === "conflict" || decision.outcome === "rejected") { - if ( - decision.code === "todo_not_found" || - decision.code === "todo_not_open" || - decision.code === "owner_not_registered" || - decision.code === "owner_excluded_from_todo" || - decision.code === "owner_conflicts_with_claim" - ) { - const rejectedOwner = request.operation === "transfer" && - decision.code === "owner_not_registered" && - !request.authority?.registered_agents.includes(request.owner!) - ? request.owner - : request.operation === "transfer" ? request.new_owner : request.owner; - throw ownerError(request, decision.code, todo, rejectedOwner); - } throw transitionError(request, decision.code, existing, leasePath); } - if (request.operation === "release") { - if (decision.code === "lease_missing") { - return { - ok: true, - schema_version: "task_lease_v0", - action: "release", - released: false, - missing: true, - lease_path: leasePath, - ...(request.authority?.handoff_mode - ? { handoff_mode: request.authority.handoff_mode } - : {}), - settlement: lifecycleSettlement(request, "committed"), - }; - } - if (decision.code === "lease_release_replay" && existing !== null) { - return responseForOrdinary(request, leasePath, existing, true, { released: true }); - } - if (existing === null || decision.next_lease === null) { - throw transitionError(request, "invalid_lease_snapshot", existing, leasePath); - } - const next = releasedLease(existing, at); - const response = responseForOrdinary(request, leasePath, next, false, { released: true }); - await persistOperationReceipt(request, "prepared", next, response); - await dependencies.beforeWrite?.(next); - await atomicWriteJson(leasePath, next); - await persistOperationReceipt(request, "committed", next, response); - return response; + if (decision.code === "lease_missing") { + return { + ok: true, + schema_version: "task_lease_v0", + action: "release", + released: false, + missing: true, + lease_path: leasePath, + ...(request.authority?.handoff_mode + ? { handoff_mode: request.authority.handoff_mode } + : {}), + settlement: lifecycleSettlement(request, "committed"), + }; + } + if (decision.code === "lease_release_replay" && existing !== null) { + return responseForOrdinary(request, leasePath, existing, true, { released: true }); } if (existing === null || decision.next_lease === null) { throw transitionError(request, "invalid_lease_snapshot", existing, leasePath); } - const next = materializeTaskLeaseLifecycle(existing, { - operation: request.operation as "renew" | "transfer", owner: request.owner!, - idempotency_key: request.idempotency_key!, expected_version: request.expected_version, - ttl_seconds: request.ttl_seconds, new_owner: request.new_owner, - new_idempotency_key: request.new_idempotency_key, - }, decision, at); - const response = responseForOrdinary( - request, - leasePath, - next, - false, - request.operation === "renew" ? { renewed: true } : { transferred: true }, - ); + const next = releasedLease(existing, at); + const response = responseForOrdinary(request, leasePath, next, false, { released: true }); await persistOperationReceipt(request, "prepared", next, response); await dependencies.beforeWrite?.(next); - if (request.authority) await revalidateAuthoritySources(request.authority.source_receipts); await atomicWriteJson(leasePath, next); await persistOperationReceipt(request, "committed", next, response); return response; @@ -1608,27 +1491,14 @@ export async function executeTaskLeaseLifecycle( }, ); } - if (request.operation !== "release") { - throw new TaskLeaseLifecycleError( - "Canonical authority is required; back up and migrate this Goal before mutating a task lease", - "canonical_authority_required", - { - goal_id: request.goal_id, - operation: `task_lease_${request.operation}`, - write_admitted: false, - decision_read_from_provider: false, - legacy_fallback_used: false, - recovery: { - action: "migrate_goal_authority", - backup_required: true, - }, - }, - ); - } + // Presence only selects the recovery path. The existing owner re-reads the + // receipt and current lease under both locks; it never mints a fresh grant. + if (request.operation !== "release" && await readOperationReceipt( + operationReceiptPath(request), request, leasePathFor(request), + ) === null) rejectFreshSourceLeaseOperation(request); return await withFileMutationLock( legacyCoordinationLeaseLockPath(request.runtime_root, request.goal_id), () => withFileMutationLock(lockPathFor(request!), async () => { - await requireLegacyCoordinationPrimaryWriteAllowed(request!.runtime_root, request!.goal_id); return await ordinaryOperation(request!, dependencies); }), ); diff --git a/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts b/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts index bc9eb64ced..8b82b9b07e 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts @@ -1,7 +1,9 @@ +import {HANDOFF_MODES} from "../coordination/handoff_mode_vocabulary.ts"; +import {retiredHandoffModeRejection} from "../coordination/handoff_mode_policy.ts"; import {leaseOwnerRejection as ownerRejection} from "./task_lease_eligibility.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { type JsonObject } from "../effect_program.ts"; -import { requireJsonObject } from "../runtime_decode.ts"; +import { requireJsonObject, requireStringLiteral } from "../runtime_decode.ts"; import {leaseEpoch, utcIsoformat, type LeaseRecord} from "./task_lease_acquire.ts"; import {normalizeTodoRepository} from "../todos/work_requirements.ts"; import {leaseWriteRepository, leaseRepositoryRejection} from "./task_lease_repository.ts"; @@ -164,7 +166,7 @@ function decodeInput(value: unknown): TaskLeaseLifecycleDecisionInput { "task lease lifecycle decision command", ); return { - handoff_mode: stringValue(input.handoff_mode, "handoff_mode"), + handoff_mode: requireStringLiteral(input.handoff_mode, HANDOFF_MODES, "handoff_mode"), registered_agents: stringArray( input.registered_agents, "registered_agents", @@ -266,6 +268,8 @@ export function decideTaskLeaseLifecycle( nextLease: { ...lease, active: false, status: "released" }, }); } + const retired = retiredHandoffModeRejection(input.handoff_mode); + if (retired !== null) return result("rejected", retired); if (lease === null || !lease.present || !lease.active) { return result("rejected", "lease_not_active"); } diff --git a/loopx/visible_governance.py b/loopx/visible_governance.py deleted file mode 100644 index ca89e6c912..0000000000 --- a/loopx/visible_governance.py +++ /dev/null @@ -1,611 +0,0 @@ -"""Synthetic visible-governance slice for GH-C62. - -Projects per-goal/per-agent claims, optional task leases, quota, scheduler -hints, decision scopes, and the shared-goal RFC's proposed authority/state- -provider boundary into one read-only projection. Every section makes -proposal-versus-shipped-truth explicit; no write API, no inference from -prose, no presentation of provider observations or leases as authority. -""" - -from __future__ import annotations - -from typing import Any - -from .control_plane.quota.decision_summary import compact_quota_decision -from .control_plane.todos.decision_scope import ( - build_required_decision_scope_consistency, -) -from .control_plane.todos.contract import ( - normalize_todo_claimed_by, - normalize_todo_id, -) - - -# ── schema constants ──────────────────────────────────────────────────────── -GOVERNANCE_SLICE_SCHEMA = "visible_governance_slice_v0" -LEASE_NATURE = "optional runtime fence, not authority" - - -# ── authority-boundary static table ───────────────────────────────────────── -def _build_authority_boundary_table() -> list[dict[str, Any]]: - """Implementation availability, not a claim that this Goal was promoted. - - Keep the original RFC section identifiers for report consumers. The original - prototype commands/formats are distinct from their shipped native successors. - Per-Goal authority must still be read from its actual binding and writer fence. - """ - return [ - { - "rfc_section": "3 -- State Classification", - "proposal": "Shared canonical Todo lifecycle, claims, leases and operation receipts", - "shipped": True, - "shipped_equivalent": ( - "Promoted Goals use native TS commands and AuthorityStore transactions. " - "Unpromoted Goals retain the Markdown/lease adapters." - ), - "gap": "Implementation availability does not select the default provider or migrate this Goal.", - }, - { - "rfc_section": "4 -- Coordination Ledger Shape", - "proposal": "Original coordination head aggregate with an embedded receipt index", - "shipped": False, - "shipped_equivalent": ( - "The Python prototype is retired. Native File/SQLite journals retain " - "head, committed history and receipts through one AuthorityStore contract. " - "PostgreSQL and NoKV have separate candidate qualification." - ), - "gap": "The retired prototype head is not a supported Goal journal or migration source.", - }, - { - "rfc_section": "5.1 -- claim_work command", - "proposal": "Atomic claim, lease and receipt publication", - "shipped": False, - "shipped_equivalent": ( - "The experimental claim_work executor is retired. Native todo_claim.ts " - "owns atomic claim/lease acquisition on canonical providers." - ), - "gap": ( - "A historical receipt is not current execution authority. Lease/effect " - "checks and the actual Goal binding remain required; claim_work is not a public command." - ), - }, - { - "rfc_section": "7 -- Receipt Retention", - "proposal": "Retain original receipts and fail closed on unproved outcomes", - "shipped": True, - "shipped_equivalent": "Native AuthorityStore readReceipt and committed history preserve replay evidence.", - "gap": "Receipt correctness does not qualify unbounded capacity or the SQLite D2 profile.", - }, - { - "rfc_section": "8 -- Local vs Shared Mode", - "proposal": "Explicit provider binding, writer fencing and migration parity", - "shipped": True, - "shipped_equivalent": "Reviewed per-Goal promotion and fenced rollback are implemented.", - "gap": "Whole-Goal cohort qualification and default onboarding remain separate from per-Goal opt-in.", - }, - { - "rfc_section": "Appendix B -- handoff_mode", - "proposal": "Explicit legacy, soft_claim or hard_lease coordination mode", - "shipped": True, - "shipped_equivalent": "The native handoff-mode command owns transition admission and quiescence checks.", - "gap": "Provider selection does not silently replace the Goal's chosen handoff mode.", - }, - { - "rfc_section": "9 -- Offline/Local Boundaries", - "proposal": "Unavailable shared authority must not silently fall back to local writers", - "shipped": False, - "shipped_equivalent": "Promoted local providers fail closed when canonical authority cannot be read.", - "gap": "Deployed cross-host service availability and execution-interval fencing need separate qualification.", - }, - ] - - -# ── claims projection ─────────────────────────────────────────────────────── -def _build_claims_projection( - status_payload: dict[str, Any], - *, - goal_id: str, - agent_id: str | None, -) -> dict[str, Any]: - """Extract per-todo soft claims from the status attention queue. - - Looks inside ``agent_todos`` summary lists (``first_open_items``, - ``first_executable_items``) within each goal-level attention queue - item to find todos with a ``claimed_by`` field. - """ - attention = ( - status_payload.get("attention_queue") - if isinstance(status_payload.get("attention_queue"), dict) - else {} - ) - items = attention.get("items") if isinstance(attention.get("items"), list) else [] - - claims: list[dict[str, Any]] = [] - by_agent: dict[str, list[dict[str, Any]]] = {} - seen: set[str] = set() - - _SUMMARY_LIST_KEYS = ( - "first_open_items", - "first_executable_items", - "executable_backlog_items", - "backlog_items", - ) - - for item in items: - if not isinstance(item, dict): - continue - item_goal = str(item.get("goal_id") or "") - if item_goal and item_goal != goal_id: - continue - - agent_todos = ( - item.get("agent_todos") - if isinstance(item.get("agent_todos"), dict) - else {} - ) - for list_key in _SUMMARY_LIST_KEYS: - todo_list = agent_todos.get(list_key) - if not isinstance(todo_list, list): - continue - for td in todo_list: - if not isinstance(td, dict): - continue - todo_id = normalize_todo_id(td.get("todo_id")) - claimed_by = normalize_todo_claimed_by(td.get("claimed_by")) - if not claimed_by: - continue - if todo_id in seen: - continue - seen.add(todo_id) - claim_entry: dict[str, Any] = { - "todo_id": todo_id, - "claimed_by": claimed_by, - "task_class": td.get("task_class"), - "status": td.get("status") or ( - "done" if td.get("done") else "open" - ), - } - for key in ("text", "target_key", "action_kind"): - value = td.get(key) - if value: - claim_entry[key] = str(value)[:200] - claims.append(claim_entry) - by_agent.setdefault(claimed_by, []).append(claim_entry) - - normalized_agent = normalize_todo_claimed_by(agent_id) - return { - "total_claims": len(claims), - "claiming_agent_count": len(by_agent), - "current_agent_claims": len(by_agent.get(normalized_agent, [])) - if normalized_agent - else 0, - "by_agent": { - agent: len(agent_claims) - for agent, agent_claims in sorted(by_agent.items()) - }, - "claims": claims, - } - - -# ── leases projection ─────────────────────────────────────────────────────── -def _build_leases_projection( - status_payload: dict[str, Any], - *, - goal_id: str, - agent_id: str | None, - include_task_leases: bool, -) -> dict[str, Any] | None: - """Read active task leases for the goal (read-only, never writes).""" - if not include_task_leases: - return None - - from .control_plane.work_items.task_lease import ( - lease_epoch, - lease_is_active, - read_lease, - task_lease_dir, - ) - - runtime_root_raw = status_payload.get("runtime_root") - if not runtime_root_raw: - return {"active_leases": 0, "leases": [], "nature": LEASE_NATURE} - - from pathlib import Path - - lease_dir = task_lease_dir( - runtime_root=Path(str(runtime_root_raw)), - goal_id=goal_id, - ) - if not lease_dir.exists(): - return {"active_leases": 0, "leases": [], "nature": LEASE_NATURE} - - active_leases: list[dict[str, Any]] = [] - for path in sorted(lease_dir.glob("todo_*.json")): - lease = read_lease(path) - if not lease_is_active(lease): - continue - active_leases.append( - { - "todo_id": lease.get("todo_id"), - "owner": lease.get("owner"), - "expires_at": lease.get("expires_at"), - "version": lease.get("version"), - "lease_epoch": lease_epoch(lease), - "write_scopes": lease.get("write_scopes") or [], - "acquire_ttl_seconds": lease.get("acquire_ttl_seconds"), - } - ) - - normalized_agent = normalize_todo_claimed_by(agent_id) - return { - "active_leases": len(active_leases), - "current_agent_active_leases": sum( - 1 - for lease in active_leases - if lease.get("owner") == normalized_agent - ), - "leases": active_leases, - "nature": LEASE_NATURE, - } - - -# ── quota projection ──────────────────────────────────────────────────────── -def _build_quota_projection( - status_payload: dict[str, Any], - *, - goal_id: str, - agent_id: str | None, -) -> dict[str, Any] | None: - """Compact quota decision via ``build_quota_should_run``.""" - from .quota import build_quota_should_run - - try: - decision = build_quota_should_run( - status_payload, - goal_id=goal_id, - agent_id=agent_id, - ) - except (ValueError, KeyError, OSError): - return None - return compact_quota_decision(decision) - - -# ── scheduler hints projection ────────────────────────────────────────────── -def _build_scheduler_hints_projection( - status_payload: dict[str, Any], - *, - goal_id: str, - agent_id: str | None, -) -> dict[str, Any] | None: - """Extract scheduler hints from the quota should-run packet.""" - from .quota import build_quota_should_run - - try: - decision = build_quota_should_run( - status_payload, - goal_id=goal_id, - agent_id=agent_id, - include_scheduler_detail=True, - ) - except (ValueError, KeyError, OSError): - return None - - scheduler = decision.get("scheduler_hint") - if not isinstance(scheduler, dict): - return None - - return { - "action": scheduler.get("action"), - "cadence_class": scheduler.get("cadence_class"), - "recommended_interval_minutes": scheduler.get( - "recommended_interval_minutes" - ), - "max_interval_minutes": scheduler.get("max_interval_minutes"), - "reset_policy": scheduler.get("reset_policy"), - "unchanged_poll": { - k: scheduler.get("unchanged_poll", {}).get(k) - for k in ("backoff_multiplier", "max_interval_minutes") - if k in scheduler.get("unchanged_poll", {}) - } - if isinstance(scheduler.get("unchanged_poll"), dict) - else None, - "arbitration": scheduler.get("arbitration_disposition"), - } - - -# ── decision scopes projection ────────────────────────────────────────────── -def _build_decision_scopes_projection( - status_payload: dict[str, Any], - *, - goal_id: str, - agent_id: str | None, -) -> dict[str, Any]: - """Project decision scopes from quota should-run summaries. - - Uses ``build_quota_should_run`` to obtain agent/user todo summaries - and standing decision authority, then runs the consistency check. - """ - from .quota import build_quota_should_run - - try: - decision = build_quota_should_run( - status_payload, - goal_id=goal_id, - agent_id=agent_id, - ) - except (ValueError, KeyError, OSError): - return { - "standing_decision_authority": None, - "required_scope_consistency": { - "ok": None, - "status": "unavailable", - }, - } - - # standing authority already computed by the quota guard - standing = decision.get("standing_decision_authority") - - # Build consistency from the summaries in the should_run payload - agent_summary = decision.get("agent_todo_summary") - user_summary = decision.get("user_todo_summary") - consistency = build_required_decision_scope_consistency( - agent_todo_summary=agent_summary if isinstance(agent_summary, dict) else None, - user_todo_summary=user_summary if isinstance(user_summary, dict) else None, - agent_id=agent_id, - registered_agent_ids=None, - standing_decision_authority=standing, - ) - - return { - "standing_decision_authority": standing, - "required_scope_consistency": { - "ok": consistency.get("ok"), - "status": consistency.get("status"), - "checked_agent_todo_count": consistency.get( - "checked_agent_todo_count" - ), - "checked_required_scope_count": consistency.get( - "checked_required_scope_count" - ), - "standing_authority_match_count": consistency.get( - "standing_authority_match_count" - ), - "terminal_outcome_count": consistency.get("terminal_outcome_count"), - }, - } - - -# ── main builder ──────────────────────────────────────────────────────────── -def build_visible_governance_slice( - status_payload: dict[str, Any], - *, - goal_id: str, - agent_id: str | None = None, - include_task_leases: bool = True, -) -> dict[str, Any]: - """Synthesize a read-only visible-governance slice for one goal. - - Args: - status_payload: Full ``collect_status(...)`` result. - goal_id: Goal to project governance for. - agent_id: Optional agent lane to scope per-agent projections. - include_task_leases: When True, read active lease files (read-only). - - Returns: - ``{"schema_version": "visible_governance_slice_v0", "ok": True, ...}`` - with sections: claims, leases, quota, scheduler_hints, - decision_scopes, authority_boundary. - """ - safe_goal_id = str(goal_id or "").strip() - if not safe_goal_id: - return { - "schema_version": GOVERNANCE_SLICE_SCHEMA, - "ok": False, - "error": "goal_id is required", - } - - claims = _build_claims_projection( - status_payload, goal_id=safe_goal_id, agent_id=agent_id - ) - leases = _build_leases_projection( - status_payload, - goal_id=safe_goal_id, - agent_id=agent_id, - include_task_leases=include_task_leases, - ) - quota = _build_quota_projection( - status_payload, goal_id=safe_goal_id, agent_id=agent_id - ) - scheduler_hints = _build_scheduler_hints_projection( - status_payload, goal_id=safe_goal_id, agent_id=agent_id - ) - decision_scopes = _build_decision_scopes_projection( - status_payload, goal_id=safe_goal_id, agent_id=agent_id - ) - authority_boundary = _build_authority_boundary_table() - - return { - "schema_version": GOVERNANCE_SLICE_SCHEMA, - "ok": True, - "goal_id": safe_goal_id, - "agent_id": normalize_todo_claimed_by(agent_id), - "claims": claims, - "leases": leases, - "quota": quota, - "scheduler_hints": scheduler_hints, - "decision_scopes": decision_scopes, - "authority_boundary": authority_boundary, - } - - -# ── markdown renderer ─────────────────────────────────────────────────────── -def render_visible_governance_markdown(slice_payload: dict[str, Any]) -> str: - """Render a visible-governance slice as public-safe markdown.""" - - goal_id = str(slice_payload.get("goal_id") or "unknown") - agent_id = str(slice_payload.get("agent_id") or "") - - lines: list[str] = [] - lines.append("# Visible Governance Slice") - lines.append("") - lines.append(f"- **Goal**: `{goal_id}`") - if agent_id: - lines.append(f"- **Agent**: `{agent_id}`") - lines.append(f"- **Schema**: `{slice_payload.get('schema_version')}`") - lines.append("") - - # ── Claims ── - claims = slice_payload.get("claims") - if isinstance(claims, dict): - lines.append("## Claims (Soft Ownership)") - lines.append("") - lines.append( - f"- Total claims: **{claims.get('total_claims', 0)}** " - f"across {claims.get('claiming_agent_count', 0)} agent(s)" - ) - current = claims.get("current_agent_claims", 0) - if current: - lines.append(f"- Current agent claims: **{current}**") - by_agent = claims.get("by_agent") - if isinstance(by_agent, dict) and by_agent: - lines.append("") - lines.append("| Agent | Claim Count |") - lines.append("|-------|-------------|") - for agent, count in sorted(by_agent.items()): - lines.append(f"| `{agent}` | {count} |") - lines.append("") - - # ── Leases ── - leases = slice_payload.get("leases") - if isinstance(leases, dict): - lines.append("## Task Leases (Optional Runtime Fence)") - lines.append("") - lines.append(f"- Nature: *{leases.get('nature', LEASE_NATURE)}*") - lines.append( - f"- Active leases: **{leases.get('active_leases', 0)}**" - ) - lease_list = leases.get("leases") - if isinstance(lease_list, list) and lease_list: - lines.append("") - lines.append( - "| Todo ID | Owner | Expires | Version | TTL (s) |" - ) - lines.append( - "|---------|-------|---------|---------|---------|" - ) - for lease in lease_list: - lines.append( - f"| `{lease.get('todo_id', '')}` " - f"| `{lease.get('owner', '')}` " - f"| {lease.get('expires_at', '')} " - f"| {lease.get('version', '')} " - f"| {lease.get('acquire_ttl_seconds', '')} |" - ) - lines.append("") - - # ── Quota ── - quota = slice_payload.get("quota") - if isinstance(quota, dict): - lines.append("## Quota Decision") - lines.append("") - lines.append(f"- Should run: `{quota.get('should_run')}`") - lines.append( - f"- Effective action: `{quota.get('effective_action', '')}`" - ) - lines.append(f"- State: `{quota.get('state', '')}`") - compute = quota.get("compute") - if compute is not None: - lines.append(f"- Compute: {compute}") - slots_info = [] - for key in ("spent_slots", "allowed_slots", "slot_minutes"): - value = quota.get(key) - if value is not None: - slots_info.append(f"{key}: {value}") - if slots_info: - lines.append(f"- Slots: {', '.join(slots_info)}") - lines.append("") - - # ── Scheduler Hints ── - sched = slice_payload.get("scheduler_hints") - if isinstance(sched, dict): - lines.append("## Scheduler Hints") - lines.append("") - lines.append(f"- Action: `{sched.get('action', '')}`") - lines.append( - f"- Cadence class: `{sched.get('cadence_class', '')}`" - ) - interval = sched.get("recommended_interval_minutes") - if interval is not None: - lines.append(f"- Recommended interval: {interval} min") - lines.append( - f"- Arbitration: `{sched.get('arbitration', '')}`" - ) - lines.append("") - - # ── Decision Scopes ── - scopes = slice_payload.get("decision_scopes") - if isinstance(scopes, dict): - lines.append("## Decision Scopes") - lines.append("") - standing = scopes.get("standing_decision_authority") - if isinstance(standing, dict): - lines.append( - f"- Standing authority receipts: " - f"**{standing.get('active_count', 0)}** active, " - f"{standing.get('inactive_count', 0)} inactive" - ) - consistency = scopes.get("required_scope_consistency") - if isinstance(consistency, dict): - lines.append( - f"- Scope consistency: " - f"`{consistency.get('status', 'unknown')}` " - f"(checked {consistency.get('checked_agent_todo_count', 0)} " - f"todos, {consistency.get('checked_required_scope_count', 0)} " - f"scopes)" - ) - matches = consistency.get("standing_authority_match_count", 0) - if matches: - lines.append( - f"- Standing authority matches: **{matches}**" - ) - lines.append("") - - # ── Authority Boundary ── - boundary = slice_payload.get("authority_boundary") - if isinstance(boundary, list): - lines.append("## Authority Boundary: Proposal vs Shipped") - lines.append("") - lines.append( - "| RFC Section | Shipped | Gap |" - ) - lines.append( - "|-------------|---------|-----|" - ) - for entry in boundary: - shipped = "Yes" if entry.get("shipped") else "**No**" - gap = str(entry.get("gap") or "")[:120] - lines.append( - f"| {entry.get('rfc_section', '')} " - f"| {shipped} " - f"| {gap} |" - ) - lines.append("") - - # ── Summary ── - lines.append("## Summary") - lines.append("") - lines.append( - f"- **Proposals shipped**: " - f"{sum(1 for e in (boundary or []) if e.get('shipped'))} / " - f"{len(boundary) if boundary else 0}" - ) - lines.append( - "- **Key takeaway**: Stage-2 ships an additive coordination head, " - "file provider, and claim_work executor plus handoff_mode; default " - "runtime still uses Markdown soft claims, file-backed task leases " - "(optional fence, not write authority), and turn-scoped settlement. " - "Shared-mode promotion remains Stage 3." - ) - lines.append("") - - return "\n".join(lines) diff --git a/man/loopx.1 b/man/loopx.1 index 148a65183d..36ca34ba52 100644 --- a/man/loopx.1 +++ b/man/loopx.1 @@ -1,4 +1,4 @@ -.TH LOOPX 1 "" "LoopX 1.4.1" "User Commands" +.TH LOOPX 1 "" "LoopX 2.0.0a1" "User Commands" .SH NAME loopx \- control-plane helper for long-running agent work .SH SYNOPSIS diff --git a/tests/control_plane/fixtures/retained_lease_receipts_1x.json b/tests/control_plane/fixtures/retained_lease_receipts_1x.json new file mode 100644 index 0000000000..716d23d22c --- /dev/null +++ b/tests/control_plane/fixtures/retained_lease_receipts_1x.json @@ -0,0 +1,431 @@ +{ + "source_commit": "60d833b13c85135587b725013b247e827663ffb9", + "scenarios": { + "original": { + "request": { + "schema_version": "loopx_task_lease_acquire_native_v0", + "runtime_root": "$ROOT/runtime", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "ttl_seconds": 600, + "write_scopes": [], + "expected_version": null, + "authority": { + "handoff_mode": "hard_lease", + "registered_agent_candidates": [ + [ + "agent-a", + "agent-b" + ] + ], + "todos": [ + { + "todo_id": "todo_target", + "status": "open", + "claimed_by": null, + "excluded_agents": [] + } + ], + "todo_projection_error": null, + "source_receipts": [ + { + "source_id": "authority", + "path": "$ROOT/authority.json", + "state": "file", + "sha256": "408c3ee0aef794f423d49ba8141f3492961aa68b354d03c37c455d072d7e32c3" + } + ] + } + }, + "files": { + "goals/goal-a/task-leases/todo_target.json": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 1, + "lease_epoch": 1, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:00:00Z", + "expires_at": "2026-09-01T03:10:00Z", + "status": "active" + } + } + }, + "renew": { + "request": { + "schema_version": "loopx_task_lease_lifecycle_native_v0", + "runtime_root": "$ROOT/runtime", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "ttl_seconds": 600, + "write_scopes": [], + "expected_version": 1, + "authority": { + "handoff_mode": "hard_lease", + "registered_agent_candidates": [ + [ + "agent-a", + "agent-b" + ] + ], + "todos": [ + { + "todo_id": "todo_target", + "status": "open", + "claimed_by": null, + "excluded_agents": [] + } + ], + "todo_projection_error": null, + "source_receipts": [ + { + "source_id": "authority", + "path": "$ROOT/authority.json", + "state": "file", + "sha256": "408c3ee0aef794f423d49ba8141f3492961aa68b354d03c37c455d072d7e32c3" + } + ] + }, + "operation": "renew", + "new_owner": null, + "new_idempotency_key": null, + "lock_token": null, + "committed": false, + "release_lease": false, + "fence_owner": null, + "fence_idempotency_key": null, + "fence_expected_version": null, + "fence_expected_lease_epoch": 1 + }, + "files": { + "goals/goal-a/task-leases/.lifecycle-operations/83642ecca0b109ad9c67ef641c43a3cf84dfcb1a156bfc012e7b975475926323.json": { + "schema_version": "task_lease_lifecycle_receipt_v0", + "operation_id": "83642ecca0b109ad9c67ef641c43a3cf84dfcb1a156bfc012e7b975475926323", + "request_digest": "3c064882fc370f6dbc79e8526f25162e4c893830cc9be7ae3e455067f5d1a2dc", + "state": "committed", + "planned_lease": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 2, + "lease_epoch": 1, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:01:00Z", + "expires_at": "2026-09-01T03:11:00Z", + "status": "active" + }, + "response": { + "ok": true, + "schema_version": "task_lease_v0", + "action": "renew", + "renewed": true, + "lease": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 2, + "lease_epoch": 1, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:01:00Z", + "expires_at": "2026-09-01T03:11:00Z", + "status": "active" + }, + "lease_path": "$ROOT/runtime/goals/goal-a/task-leases/todo_target.json", + "handoff_mode": "hard_lease", + "idempotent": false, + "settlement": { + "effect_id": "goal-a:agent-a:todo_target:lease-a", + "receipts": [ + { + "step": "validation", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_target:lease-a" + }, + { + "step": "durable_writeback", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_target:lease-a" + } + ] + } + } + }, + "goals/goal-a/task-leases/todo_target.json": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 2, + "lease_epoch": 1, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:01:00Z", + "expires_at": "2026-09-01T03:11:00Z", + "status": "active" + } + } + }, + "transfer": { + "request": { + "schema_version": "loopx_task_lease_lifecycle_native_v0", + "runtime_root": "$ROOT/runtime", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "ttl_seconds": 600, + "write_scopes": [], + "expected_version": 1, + "authority": { + "handoff_mode": "hard_lease", + "registered_agent_candidates": [ + [ + "agent-a", + "agent-b" + ] + ], + "todos": [ + { + "todo_id": "todo_target", + "status": "open", + "claimed_by": null, + "excluded_agents": [] + } + ], + "todo_projection_error": null, + "source_receipts": [ + { + "source_id": "authority", + "path": "$ROOT/authority.json", + "state": "file", + "sha256": "408c3ee0aef794f423d49ba8141f3492961aa68b354d03c37c455d072d7e32c3" + } + ] + }, + "operation": "transfer", + "new_owner": "agent-b", + "new_idempotency_key": "lease-b", + "lock_token": null, + "committed": false, + "release_lease": false, + "fence_owner": null, + "fence_idempotency_key": null, + "fence_expected_version": null, + "fence_expected_lease_epoch": 1 + }, + "files": { + "goals/goal-a/task-leases/.lifecycle-operations/993e3b79909ee83f0dfcb96ee676b54b7435f12f5dfb14b4a0c7151216e3a5c5.json": { + "schema_version": "task_lease_lifecycle_receipt_v0", + "operation_id": "993e3b79909ee83f0dfcb96ee676b54b7435f12f5dfb14b4a0c7151216e3a5c5", + "request_digest": "3e7bf5cde7d9499d77ecb7b29d003f935ea2a868c99e5703ede7e54513864219", + "state": "committed", + "planned_lease": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-b", + "idempotency_key": "lease-b", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 2, + "lease_epoch": 2, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:01:00Z", + "expires_at": "2026-09-01T03:11:00Z", + "status": "active" + }, + "response": { + "ok": true, + "schema_version": "task_lease_v0", + "action": "transfer", + "transferred": true, + "lease": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-b", + "idempotency_key": "lease-b", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 2, + "lease_epoch": 2, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:01:00Z", + "expires_at": "2026-09-01T03:11:00Z", + "status": "active" + }, + "lease_path": "$ROOT/runtime/goals/goal-a/task-leases/todo_target.json", + "handoff_mode": "hard_lease", + "idempotent": false, + "settlement": { + "effect_id": "goal-a:agent-a:todo_target:lease-a", + "receipts": [ + { + "step": "validation", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_target:lease-a" + }, + { + "step": "durable_writeback", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_target:lease-a" + } + ] + } + } + }, + "goals/goal-a/task-leases/todo_target.json": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-b", + "idempotency_key": "lease-b", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 2, + "lease_epoch": 2, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:01:00Z", + "expires_at": "2026-09-01T03:11:00Z", + "status": "active" + } + } + }, + "terminal-held": { + "request": { + "schema_version": "loopx_task_lease_lifecycle_native_v0", + "runtime_root": "$ROOT/runtime", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "ttl_seconds": 600, + "write_scopes": [], + "expected_version": 1, + "authority": { + "handoff_mode": "hard_lease", + "registered_agent_candidates": [ + [ + "agent-a", + "agent-b" + ] + ], + "todos": [ + { + "todo_id": "todo_target", + "status": "open", + "claimed_by": null, + "excluded_agents": [] + } + ], + "todo_projection_error": null, + "source_receipts": [ + { + "source_id": "authority", + "path": "$ROOT/authority.json", + "state": "file", + "sha256": "408c3ee0aef794f423d49ba8141f3492961aa68b354d03c37c455d072d7e32c3" + } + ] + }, + "operation": "terminal_verify", + "new_owner": null, + "new_idempotency_key": null, + "lock_token": null, + "committed": false, + "release_lease": false, + "fence_owner": null, + "fence_idempotency_key": null, + "fence_expected_version": null, + "fence_expected_lease_epoch": 1 + }, + "files": { + "goals/goal-a/task-leases/.lifecycle-fences/ee58a5ed5a4df4e8c8657e727f47f305250bacddc13bf50d27819573e9ed080a.json": { + "schema_version": "task_lease_fence_receipt_v0", + "operation_id": "ee58a5ed5a4df4e8c8657e727f47f305250bacddc13bf50d27819573e9ed080a", + "request_digest": "0e4b9170ebc4555e5c8aeb5d7195db3e5ad2c61750f1a29597c5ea61a2433f5d", + "close_request_digest": null, + "state": "held", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner_pid": 54181, + "lock_token": "8ff0637e-8406-4215-b76c-0041b84d235e", + "fence_owner": "agent-a", + "fence_idempotency_key": "lease-a", + "fence_expected_version": 1, + "fence_expected_lease_epoch": 1, + "lease": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 1, + "lease_epoch": 1, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:00:00Z", + "expires_at": "2026-09-01T03:10:00Z", + "status": "active" + }, + "response": { + "schema_version": "task_lease_v0", + "required": true, + "active": true, + "owner": "agent-a", + "version": 1, + "lease_epoch": 1, + "execution_instance_verified": true, + "lock_token": "8ff0637e-8406-4215-b76c-0041b84d235e", + "fence_operation_id": "ee58a5ed5a4df4e8c8657e727f47f305250bacddc13bf50d27819573e9ed080a" + }, + "verify_response": { + "schema_version": "task_lease_v0", + "required": true, + "active": true, + "owner": "agent-a", + "version": 1, + "lease_epoch": 1, + "execution_instance_verified": true, + "lock_token": "8ff0637e-8406-4215-b76c-0041b84d235e", + "fence_operation_id": "ee58a5ed5a4df4e8c8657e727f47f305250bacddc13bf50d27819573e9ed080a" + } + }, + "goals/goal-a/task-leases/todo_target.json": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_target", + "owner": "agent-a", + "idempotency_key": "lease-a", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 1, + "lease_epoch": 1, + "acquired_at": "2026-09-01T03:00:00Z", + "updated_at": "2026-09-01T03:00:00Z", + "expires_at": "2026-09-01T03:10:00Z", + "status": "active" + } + } + } + } +} diff --git a/tests/control_plane/fixtures/team_plan_1x.json b/tests/control_plane/fixtures/team_plan_1x.json new file mode 100644 index 0000000000..2679da2cce --- /dev/null +++ b/tests/control_plane/fixtures/team_plan_1x.json @@ -0,0 +1,80 @@ +{ + "source_revision": "60d833b13c85135587b725013b247e827663ffb9", + "plan": { + "schema_version": "steward_team_plan_preview_v0", + "kind": "steward_team_plan_preview", + "goal_id": "goal-a", + "proposal_id": "historical-plan", + "objective": "Independent lane outcomes", + "quota_envelope": { + "slots": 2 + }, + "stop_condition": "Owner ends request", + "lanes": [ + { + "lane_id": "lane-alpha", + "agent_id": "alpha", + "acceptance": "Return independent evidence", + "first_todo": { + "text": "Own work", + "priority": "P1", + "task_class": "advancement_task", + "action_kind": "implement" + } + } + ] + }, + "basis": "e56dfe4695a07f36678032267de2d3aca064a7ce1cdf69db4cec2367a11da105", + "source": "\n\n# Goal\n\n## Agent Todo\n\n- [ ] [P1] Own work\n \n\n", + "result": { + "action": "created", + "todo_id": "todo_0257c02b0bf76ba6c7c3ed4e", + "target_key": null, + "created_todo_ids": [ + "todo_0257c02b0bf76ba6c7c3ed4e" + ], + "lane_todo_ids": [ + "todo_0257c02b0bf76ba6c7c3ed4e" + ], + "lane_settlements": [ + { + "lane_id": "lane-alpha", + "agent_id": "alpha", + "priority": "P1", + "acceptance": "Return independent evidence", + "disposition": "created", + "todo_id": "todo_0257c02b0bf76ba6c7c3ed4e" + } + ], + "gap_count": 0, + "gap_lanes": [], + "reused_lane_count": 0, + "intent_basis": "sha256:0960f2bef565df76a1c073b25ca20c31db825c5b1b30ac66e03dc721b33b4c8e", + "lane_failure": null, + "coordination_runtime_shadow": { + "schema_version": "loopx_local_authority_shadow_evidence_v1", + "outcome": "no_transaction", + "reason_code": "shadow_disabled", + "goal_id": "goal-a", + "entry": { + "entry_id": null, + "partition": null, + "seq": null, + "partition_digest": null, + "source_bytes_digest": null + }, + "drain": null, + "capture_kind": "source_transaction_outbox", + "source_transaction_correlated": false, + "durable_source_outbox": false, + "source_candidate_compared": false, + "parity_verdict": "not_evaluated", + "primary_authority": "legacy_local", + "candidate_provider": "file", + "candidate_read_for_decision": false, + "provider_to_local_writes": false, + "primary_writeback_preserved": true, + "store_identity": null + } + } +} diff --git a/tests/control_plane/test_canonical_goal_channel_coordination.py b/tests/control_plane/test_canonical_goal_channel_coordination.py index 6de461b1d7..7359432f40 100644 --- a/tests/control_plane/test_canonical_goal_channel_coordination.py +++ b/tests/control_plane/test_canonical_goal_channel_coordination.py @@ -100,3 +100,73 @@ def channels(value): assert all(item['active_leases'] == [] for item in found) assert all(item['coordination_observation']['source_authority'] == provider + '_v0' for item in found) assert state.read_bytes() == before + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_real_cli_reports_native_lease_and_recovers_provider_without_legacy_fallback(tmp_path, provider): + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + from loopx.control_plane.testing.canary_harness import import_fixture_authority, run_json_cli, run_json_cli_result + from test_goal_channel_hard_lease_visibility import _write_status_workspace + + registry, repo, runtime = _write_status_workspace(tmp_path) + state = repo / 'ACTIVE_GOAL_STATE.md' + import_fixture_authority(registry, GOAL_ID, provider=provider) + + def cli(*args): + return run_json_cli(*args, registry_path=registry, runtime_root=runtime) + + cli('handoff-mode', 'set', '--goal-id', GOAL_ID, '--mode', 'hard_lease', + '--operation-id', 'channel-lease-fixture-policy') + task = cli('todo', 'add', '--goal-id', GOAL_ID, '--role', 'agent', + '--text', 'Observe the current canonical lease.', '--task-class', 'advancement_task', + '--claimed-by', 'agent-a') + lease = cli('task-lease', 'acquire', '--goal-id', GOAL_ID, '--todo-id', task['todo_id'], + '--owner', 'agent-a', '--idempotency-key', 'channel-lease-fixture', '--ttl-seconds', '600') + assert lease['acquired'] is True + original = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID, include_leases=True) + source, registry_bytes = state.read_bytes(), registry.read_bytes() + _write_active_lease(runtime, owner='stale-agent', todo_id=task['todo_id']) + + def channel(): + status = cli('status', '--goal-id', GOAL_ID) + items = status['attention_queue']['items'] + item = next(row for row in items if row['goal_id'] == GOAL_ID) + return item['goal_channel_projection'] + + def assert_current(view): + assert view['coordination_observation']['source_authority'] == provider + '_v0' + assert view['coordination_authority'] == {'state': 'promoted'} + hard = [row for row in view['active_leases'] if row['status'] == 'hard_lease'] + assert len(hard) == 1, view + assert hard[0]['todo_id'] == task['todo_id'] and hard[0]['owner_agent'] == 'agent-a', hard + assert hard[0]['lease_version'] == lease['lease']['version'] + assert all(row.get('owner_agent') != 'stale-agent' for row in view['active_leases']) + assert view['truth_contract']['write_authority'] == 'none' + + assert_current(channel()) + state.unlink() + assert_current(channel()) + assert not state.exists(), 'status must not reconstruct the display' + state.write_bytes(source) + + authority, quarantine = runtime / 'authority', runtime / 'provider-quarantine' + authority.rename(quarantine) + try: + unavailable = build_goal_channel_projection( + goal_id=GOAL_ID, status_item=_status_item(claimed_by='stale-agent'), runtime_root=runtime, + ) + assert unavailable['coordination_observation']['status'] == 'unavailable' + assert unavailable['coordination_authority'] == {'state': 'unavailable'} + assert unavailable['active_leases'] == [] + assert unavailable['truth_contract']['write_authority'] == 'none' + code, rejected = run_json_cli_result('status', '--goal-id', GOAL_ID, + registry_path=registry, runtime_root=runtime) + assert code == 1 and rejected['ok'] is False, rejected + assert rejected['attention_queue']['items'][0]['status'] == 'status_collection_failed' + assert 'canonical Todo authority is unavailable' in rejected['error'] + assert 'stale-agent' not in json.dumps(rejected) + finally: + quarantine.rename(authority) + assert_current(channel()) + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID, include_leases=True) == original + assert state.read_bytes() == source and registry.read_bytes() == registry_bytes diff --git a/tests/control_plane/test_canonical_handoff_invariants.py b/tests/control_plane/test_canonical_handoff_invariants.py new file mode 100644 index 0000000000..0f1d9602f1 --- /dev/null +++ b/tests/control_plane/test_canonical_handoff_invariants.py @@ -0,0 +1,842 @@ +"""Canonical handoff invariants retained from the retired writer proposal. + +Both real local providers must enforce one claim/lease owner. Ownership transfer +is atomic; metadata and duplicate create cannot perform the old two-write handoff. +Current transactions use real File/SQLite; no retired producer is restored. +""" + +from __future__ import annotations + +import json +from datetime import timedelta +from pathlib import Path +from typing import Any + +import pytest +from loopx.control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityRejection, + LocalCoordinationAuthorityUnavailable, + read_canonical_todos_if_promoted, +) +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) +from loopx.control_plane.todos.handoff_mode import ( + HandoffModeError, + set_goal_handoff_mode, +) +from loopx.control_plane.work_items import task_lease +from loopx.control_plane.work_items.task_lease import ( + TaskLeaseError, + acquire_task_lease, + inspect_task_lease, + release_task_lease, + renew_task_lease, + task_lease_path, + transfer_task_lease, +) +from loopx.todos import ( + add_goal_todo, + list_goal_todos, + complete_goal_todo, + supersede_goal_todo, + update_goal_todo, +) +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) + +GOAL_ID = "handoff-mode-gate" +AGENT_A, AGENT_B, ORCHESTRATOR = "agent-a", "agent-b", "agent-orchestrator" +DECISION_SCOPE = "direction:action:approve_handoff" + + + +def _write_workspace( + tmp_path: Path, + *, + handoff_mode: str | None = None, + lifecycle_authority: list[dict[str, Any]] | None = None, +) -> tuple[Path, Path]: + repo = tmp_path / "repo" + repo.mkdir(exist_ok=True) + state = repo / "ACTIVE_GOAL_STATE.md" + front_matter = [ + "---", + f"goal_id: {GOAL_ID}", + "updated_at: 2026-08-01T00:00:00+00:00", + ] + if handoff_mode is not None: + front_matter.append(f"handoff_mode: {handoff_mode}") + front_matter.append("---") + state.write_text( + "\n".join([*front_matter, "", "## Agent Todo", ""]) + "\n", + encoding="utf-8", + ) + coordination: dict[str, Any] = { + "agent_model": "peer_v1", + "registered_agents": [AGENT_A, AGENT_B, ORCHESTRATOR], + } + if lifecycle_authority is not None: + coordination["todo_lifecycle_authority"] = lifecycle_authority + registry = tmp_path / "registry.global.json" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(tmp_path / "runtime"), + "goals": [ + { + "id": GOAL_ID, + "domain": "harness_self_improvement", + "status": "active", + "repo": str(repo), + "state_file": state.name, + "adapter": {"kind": "harness_self_improvement"}, + "coordination": coordination, + } + ], + } + ), + encoding="utf-8", + ) + return registry, state + + +def _set_frontmatter_mode(state: Path, mode: str) -> None: + """Hand-edit the front-matter mode (simulates the out-of-contract path).""" + + lines = state.read_text(encoding="utf-8").splitlines() + close = next(i for i in range(1, len(lines)) if lines[i].strip() == "---") + for index in range(1, close): + if lines[index].split(":", 1)[0].strip() == "handoff_mode": + lines[index] = f"handoff_mode: {mode}" + break + else: + lines.insert(close, f"handoff_mode: {mode}") + state.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _add_todo(registry: Path, *, claimed_by: str | None = None) -> dict[str, Any]: + return add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Deliver one bounded control-plane change.", + task_class="advancement_task", + claimed_by=claimed_by, + ) + + +def _agent_todo(state: Path, todo_id: str) -> dict[str, Any]: + registry = state.parent.parent / "registry.global.json" + return list_goal_todos(registry_path=registry, goal_id=GOAL_ID, todo_id=todo_id)[ + "todo" + ] + + +def _add_exact_user_gate_pair( + registry: Path, +) -> tuple[dict[str, Any], dict[str, Any]]: + target = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Continue after the exact owner decision.", + task_class="advancement_task", + claimed_by=AGENT_A, + required_decision_scopes=[DECISION_SCOPE], + ) + gate = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="user", + text="Approve the exact handoff decision.", + task_class="user_gate", + blocks_agent=AGENT_A, + decision_scope=DECISION_SCOPE, + unblocks_todo_id=target["todo_id"], + ) + return target, gate + + +def _acquire( + registry: Path, + tmp_path: Path, + todo_id: str, + *, + owner: str, + key: str = "turn-lease-1", + ttl_seconds: int = 600, +) -> dict[str, Any]: + return acquire_task_lease( + registry_path=registry, + runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, + todo_id=todo_id, + owner=owner, + idempotency_key=key, + ttl_seconds=ttl_seconds, + ) + + +def _claim(registry: Path, todo_id: str, agent: str, **kwargs: Any) -> dict[str, Any]: + return update_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo_id, + claimed_by=agent, + agent_id=agent, + claim_only=True, + **kwargs, + ) + + +def _force_bootstrap( + registry: Path, state: Path, tmp_path: Path, **overrides: Any +) -> dict[str, Any]: + from loopx.bootstrap import bootstrap_project + + options: dict[str, Any] = dict( + project=state.parent, + registry_path=registry, + runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, + objective="Rebuild the active state without changing the handoff contract.", + domain="harness_self_improvement", + role="owner", + parent_goal_id=None, + state_file=state, + goal_doc=None, + adapter_kind="harness_self_improvement", + adapter_status="active", + next_probe=None, + spawn_allowed=False, + max_children=0, + allowed_domains=None, + write_scope=None, + preserve_todos=False, + force=True, + dry_run=False, + sync_global=False, + ) + options.update(overrides) + return bootstrap_project(**options) + + + + + + + + + + + + +@pytest.fixture(params=["file", "sqlite"]) +def canonical_workspace(request, tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + + def create(root, *, handoff_mode="soft_claim", retained=None, **kwargs): + registry, state = _write_workspace(root, handoff_mode=handoff_mode, **kwargs) + todos, leases = [], [] + if retained: + # Synthetic imported facts, committed once before any current call. + # This fixture is consumer-policy evidence, not an import journey. + todos = [ + { + "schema_version": "todo_item_v0", + "todo_id": "todo_retained", + "role": "agent", + "status": "open", + "done": False, + "text": "Retained work", + "archive_state": "active", + "source_section": "Agent Todo", + "index": 1, + "task_class": "advancement_task", + **retained.get("todo", {}), + } + ] + now = task_lease.now_utc() + start = now - timedelta(hours=2) if retained.get("expired") else now + leases = [ + { + "schema_version": "task_lease_v0", + "status": "active", + "goal_id": GOAL_ID, + "todo_id": "todo_retained", + "owner": AGENT_A, + "idempotency_key": "turn-lease-1", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 1, + "lease_epoch": 1, + "acquired_at": task_lease.isoformat(start), + "updated_at": task_lease.isoformat(start), + "expires_at": task_lease.isoformat(start + timedelta(seconds=600)), + } + ] + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, todos=todos, leases=leases, handoff_mode=handoff_mode + ) + initialize_canonical_authority( + root / "runtime", + GOAL_ID, + projection, + state_path=state, + provider=request.param, + ) + assert _snapshot(registry)["source_authority"] == request.param + "_v0" + return registry, state + + return create + + +def _snapshot(registry): + result = read_canonical_todos_if_promoted( + runtime_root=registry.parent / "runtime", goal_id=GOAL_ID, include_leases=True + ) + assert result["decision_read_from_provider"] is True + assert result["legacy_fallback_used"] is False + return result + + +def _lease(registry, todo_id): + result = inspect_task_lease( + registry_path=registry, + runtime_root=registry.parent / "runtime", + goal_id=GOAL_ID, + todo_id=todo_id, + ) + assert result["legacy_fallback_used"] is False + return result["lease"] + + +def _terminal(registry, todo_id, operation="complete", **kwargs): + options = { + "registry_path": registry, + "goal_id": GOAL_ID, + "todo_id": todo_id, + "agent_id": AGENT_A, + **kwargs, + } + if operation == "complete": + return complete_goal_todo(**options, no_followup=True) + return supersede_goal_todo(**options, reason="Retire obsolete work") + + +def _authority_unchanged(registry, before): + after = _snapshot(registry) + for field in ("provider_revision", "todos", "leases", "handoff_mode"): + assert after[field] == before[field], field + + +def _unchanged(registry, state, before, display): + _authority_unchanged(registry, before) + assert state.read_bytes() == display + + +@pytest.mark.parametrize("operation", ["claim", "replay"]) +def test_display_mode_cannot_override_canonical_policy( + tmp_path, canonical_workspace, operation +): + registry, state = canonical_workspace(tmp_path) + todo = _add_todo(registry, claimed_by=AGENT_A) + if operation == "replay": + original = _terminal( + registry, todo["todo_id"], completion_turn_key="display-replay" + ) + _set_frontmatter_mode(state, "banana") + if operation == "claim": + assert _claim(registry, todo["todo_id"], AGENT_A)["ok"] + else: + before, display = _snapshot(registry), state.read_bytes() + replay = _terminal( + registry, todo["todo_id"], completion_turn_key="display-replay" + ) + assert replay["idempotent_replay"] and not replay["changed"] + assert replay["original_receipt"] == original["original_receipt"] + _unchanged(registry, state, before, display) + assert _snapshot(registry)["handoff_mode"] == "soft_claim" + + +@pytest.mark.parametrize("verb", ["acquire", "renew", "transfer"]) +def test_soft_claim_forbids_new_lease_effects_even_with_retained_lineage( + tmp_path, canonical_workspace, verb +): + registry, state = canonical_workspace( + tmp_path, retained={"todo": {"claimed_by": AGENT_A}} + ) + before, display = _snapshot(registry), state.read_bytes() + with pytest.raises(TaskLeaseError) as error: + if verb == "acquire": + _acquire(registry, tmp_path, "todo_retained", owner=AGENT_A) + elif verb == "renew": + renew_task_lease( + registry_path=registry, + runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, + todo_id="todo_retained", + owner=AGENT_A, + idempotency_key="turn-lease-1", + expected_version=1, + ) + else: + transfer_task_lease( + registry_path=registry, + runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, + todo_id="todo_retained", + owner=AGENT_A, + idempotency_key="turn-lease-1", + expected_version=1, + new_owner=AGENT_B, + new_idempotency_key="peer", + transfer_claim=True, + ) + assert error.value.code == ( + "claim_transfer_requires_hard_lease" + if verb == "transfer" + else "handoff_mode_forbids_lease" + ) + _unchanged(registry, state, before, display) + + +def test_soft_claim_retained_lease_can_be_inspected_and_released( + tmp_path, canonical_workspace +): + registry, _ = canonical_workspace( + tmp_path, retained={"todo": {"claimed_by": AGENT_A}} + ) + assert _lease(registry, "todo_retained")["status"] == "active" + result = release_task_lease( + registry_path=registry, + runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, + todo_id="todo_retained", + owner=AGENT_A, + idempotency_key="turn-lease-1", + expected_version=1, + ) + assert result["released"] + assert _lease(registry, "todo_retained")["status"] == "released" + + +@pytest.mark.parametrize("operation", ["complete", "supersede"]) +def test_soft_claim_claim_and_terminal_need_no_execution_lease( + tmp_path, canonical_workspace, operation +): + registry, state = canonical_workspace(tmp_path) + todo = _add_todo(registry) + assert _claim(registry, todo["todo_id"], AGENT_A)["ok"] + result = _terminal(registry, todo["todo_id"], operation) + assert result["ok"] and result["task_lease_fence"]["lease_fence"] == "not_required" + assert _agent_todo(state, todo["todo_id"])["status"] == "done" + assert _snapshot(registry)["leases"] == [] + + +@pytest.mark.parametrize("mode", ["soft_claim", "hard_lease"]) +def test_terminal_replay_preserves_receipt_and_does_not_rewind_policy( + tmp_path, canonical_workspace, mode +): + registry, state = canonical_workspace(tmp_path, handoff_mode=mode) + todo = _add_todo(registry, claimed_by=AGENT_A) + proof = {} + if mode == "hard_lease": + _acquire(registry, tmp_path, todo["todo_id"], owner=AGENT_A) + proof = dict( + task_lease_idempotency_key="turn-lease-1", task_lease_expected_version=1 + ) + first = _terminal( + registry, todo["todo_id"], completion_turn_key="terminal-replay", **proof + ) + target = "hard_lease" if mode == "soft_claim" else "soft_claim" + assert set_goal_handoff_mode(registry_path=registry, goal_id=GOAL_ID, mode=target)[ + "changed" + ] + before = _snapshot(registry) + replay = _terminal( + registry, todo["todo_id"], completion_turn_key="terminal-replay", **proof + ) + assert replay["idempotent_replay"] and not replay["changed"] + assert replay["original_receipt"] == first["original_receipt"] + assert _snapshot(registry)["handoff_mode"] == target + # Replaying may deliver a pending projection at the current revision; + # the business receipt and canonical state must remain unchanged. + _authority_unchanged(registry, before) + assert todo["todo_id"] in state.read_text() + + +@pytest.mark.parametrize("lineage", ["missing", "foreign", "expired"]) +def test_hard_lease_claim_rejects_missing_foreign_or_expired_holder( + tmp_path, canonical_workspace, lineage +): + registry, state = canonical_workspace( + tmp_path, + handoff_mode="hard_lease", + retained={"expired": True} if lineage == "expired" else None, + ) + todo_id = ( + "todo_retained" if lineage == "expired" else _add_todo(registry)["todo_id"] + ) + if lineage == "foreign": + _acquire(registry, tmp_path, todo_id, owner=AGENT_B) + before, display = _snapshot(registry), state.read_bytes() + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + _claim(registry, todo_id, AGENT_A) + assert error.value.code == "handoff_mode_requires_lease" + _unchanged(registry, state, before, display) + + +@pytest.mark.parametrize( + "owner,claimant", [(AGENT_A, AGENT_A), (" Agent-A ", "AGENT-A")] +) +def test_hard_lease_holder_claim_normalizes_identities( + tmp_path, canonical_workspace, owner, claimant +): + registry, state = canonical_workspace(tmp_path, handoff_mode="hard_lease") + todo = _add_todo(registry) + _acquire(registry, tmp_path, todo["todo_id"], owner=owner) + assert _claim(registry, todo["todo_id"], claimant)["ok"] + assert _agent_todo(state, todo["todo_id"])["claimed_by"] == AGENT_A + assert _lease(registry, todo["todo_id"])["owner"] == AGENT_A + + +@pytest.mark.parametrize( + "ownership", + [{"clear_claim": True}, {"claimed_by": AGENT_A}, {"claimed_by": AGENT_B}], +) +@pytest.mark.parametrize("leased", [False, True]) +def test_metadata_cannot_rewrite_hard_lease_execution_grants( + tmp_path, canonical_workspace, ownership, leased +): + grant = [ + { + "agent_id": ORCHESTRATOR, + "actions": ["reassign", "update"], + "requires_reason": True, + } + ] + registry, state = canonical_workspace( + tmp_path, handoff_mode="hard_lease", lifecycle_authority=grant + ) + todo = _add_todo(registry, claimed_by=AGENT_A) + if leased: + _acquire(registry, tmp_path, todo["todo_id"], owner=AGENT_A) + before, display = _snapshot(registry), state.read_bytes() + # Even explicit orchestration authority cannot split claim and lease owners. + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + update_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo["todo_id"], + agent_id=ORCHESTRATOR, + authority_reason="Hand over work", + **ownership, + ) + assert error.value.code == "update_lease_ownership_transition_unsupported" + _unchanged(registry, state, before, display) + + +def test_metadata_edit_requires_current_execution_proof(tmp_path, canonical_workspace): + registry, state = canonical_workspace(tmp_path, handoff_mode="hard_lease") + todo = _add_todo(registry, claimed_by=AGENT_A) + before, display = _snapshot(registry), state.read_bytes() + with pytest.raises(LocalCoordinationAuthorityRejection): + update_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo["todo_id"], + agent_id=AGENT_A, + note="Validated progress", + ) + _unchanged(registry, state, before, display) + _acquire(registry, tmp_path, todo["todo_id"], owner=AGENT_A) + updated = update_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo["todo_id"], + agent_id=AGENT_A, + note="Validated progress", + task_lease_idempotency_key="turn-lease-1", + task_lease_expected_version=1, + ) + assert updated["ok"] + assert "Validated progress" in _agent_todo(state, todo["todo_id"])["note"] + + +def test_create_assigns_new_work_but_duplicate_create_cannot_reassign( + tmp_path, canonical_workspace +): + registry, state = canonical_workspace(tmp_path, handoff_mode="hard_lease") + created = _add_todo(registry, claimed_by=AGENT_A) + assert created["added"] + _acquire(registry, tmp_path, created["todo_id"], owner=AGENT_A) + before, display = _snapshot(registry), state.read_bytes() + assert _add_todo(registry, claimed_by=AGENT_A)["already_exists"] + _authority_unchanged(registry, before) + display = state.read_bytes() + with pytest.raises( + LocalCoordinationAuthorityUnavailable, match="different claimed_by" + ): + _add_todo(registry, claimed_by=AGENT_B) + _unchanged(registry, state, before, display) + + +@pytest.mark.parametrize("operation", ["complete", "supersede"]) +@pytest.mark.parametrize( + "fault,code", + [ + ("missing", "handoff_mode_requires_lease"), + ("keyless", "lease_fence_required"), + ("wrong_key", "lease_cas_mismatch"), + ("foreign", "lease_cas_mismatch"), + ("diverged", "handoff_mode_lease_claim_divergence"), + ], +) +def test_terminal_fence_rejects_invalid_execution_without_mutation( + tmp_path, canonical_workspace, operation, fault, code +): + registry, state = canonical_workspace( + tmp_path, + handoff_mode="hard_lease", + retained={"todo": {"claimed_by": AGENT_B}} if fault == "diverged" else None, + ) + todo_id = ( + "todo_retained" + if fault == "diverged" + else _add_todo(registry, claimed_by=None if fault == "foreign" else AGENT_A)[ + "todo_id" + ] + ) + if fault not in ("missing", "diverged"): + _acquire(registry, tmp_path, todo_id, owner=AGENT_A) + proof = {} + if fault in ("wrong_key", "foreign"): + proof = dict( + task_lease_idempotency_key="wrong" + if fault == "wrong_key" + else "turn-lease-1", + task_lease_expected_version=1, + ) + before, display = _snapshot(registry), state.read_bytes() + actor = AGENT_B if fault in ("diverged", "foreign") else AGENT_A + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + _terminal(registry, todo_id, operation, agent_id=actor, **proof) + assert error.value.code == code + _unchanged(registry, state, before, display) + + +@pytest.mark.parametrize("operation", ["complete", "supersede"]) +@pytest.mark.parametrize("dry_run", [False, True]) +def test_terminal_verified_execution_releases_only_on_commit( + tmp_path, canonical_workspace, operation, dry_run +): + registry, state = canonical_workspace(tmp_path, handoff_mode="hard_lease") + todo = _add_todo(registry, claimed_by=AGENT_A) + _acquire(registry, tmp_path, todo["todo_id"], owner=AGENT_A) + before, display = _snapshot(registry), state.read_bytes() + result = _terminal( + registry, + todo["todo_id"], + operation, + dry_run=dry_run, + task_lease_idempotency_key="turn-lease-1", + task_lease_expected_version=1, + ) + assert result["ok"] and result["task_lease_fence"]["lease_fence"] == "required" + if dry_run: + _unchanged(registry, state, before, display) + else: + assert _agent_todo(state, todo["todo_id"])["status"] == "done" + assert _lease(registry, todo["todo_id"])["status"] == "released" + assert not task_lease_path( + runtime_root=tmp_path / "runtime", goal_id=GOAL_ID, todo_id=todo["todo_id"] + ).exists() + + +def test_completion_can_assign_successor_without_inventing_its_lease( + tmp_path, canonical_workspace +): + registry, state = canonical_workspace(tmp_path, handoff_mode="hard_lease") + todo = _add_todo(registry, claimed_by=AGENT_A) + _acquire(registry, tmp_path, todo["todo_id"], owner=AGENT_A) + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo["todo_id"], + agent_id=AGENT_A, + task_lease_idempotency_key="turn-lease-1", + task_lease_expected_version=1, + next_agent_todo="Follow-up slice", + next_claimed_by=AGENT_B, + ) + successor = result["next_todos"][0]["todo_id"] + assert _agent_todo(state, successor)["claimed_by"] == AGENT_B + assert all(lease["todo_id"] != successor for lease in _snapshot(registry)["leases"]) + + +@pytest.mark.parametrize("operation", ["complete", "supersede"]) +@pytest.mark.parametrize("authorized", [False, True]) +def test_delegated_terminal_authority_requires_real_grant( + tmp_path, canonical_workspace, operation, authorized +): + grants = ( + [{"agent_id": ORCHESTRATOR, "actions": [operation], "requires_reason": True}] + if authorized + else [] + ) + registry, state = canonical_workspace( + tmp_path, handoff_mode="hard_lease", lifecycle_authority=grants + ) + todo = _add_todo(registry, claimed_by=AGENT_A) + before, display = _snapshot(registry), state.read_bytes() + if not authorized: + with pytest.raises(LocalCoordinationAuthorityRejection): + _terminal( + registry, + todo["todo_id"], + operation, + agent_id=ORCHESTRATOR, + authority_reason="Retire stalled item", + ) + _unchanged(registry, state, before, display) + else: + result = _terminal( + registry, + todo["todo_id"], + operation, + agent_id=ORCHESTRATOR, + authority_reason="Retire stalled item", + ) + assert ( + result["mutation_authority"]["authority_mode"] + == "delegated_orchestration_override" + ) + assert result["task_lease_fence"]["lease_fence"] == "delegated_override" + assert _agent_todo(state, todo["todo_id"])["status"] == "done" + + +@pytest.mark.parametrize( + "proof", ["auto", "verified", "wrong_key", "foreign", "foreign_keyless"] +) +def test_exact_user_gate_completion_preserves_execution_fence( + tmp_path, canonical_workspace, proof +): + registry, state = canonical_workspace(tmp_path, handoff_mode="hard_lease") + target, gate = _add_exact_user_gate_pair(registry) + options = dict(role="user", decision_outcome="approve") + if proof != "auto": + _acquire( + registry, + tmp_path, + gate["todo_id"], + owner=AGENT_B if proof.startswith("foreign") else AGENT_A, + ) + if proof != "foreign_keyless": + options.update( + task_lease_idempotency_key="wrong" + if proof == "wrong_key" + else "turn-lease-1", + task_lease_expected_version=1, + ) + before, display = _snapshot(registry), state.read_bytes() + if proof in ("wrong_key", "foreign", "foreign_keyless"): + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + _terminal(registry, gate["todo_id"], **options) + assert error.value.code == ( + "lease_fence_required" + if proof == "foreign_keyless" + else "lease_cas_mismatch" + ) + _unchanged(registry, state, before, display) + else: + result = _terminal(registry, gate["todo_id"], **options) + assert ( + result["mutation_authority"]["authority_mode"] + == "exact_user_gate_decision_scope_override" + ) + assert result["task_lease_fence"]["lease_fence"] == ( + "auto_acquire" if proof == "auto" else "required" + ) + assert _lease(registry, gate["todo_id"])["status"] == "released" + assert _agent_todo(state, gate["todo_id"])["status"] == "done" + assert not _agent_todo(state, target["todo_id"]).get("required_decision_scopes") + assert result["decision_scope_resolution"]["state"] == "resolved" + + +def test_bound_user_action_completes_without_gate_or_execution_claim( + tmp_path, canonical_workspace +): + registry, state = canonical_workspace(tmp_path, handoff_mode="hard_lease") + todo = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="user", + text="Owner follow-up", + task_class="user_action", + bound_agent=AGENT_A, + ) + result = _terminal(registry, todo["todo_id"], role="user") + assert result["task_lease_fence"]["lease_fence"] == "not_required" + assert _snapshot(registry)["leases"] == [] + assert _agent_todo(state, todo["todo_id"])["status"] == "done" + + +@pytest.mark.parametrize("mode", ["soft_claim", "hard_lease"]) +@pytest.mark.parametrize("dry_run", [False, True]) +def test_force_bootstrap_cannot_erase_canonical_policy_or_work( + tmp_path, canonical_workspace, mode, dry_run +): + registry, state = canonical_workspace(tmp_path, handoff_mode=mode) + _add_todo(registry, claimed_by=AGENT_A) + before, display = _snapshot(registry), state.read_bytes() + with pytest.raises( + ValueError, match="Canonical authority cannot rebuild existing state" + ): + _force_bootstrap(registry, state, tmp_path, dry_run=dry_run) + _unchanged(registry, state, before, display) + + + + +def test_delegated_terminal_replay_retains_original_authority_receipt( + tmp_path, canonical_workspace +): + registry, state = canonical_workspace( + tmp_path, + handoff_mode="hard_lease", + lifecycle_authority=[ + {"agent_id": ORCHESTRATOR, "actions": ["complete"], "requires_reason": True} + ], + ) + todo = _add_todo(registry, claimed_by=AGENT_A) + intent = dict( + agent_id=ORCHESTRATOR, + authority_reason="Close stalled work", + completion_turn_key="delegated-close", + ) + result = _terminal(registry, todo["todo_id"], **intent) + before, display = _snapshot(registry), state.read_bytes() + replay = _terminal(registry, todo["todo_id"], **intent) + assert replay["idempotent_replay"] and not replay["changed"] + assert replay["original_receipt"] == result["original_receipt"] + assert ( + replay["mutation_authority"]["authority_mode"] + == "delegated_orchestration_override" + ) + _unchanged(registry, state, before, display) + + +def test_invalid_requested_policy_leaves_canonical_authority_unchanged( + tmp_path, canonical_workspace +): + registry, state = canonical_workspace(tmp_path) + _add_todo(registry, claimed_by=AGENT_A) + before, display = _snapshot(registry), state.read_bytes() + with pytest.raises(HandoffModeError) as error: + set_goal_handoff_mode(registry_path=registry, goal_id=GOAL_ID, mode="banana") + assert error.value.code == "invalid_handoff_mode" + _unchanged(registry, state, before, display) diff --git a/tests/control_plane/test_canonical_handoff_mode.py b/tests/control_plane/test_canonical_handoff_mode.py index 4a40ab162b..fe81fe0bed 100644 --- a/tests/control_plane/test_canonical_handoff_mode.py +++ b/tests/control_plane/test_canonical_handoff_mode.py @@ -228,3 +228,94 @@ def test_provider_failure_is_not_a_legacy_fallback(canonical_mode, monkeypatch): provider_handoff_mode.set_canonical_handoff_mode(runtime_root=runtime, goal_id="mode-goal", mode="hard_lease", operation_id="unavailable", dry_run=False) assert state.read_bytes() == before + + +def test_retired_policy_requires_release_and_reviewed_migration_before_execution(canonical_mode): + state, runtime, cli = canonical_mode + from datetime import datetime, timedelta, timezone + from loopx.todos import add_goal_todo + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + + registry = runtime.parent / "registry.json" + todo = add_goal_todo(registry_path=registry, goal_id="mode-goal", role="agent", + text="Preserve imported ownership", claimed_by="agent-a", + task_class="advancement_task", note="Retain original metadata") + # Imported historical facts are installed directly in this isolated test + # provider. No current business writer or new legacy lease grant produces them. + at = datetime.now(timezone.utc).replace(microsecond=0) + lease = {"schema_version": "task_lease_v0", "goal_id": "mode-goal", "todo_id": todo["todo_id"], + "owner": "agent-a", "idempotency_key": "imported-execution", "version": 4, "lease_epoch": 2, + "status": "active", "write_scopes": [], "acquire_ttl_seconds": 600, + "acquired_at": (at - timedelta(seconds=30)).isoformat(), "updated_at": at.isoformat(), + "expires_at": (at + timedelta(seconds=600)).isoformat()} + coordination = Path(__file__).resolve().parents[2] / "loopx/control_plane/coordination" + script = f""" + import {{openLocalAuthorityStore}} from {json.dumps((coordination / 'local_authority_provider.ts').as_uri())}; + const store = await openLocalAuthorityStore(process.argv[1], 'mode-goal'); + const before = await store.loadAuthority(); + if (before.status !== 'loaded') throw new Error(JSON.stringify(before)); + const result = await store.commitAuthority({{operation_id: 'imported-policy-fixture', + expected_provider_revision: before.provider_revision, next_projection: {{...before.head, + handoff_mode: 'legacy', leases: [{json.dumps(lease)}]}}, events: [], receipts: []}}); + if (result.status !== 'applied') throw new Error(JSON.stringify(result)); + """ + seeded = subprocess.run(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", + "-e", script, str(runtime)], capture_output=True, text=True, timeout=45) + assert seeded.returncode == 0, seeded.stderr + + def read(): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="mode-goal", include_leases=True) + + def work(*args, expected_exit=0): + child = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), "--format", "json", + *args, "--goal-id", "mode-goal", "--todo-id", todo["todo_id"]], capture_output=True, text=True, timeout=90) + assert child.returncode == expected_exit, child.stdout + child.stderr + return json.loads(child.stdout) + + before = read() + inspected = work("task-lease", "inspect") + assert inspected["active"] is False + assert inspected["executor_constraint"]["reason"] == "handoff_mode_retired" + assert inspected["lease"] == lease + for args in [ + ("todo", "claim", "--agent-id", "agent-a", "--claimed-by", "agent-a"), + ("todo", "update", "--agent-id", "agent-a", "--note", "Unreviewed work"), + ("todo", "complete", "--agent-id", "agent-a", "--no-follow-up", "--note", "Bounded completion"), + ("todo", "update", "--agent-id", "agent-a", "--note", "Old proof cannot authorize", + "--task-lease-idempotency-key", "imported-execution", "--task-lease-expected-version", "4"), + ]: + refused = work(*args, expected_exit=1) + assert (refused.get("error_code") or refused.get("reason_code")) == "handoff_mode_retired", refused + assert read() == before + released = work("task-lease", "release", "--owner", "agent-a", "--idempotency-key", "imported-execution", + "--expected-version", "4") + assert released["released"] is True + retained = read() + assert retained["handoff_mode"] == "legacy" and retained["todos"] == before["todos"] + assert retained["leases"][0]["status"] == "released" + assert retained["leases"][0]["version"] == 4 and retained["leases"][0]["lease_epoch"] == 2 + plan = runtime.parent / "retired-policy-upgrade.json" + code, planned = cli("plan-migration", "--mode", "hard_lease", "--plan", str(plan)) + assert code == 0 and planned["status"] == "planned", planned + assert read() == retained + digest = planned["plan_sha256"] + code, applied = cli("migrate", "--plan", str(plan), "--plan-sha256", digest, "--execute") + assert code == 0 and applied["status"] == "applied", applied + assert applied["execution_authority_granted"] is False + assert Path(applied["backup_path"]).is_file() + migrated = read() + assert migrated["handoff_mode"] == "hard_lease" + assert migrated["todos"] == retained["todos"] and migrated["leases"] == retained["leases"] + acquired = work("task-lease", "acquire", "--owner", "agent-a", "--idempotency-key", "new-execution", + "--expected-version", "4", "--ttl-seconds", "600") + assert acquired["acquired"] is True and acquired["lease"]["version"] == 5 + assert acquired["lease"]["lease_epoch"] == 3 + assert work("todo", "update", "--agent-id", "agent-a", "--note", "Reviewed current work", + "--task-lease-idempotency-key", "new-execution", "--task-lease-expected-version", "5")["ok"] is True + later = read() + code, replay = cli("migrate", "--plan", str(plan), "--plan-sha256", digest, "--execute") + assert code == 0 and replay["status"] == "replayed", replay + assert read() == later, "historical migration cannot overwrite later execution" + assert work("task-lease", "release", "--owner", "agent-a", "--idempotency-key", "new-execution", + "--expected-version", "5")["released"] is True + assert state.exists() diff --git a/tests/control_plane/test_canonical_lease_inspection.py b/tests/control_plane/test_canonical_lease_inspection.py index 88ca782337..b00f5767bf 100644 --- a/tests/control_plane/test_canonical_lease_inspection.py +++ b/tests/control_plane/test_canonical_lease_inspection.py @@ -171,16 +171,6 @@ def change_source(method, payload, **kwargs): assert len(calls) == 3 -def test_owner_constraint_requires_the_native_diagnostic_contract(monkeypatch): - from loopx.control_plane import effect_runtime - from loopx.control_plane.work_items.task_lease import task_lease_owner_constraint - monkeypatch.setattr(effect_runtime, "effect_runtime_result", lambda *_: { - "schema_version": "task_lease_owner_eligibility_v0", "outcome": "apply", "code": "lease_owner_allowed", - }) - with pytest.raises(RuntimeError, match="constraint shape mismatch"): - task_lease_owner_constraint({"status": "open"}, owner="agent-a") - - def test_inactive_legacy_inspection_does_not_project_todos(tmp_path, monkeypatch): registry = tmp_path / "registry.json" state = tmp_path / "state.md" @@ -200,10 +190,11 @@ def forbidden_projection(**_): assert expired["active"] is False and expired["handoff_mode"] == "hard_lease" -def test_legacy_inspection_reloads_lease_after_demanding_todo_facts(tmp_path, monkeypatch): +@pytest.mark.parametrize("mode", ["legacy", "soft_claim", "hard_lease"]) +def test_retained_source_inspection_never_projects_current_execution(tmp_path, monkeypatch, mode): registry = tmp_path / "registry.json" state = tmp_path / "state.md" - state.write_text("---\nhandoff_mode: hard_lease\n---\n# Synthetic state\n") + state.write_text(f"---\nhandoff_mode: {mode}\n---\n# Synthetic state\n") registry.write_text(json.dumps({"goals": [{"id": GOAL, "repo": str(tmp_path), "state_file": str(state), "coordination": {"registered_agents": ["agent-a"]}}]})) runtime = tmp_path / "runtime" @@ -212,16 +203,17 @@ def test_legacy_inspection_reloads_lease_after_demanding_todo_facts(tmp_path, mo lease = {"schema_version": "task_lease_v0", "status": "active", "owner": "agent-a", "expires_at": "2099-01-01T00:00:00Z"} lease_path.write_text(json.dumps(lease)) from loopx.control_plane.todos import list_readback - calls = [] - def release_during_projection(**_): - calls.append(True) - lease_path.write_text(json.dumps({**lease, "status": "released"})) - return {"todos": [{"todo_id": TODO, "status": "open", "claimed_by": None, "excluded_agents": []}]} - monkeypatch.setattr(list_readback, "list_goal_todos", release_during_projection) + original = lease_path.read_bytes() + def forbidden_projection(**_): + raise AssertionError("retained source inspection must not revive Todo admission") + monkeypatch.setattr(list_readback, "list_goal_todos", forbidden_projection) result = _inspect(registry, runtime) - assert len(calls) == 1 assert result["active"] is False - assert result["lease"]["status"] == "released" + assert result["lease"] == lease + assert lease_path.read_bytes() == original + assert result["executor_constraint"]["reason"] == "canonical_authority_required" + assert result["execution_policy"]["supported"] is False + assert result["execution_policy"]["execution_authority_granted"] is False assert "todo_projection_required" not in result diff --git a/tests/control_plane/test_causal_blocked_closeout_cli.py b/tests/control_plane/test_causal_blocked_closeout_cli.py index a91d232323..6582deaf5a 100644 --- a/tests/control_plane/test_causal_blocked_closeout_cli.py +++ b/tests/control_plane/test_causal_blocked_closeout_cli.py @@ -78,7 +78,7 @@ def cli(*args: str, cwd: Path = project) -> tuple[int, dict]: if defer: next(todo for todo in listed["todos"] if todo["todo_id"] == TODO_ID)["claimed_by"] = AGENT_ID initialize_canonical_authority(runtime, GOAL_ID, build_todo_runtime_shadow_projection( - goal_id=GOAL_ID, todos=listed["todos"], handoff_mode="legacy" if defer else "soft_claim", leases=[], + goal_id=GOAL_ID, todos=listed["todos"], handoff_mode="hard_lease" if defer else "soft_claim", leases=[], ), state_path=state, provider=provider) binding = ("--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", f"causal-blocked-{kind}-{provider}") diff --git a/tests/control_plane/test_chat_completion_execution_proof.py b/tests/control_plane/test_chat_completion_execution_proof.py index 8c49fcbf71..7f0e93319d 100644 --- a/tests/control_plane/test_chat_completion_execution_proof.py +++ b/tests/control_plane/test_chat_completion_execution_proof.py @@ -8,10 +8,9 @@ import pytest -from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from canonical_authority_fixture import isolate_sqlite_runtime, promoted_create_fixture from loopx.chat_action_store import ChatActionStore from loopx.chat_actions import ChatActionService -from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.work_items.task_lease import acquire_task_lease, inspect_task_lease, release_task_lease from loopx.todos import add_goal_todo, list_goal_todos @@ -19,25 +18,22 @@ def fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, ttl_seconds: int = 600, validation: bool = False): isolate_sqlite_runtime(tmp_path, monkeypatch) - state = tmp_path / "state.md" - state.write_text("# Goal\n\n## Agent Todo\n\n## Completed Work Archive\n") - runtime = tmp_path / "runtime" - registry = tmp_path / "registry.json" - registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ - "id": "goal-a", "repo": str(tmp_path), "state_file": state.name, - "coordination": {"registered_agents": ["agent-a", "agent-b"], "handoff_mode": "hard_lease"}, - }]})) + # Create through the native owner after selecting an empty canonical store. + # The retired Markdown writer cannot bootstrap a completion-proof fixture. + registry, runtime, state = promoted_create_fixture( + tmp_path, provider=provider, handoff_mode="hard_lease", + registered_agents=["agent-a", "agent-b"], + ) marker = tmp_path / "validation-count" command = shlex.join([sys.executable, "-c", "from pathlib import Path; " f"p=Path({str(marker)!r}); p.write_text(p.read_text()+'x' if p.exists() else 'x')"]) - # These transport tests start with canonical authority; fixture creation - # must not depend on an unpromoted Markdown writer before the first lease. - initialize_canonical_authority(runtime, "goal-a", - build_todo_runtime_shadow_projection(goal_id="goal-a", todos=[], handoff_mode="hard_lease"), - state_path=state, provider=provider) todo_id = add_goal_todo(registry_path=registry, goal_id="goal-a", role="agent", text="Complete independently validated work", claimed_by="agent-a", validation_command=command if validation else None)["todo_id"] + readback = list_goal_todos(registry_path=registry, goal_id="goal-a") + assert readback["authority_read"]["source_authority"] == f"{provider}_v0" + assert readback["authority_read"]["decision_read_from_provider"] is True + assert readback["authority_read"]["legacy_fallback_used"] is False lease = acquire_task_lease(registry_path=registry, runtime_root=runtime, goal_id="goal-a", todo_id=todo_id, owner="agent-a", idempotency_key="original-execution", ttl_seconds=ttl_seconds, expected_version=0)["lease"] diff --git a/tests/control_plane/test_chat_goal_ownership.py b/tests/control_plane/test_chat_goal_ownership.py index cc7de9dece..e9029865bc 100644 --- a/tests/control_plane/test_chat_goal_ownership.py +++ b/tests/control_plane/test_chat_goal_ownership.py @@ -229,6 +229,10 @@ def test_pending_lease_downgrade_rejected_even_after_clock_expiry(api): claimed_by="agent-a", task_class="advancement_task", ) + # Migrate the historical policy through the real App owner before execution. + upgrade = preview(call, "hard_lease") + code, migrated = apply(call, upgrade) + assert code == 200 and migrated["ok"], migrated # Use the real acquire owner; an expired lease is not proof the host stopped. acquired = acquire_task_lease( registry_path=registry, diff --git a/tests/control_plane/test_chat_reviewed_create.py b/tests/control_plane/test_chat_reviewed_create.py index 51cb1e12c5..418eb446ef 100644 --- a/tests/control_plane/test_chat_reviewed_create.py +++ b/tests/control_plane/test_chat_reviewed_create.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize("provider", ["file", "sqlite"]) -@pytest.mark.parametrize("timing", ["before-confirmation", "after-final-preview"]) +@pytest.mark.parametrize("timing", ["before-confirmation", "between-preview-reads", "after-final-preview"]) def test_concurrent_create_refuses_reviewed_intent(tmp_path, monkeypatch, provider, timing): isolate_sqlite_runtime(tmp_path, monkeypatch) registry, runtime, _ = promoted_create_fixture(tmp_path, provider=provider) @@ -25,7 +25,9 @@ def independent_write(): original = chat._add_review_todo def interleaved(**kwargs): - if not kwargs["dry_run"]: + if (timing == "after-final-preview" and not kwargs["dry_run"]) or ( + timing == "between-preview-reads" and kwargs["dry_run"] and kwargs.get("operation_id") + ): independent_write() return original(**kwargs) @@ -38,6 +40,15 @@ def interleaved(**kwargs): assert ("current_preview_id" in conflict.value.receipt) is (timing == "before-confirmation") snapshot = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") assert [todo["text"] for todo in snapshot["todos"]] == ["Independent accepted request"] + if timing != "before-confirmation": + monkeypatch.setattr(chat, "_add_review_todo", original) + refreshed = chat.build_todo_review_preview( + registry_path=registry, goal_id="goal-a", text="Reviewed request") + applied = chat.apply_todo_review_preview(registry_path=registry, goal_id="goal-a", + text="Reviewed request", preview_id=refreshed["preview_id"]) + assert applied["todo"]["todo_id"] == refreshed["todo"]["todo_id"] + snapshot = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") + assert {todo["text"] for todo in snapshot["todos"]} == {"Independent accepted request", "Reviewed request"} @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -45,10 +56,29 @@ def test_current_review_creates_once_and_duplicate_remains_noop(tmp_path, monkey isolate_sqlite_runtime(tmp_path, monkeypatch) registry, runtime, _ = promoted_create_fixture(tmp_path, provider=provider) for _ in range(2): + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") preview = chat.build_todo_review_preview( registry_path=registry, goal_id="goal-a", text="Reviewed request") + assert chat.build_todo_review_preview( + registry_path=registry, goal_id="goal-a", text="Reviewed request") == preview + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == before result = chat.apply_todo_review_preview(registry_path=registry, goal_id="goal-a", text="Reviewed request", preview_id=preview["preview_id"]) assert result["applied"] is True + assert result["todo"]["todo_id"] == preview["todo"]["todo_id"] snapshot = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") assert [todo["text"] for todo in snapshot["todos"]] == ["Reviewed request"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_changed_intent_cannot_apply_another_preview(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _ = promoted_create_fixture(tmp_path, provider=provider) + preview = chat.build_todo_review_preview( + registry_path=registry, goal_id="goal-a", text="Reviewed request") + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") + for changed in ({"text": "Different request"}, {"text": "Reviewed request", "priority": "P0"}): + with pytest.raises(chat.TodoReviewPreviewConflict): + chat.apply_todo_review_preview(registry_path=registry, goal_id="goal-a", + preview_id=preview["preview_id"], **changed) + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == before diff --git a/tests/control_plane/test_goal_channel_hard_lease_visibility.py b/tests/control_plane/test_goal_channel_hard_lease_visibility.py index 3a08b43646..344e35f46b 100644 --- a/tests/control_plane/test_goal_channel_hard_lease_visibility.py +++ b/tests/control_plane/test_goal_channel_hard_lease_visibility.py @@ -5,12 +5,15 @@ from pathlib import Path from typing import Any +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.goals.goal_channel import attach_goal_channel_projection from loopx.control_plane.goals.goal_channel_projection import ( build_goal_channel_projection, ) from loopx.control_plane.work_items.task_lease import ( - build_lease, isoformat, now_utc, task_lease_dir, @@ -48,19 +51,21 @@ def _write_active_lease( ttl_seconds: int = 600, ) -> dict[str, Any]: at = now_utc() - lease = build_lease( - goal_id=GOAL_ID, - todo_id=todo_id, - owner=owner, - idempotency_key=f"{owner}-instance-1", - write_scopes=["src/"], - acquire_ttl_seconds=ttl_seconds, - version=version, - lease_epoch=1, - acquired_at=isoformat(at), - updated_at=isoformat(at), - expires_at=isoformat(at + timedelta(seconds=ttl_seconds)), - ) + lease = { + "schema_version": "task_lease_v0", + "status": "active", + "goal_id": GOAL_ID, + "todo_id": todo_id, + "owner": owner, + "idempotency_key": f"{owner}-instance-1", + "write_scopes": ["src/"], + "acquire_ttl_seconds": ttl_seconds, + "version": version, + "lease_epoch": 1, + "acquired_at": isoformat(at), + "updated_at": isoformat(at), + "expires_at": isoformat(at + timedelta(seconds=ttl_seconds)), + } path = task_lease_path(runtime_root=runtime_root, goal_id=GOAL_ID, todo_id=todo_id) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(lease), encoding="utf-8") @@ -152,19 +157,21 @@ def test_missing_runtime_root_preserves_previous_projection_shape() -> None: def test_expired_lease_is_not_surfaced(tmp_path: Path) -> None: at = now_utc() - lease = build_lease( - goal_id=GOAL_ID, - todo_id=TODO_ID, - owner="agent-a", - idempotency_key="agent-a-instance-1", - write_scopes=[], - acquire_ttl_seconds=60, - version=1, - lease_epoch=1, - acquired_at=isoformat(at - timedelta(seconds=120)), - updated_at=isoformat(at - timedelta(seconds=120)), - expires_at=isoformat(at - timedelta(seconds=60)), - ) + lease = { + "schema_version": "task_lease_v0", + "status": "active", + "goal_id": GOAL_ID, + "todo_id": TODO_ID, + "owner": "agent-a", + "idempotency_key": "agent-a-instance-1", + "write_scopes": [], + "acquire_ttl_seconds": 60, + "version": 1, + "lease_epoch": 1, + "acquired_at": isoformat(at - timedelta(seconds=120)), + "updated_at": isoformat(at - timedelta(seconds=120)), + "expires_at": isoformat(at - timedelta(seconds=60)), + } path = task_lease_path(runtime_root=tmp_path, goal_id=GOAL_ID, todo_id=TODO_ID) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(lease), encoding="utf-8") @@ -309,8 +316,9 @@ def _write_status_workspace(tmp_path: Path) -> tuple[Path, Path, Path]: return registry, repo, runtime_root +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_status_attention_queue_surfaces_hard_lease_end_to_end( - tmp_path: Path, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, ) -> None: """Regression pin for the real delivery surface. @@ -321,11 +329,11 @@ def test_status_attention_queue_surfaces_hard_lease_end_to_end( from the attention item's goal_channel_projection and fails this test. """ - from canonical_authority_fixture import initialize_canonical_authority from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.todos.todo_summary import canonical_todo_read_record, structured_todo_item from loopx.status import collect_status + isolate_sqlite_runtime(tmp_path, monkeypatch) registry, repo, runtime_root = _write_status_workspace(tmp_path) todo_id = TODO_ID lease = _write_active_lease(runtime_root, owner="agent-a", todo_id=todo_id) @@ -341,7 +349,7 @@ def test_status_attention_queue_surfaces_hard_lease_end_to_end( "task_class": "advancement_task", "claimed_by": "agent-b", }, role="agent", source_section="Agent Todo"))], ), - state_path=repo / "ACTIVE_GOAL_STATE.md", + state_path=repo / "ACTIVE_GOAL_STATE.md", provider=provider, ) payload = collect_status( @@ -359,7 +367,7 @@ def test_status_attention_queue_surfaces_hard_lease_end_to_end( assert items, "expected an attention item for the fixture goal" projection = items[0].get("goal_channel_projection") assert isinstance(projection, dict) - assert projection["coordination_observation"]["source_authority"] == "file_v0" + assert projection["coordination_observation"]["source_authority"] == f"{provider}_v0" assert projection["coordination_observation"]["legacy_fallback_used"] is False assert _entries_with_status(projection, "soft_claim") == [ {"todo_id": todo_id, "owner_agent": "agent-b", "status": "soft_claim"} diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index 2f419fd250..51992c4524 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -941,10 +941,9 @@ def _seed_promoted_store( } ], ) - # main's builder carries no handoff mode and the claim owner reads a missing - # mode as legacy; this branch's builder defaults to hard_lease, so seed the - # legacy mode explicitly unless a test asks for another one. - projection["handoff_mode"] = str(handoff_mode) if handoff_mode is not None else "legacy" + # Normal claim tests need a current policy; historical wire shape does not + # imply the retired ownership strategy. Explicit modes stay caller-owned. + projection["handoff_mode"] = str(handoff_mode) if handoff_mode is not None else "soft_claim" state_path = runtime_root / "ACTIVE_GOAL_STATE.md" if not state_path.exists(): state_path.write_text( diff --git a/tests/control_plane/test_native_todo_planning_update.py b/tests/control_plane/test_native_todo_planning_update.py index b9857da4a5..a7ab8ab0fa 100644 --- a/tests/control_plane/test_native_todo_planning_update.py +++ b/tests/control_plane/test_native_todo_planning_update.py @@ -67,6 +67,56 @@ def records(registry: Path) -> dict[str, dict]: return {item["todo_id"]: item for item in list_goal_todos(registry_path=registry, goal_id="goal-a")["todos"]} +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("claim_flag", [False, True]) +def test_cli_imported_completion_repairs_only_metadata(tmp_path, monkeypatch, provider, claim_flag): + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + from loopx.control_plane.testing.canary_harness import run_json_cli, run_json_cli_result + + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = source_fixture(tmp_path) + rows = list_goal_todos(registry_path=registry, goal_id="goal-a")["todos"] + imported = next(row for row in rows if row["todo_id"] == "todo_target") + imported.update(status="done", done=True, evidence="Original evidence", reason="Original reason", + completed_at="2026-01-02T00:00:00Z", updated_at="2026-01-02T01:00:00Z", + last_actor_agent_id="agent-b", completion_receipt_id="tcw_original") + projection = build_todo_runtime_shadow_projection(goal_id="goal-a", todos=rows, handoff_mode="soft_claim") + runtime = tmp_path / "runtime" + initialize_canonical_authority(runtime, "goal-a", projection, state_path=state, provider=provider) + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") + source_before = state.read_bytes() + prefix = ("todo", "complete", "--goal-id", "goal-a", "--todo-id", "todo_target") + for actor, extra, code in [ + ("agent-b", (), "claim_owner_mismatch"), + ("agent-a", ("--note", "A fresh annotation"), "invalid_todo_completion_transaction"), + ("agent-a", ("--evidence", "A fresh completion result"), "invalid_todo_completion_transaction"), + ("agent-a", ("--next-agent-todo", "Fresh successor work"), "invalid_todo_completion_transaction"), + ]: + returncode, rejected = run_json_cli_result(*prefix, "--agent-id", actor, *extra, registry_path=registry) + assert returncode == 1 and rejected["error_code"] == code, rejected + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == before + assert state.read_bytes() == source_before + repair = (*prefix, "--agent-id", "agent-a", *(("--claimed-by", "agent-a") if claim_flag else ())) + preview = run_json_cli(*repair, "--dry-run", registry_path=registry) + assert preview["dry_run"] is True, preview + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == before + assert state.read_bytes() == source_before + applied = run_json_cli(*repair, registry_path=registry) + assert applied["changed"] is True and applied["completion_policy"] is None, applied + original = next(row for row in before["todos"] if row["todo_id"] == "todo_target") + current = records(registry)["todo_target"] + assert {k: v for k, v in current.items() if k != "content_revision"} == { + **{k: v for k, v in original.items() if k != "content_revision"}, "completion_continuation": "active_goal", + } + run_json_cli("todo", "add", "--goal-id", "goal-a", "--role", "agent", + "--text", "A later independent operation", registry_path=registry) + later = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") + replay = run_json_cli(*repair, registry_path=registry) + assert replay["changed"] is False, replay + after = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") + assert after["todos"] == later["todos"] and after["provider_revision"] == later["provider_revision"] + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("command", ["complete", "supersede"]) def test_cli_same_title_terminal_successor_preserves_peers_and_replay(tmp_path, monkeypatch, provider, command): diff --git a/tests/control_plane/test_refresh_state_replan_gate.py b/tests/control_plane/test_refresh_state_replan_gate.py index 4c318bd260..66890149d6 100644 --- a/tests/control_plane/test_refresh_state_replan_gate.py +++ b/tests/control_plane/test_refresh_state_replan_gate.py @@ -1216,6 +1216,7 @@ def test_completed_todo_identity_repair_preserves_settlement_fence( state.write_text( "---\n" f"goal_id: {GOAL_ID}\n" + "handoff_mode: soft_claim\n" "updated_at: 2026-08-22T00:00:00+00:00\n" "---\n\n" "## User Todo / Owner Review Reading Queue\n\n" diff --git a/tests/control_plane/test_retained_team_plan_receipt.py b/tests/control_plane/test_retained_team_plan_receipt.py new file mode 100644 index 0000000000..fa41ed520b --- /dev/null +++ b/tests/control_plane/test_retained_team_plan_receipt.py @@ -0,0 +1,45 @@ +"""Recover a fixed 1.x receipt through the current public adapter.""" +import json +from pathlib import Path + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.work_items.team_plan_adapter import apply_team_plan +from loopx.todos import add_goal_todo, list_goal_todos + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_original_1x_team_receipt_survives_canonical_later_writes(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + history = json.loads((Path(__file__).parent / "fixtures/team_plan_1x.json").read_text()) + assert history["source_revision"] == "60d833b13c85135587b725013b247e827663ffb9" + runtime, state, registry = tmp_path / "runtime", tmp_path / "state.md", tmp_path / "registry.json" + state.write_text(history["source"]) + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": "goal-a", "repo": str(tmp_path), "state_file": "state.md", + "coordination": {"registered_agents": ["alpha"], "agent_model": "peer_v1"}}]})) + + def replay(**overrides): + return apply_team_plan(registry_path=registry, goal_id="goal-a", + agent_id=None, proposal=history["plan"], expected_state_fingerprint=history["basis"], **overrides) + + original = state.read_bytes() + assert replay()["action"] == "reused" + assert state.read_bytes() == original + rows = list_goal_todos(registry_path=registry, goal_id="goal-a")["todos"] + initialize_canonical_authority(runtime, "goal-a", + build_todo_runtime_shadow_projection(goal_id="goal-a", todos=rows, handoff_mode="soft_claim"), + state_path=state, provider=provider) + add_goal_todo(registry_path=registry, goal_id="goal-a", role="agent", text="Later accepted work", agent_id="alpha") + before, display = list_goal_todos(registry_path=registry, goal_id="goal-a"), state.read_bytes() + recovered = replay() + assert recovered["action"] == "reused" + assert recovered["lane_todo_ids"] == history["result"]["lane_todo_ids"] + with pytest.raises(Exception, match="identity mismatch"): + apply_team_plan(registry_path=registry, goal_id="goal-a", agent_id=None, + proposal={**history["plan"], "objective": "Changed original intent"}, + expected_state_fingerprint=history["basis"]) + assert list_goal_todos(registry_path=registry, goal_id="goal-a") == before + assert state.read_bytes() == display diff --git a/tests/control_plane/test_task_lease.py b/tests/control_plane/test_task_lease.py index 4125e8aa87..06fbb99ff1 100644 --- a/tests/control_plane/test_task_lease.py +++ b/tests/control_plane/test_task_lease.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -11,12 +12,10 @@ from loopx.control_plane.work_items.task_lease import ( MAX_TASK_LEASE_TTL_SECONDS, TaskLeaseError, - assert_expected_version, normalize_idempotency_key, normalize_ttl_seconds, release_task_lease, renew_task_lease, - task_lease_owner_constraint, write_scopes_overlap, ) @@ -63,57 +62,6 @@ def test_write_scopes_overlap( assert write_scopes_overlap(left, right) is expected -@pytest.mark.parametrize( - ("todo", "owner", "registered_agents", "reason"), - [ - (None, "agent-a", ["agent-a"], "todo_not_found"), - ({"status": "done"}, "agent-a", ["agent-a"], "todo_not_open"), - ({"status": "open"}, "", ["agent-a"], "invalid_owner"), - ({"status": "open"}, "agent-b", ["agent-a"], "owner_not_registered"), - ( - {"status": "open", "excluded_agents": ["agent-a"]}, - "agent-a", - ["agent-a"], - "owner_excluded_from_todo", - ), - ( - {"status": "open", "claimed_by": "agent-b"}, - "agent-a", - ["agent-a", "agent-b"], - "owner_conflicts_with_claim", - ), - ], -) -def test_task_lease_owner_constraint_rejects_ineligible_owner( - todo: dict[str, Any] | None, - owner: str, - registered_agents: list[str], - reason: str, -) -> None: - constraint = task_lease_owner_constraint( - todo, - owner=owner, - registered_agents=registered_agents, - ) - - assert constraint["effective"] is False - assert constraint["reason"] == reason - - -def test_task_lease_owner_constraint_accepts_matching_claim() -> None: - constraint = task_lease_owner_constraint( - { - "status": "open", - "claimed_by": "agent-a", - "excluded_agents": ["agent-b"], - }, - owner="agent-a", - registered_agents=["agent-a", "agent-b"], - ) - - assert constraint == {"effective": True} - - @pytest.mark.parametrize("ttl", [0, -1, MAX_TASK_LEASE_TTL_SECONDS + 1]) def test_normalize_ttl_seconds_rejects_out_of_range(ttl: int) -> None: with pytest.raises(TaskLeaseError, match="ttl seconds") as error: @@ -130,43 +78,40 @@ def test_normalize_idempotency_key_rejects_non_token(key: str) -> None: assert error.value.code == "invalid_idempotency_key" -def test_expected_version_is_compare_and_swap_guard() -> None: - with pytest.raises(TaskLeaseError) as error: - assert_expected_version({"version": 3}, 2) +@pytest.fixture +def retained_lease(tmp_path: Path): + # Fixed original 1.x bytes; the current runtime must read/release these + # without relying on a surviving Python lease producer. + original = json.loads((Path(__file__).parent / "fixtures" / "retained_lease_receipts_1x.json").read_text())["scenarios"]["original"] + relative, lease = next(iter(original["files"].items())) + runtime = tmp_path / "runtime" + path = runtime / relative + path.parent.mkdir(parents=True) + path.write_text(json.dumps(lease)) + request = {key: original["request"][key] for key in ("goal_id", "todo_id", "owner", "idempotency_key")} + return path, lease, {**request, "runtime_root": runtime, "expected_version": lease["version"]} - assert error.value.code == "version_mismatch" - assert error.value.payload == {"expected_version": 2, "actual_version": 3} - - -def test_lease_epoch_migrates_legacy_records_and_rejects_corruption() -> None: - assert task_lease.lease_epoch(None) == 0 - assert ( - task_lease.lease_epoch({"schema_version": "task_lease_v0", "version": 7}) == 1 - ) - - for raw_epoch in (0, -1, "not-an-epoch"): - with pytest.raises(TaskLeaseError) as error: - task_lease.lease_epoch({"lease_epoch": raw_epoch}) - assert error.value.code == "corrupt_lease" +def test_retained_release_enforces_cas_without_rewriting_history(retained_lease) -> None: + path, lease, request = retained_lease + before = path.read_bytes() + with pytest.raises(TaskLeaseError) as error: + release_task_lease(**{**request, "expected_version": lease["version"] + 1}) + assert error.value.code == "version_mismatch" + assert path.read_bytes() == before -def test_bool_disguised_lease_integers_fail_closed() -> None: - """bool is an int subclass: a JSON ``true`` must not become epoch/version/ - TTL ``1`` silently. The shared Stage 2 head codec already rejects bool - (head._is_count); the local record codec is the migration source for - ``last_lease_epoch`` seeding, so the same typed corrupt_lease applies - before any local-to-shared migration.""" - for raw in (True, False): - with pytest.raises(TaskLeaseError) as error: - task_lease.lease_epoch({"lease_epoch": raw}) - assert error.value.code == "corrupt_lease" - with pytest.raises(TaskLeaseError) as error: - task_lease.lease_version({"version": raw}) - assert error.value.code == "corrupt_lease" - with pytest.raises(TaskLeaseError) as error: - task_lease.lease_acquire_ttl_seconds({"acquire_ttl_seconds": raw}) - assert error.value.code == "corrupt_lease" +@pytest.mark.parametrize(("field", "raw"), [ + ("lease_epoch", value) for value in [0, -1, "not-an-epoch", True, False] +] + [("version", value) for value in [-1, "not-a-version", True, False]]) +def test_retained_release_rejects_corrupt_generation_without_writes(retained_lease, field, raw) -> None: + path, lease, request = retained_lease + path.write_text(json.dumps({**lease, field: raw})) + before = path.read_bytes() + with pytest.raises(TaskLeaseError) as error: + release_task_lease(**request) + assert error.value.code == "corrupt_lease" + assert path.read_bytes() == before def test_lifecycle_mutation_fails_closed_without_registry( @@ -181,24 +126,24 @@ def test_lifecycle_mutation_fails_closed_without_registry( goal_id="goal-a", todo_id="todo_missing_registry", ) - task_lease.write_lease( - lease_path, - task_lease.build_lease( - goal_id="goal-a", - todo_id="todo_missing_registry", - owner="agent-a", - idempotency_key="turn-1", - write_scopes=[], - acquire_ttl_seconds=120, - version=1, - lease_epoch=1, - acquired_at=now.isoformat().replace("+00:00", "Z"), - updated_at=now.isoformat().replace("+00:00", "Z"), - expires_at=(now + timedelta(seconds=120)) + lease_path.parent.mkdir(parents=True) + lease_path.write_text(json.dumps({ + "schema_version": "task_lease_v0", + "status": "active", + "goal_id": "goal-a", + "todo_id": "todo_missing_registry", + "owner": "agent-a", + "idempotency_key": "turn-1", + "write_scopes": [], + "acquire_ttl_seconds": 120, + "version": 1, + "lease_epoch": 1, + "acquired_at": now.isoformat().replace("+00:00", "Z"), + "updated_at": now.isoformat().replace("+00:00", "Z"), + "expires_at": (now + timedelta(seconds=120)) .isoformat() .replace("+00:00", "Z"), - ), - ) + })) before = task_lease.read_lease(lease_path) with pytest.raises(TaskLeaseError) as error: @@ -228,9 +173,8 @@ def test_legacy_generation_and_unrelated_ttl_do_not_block_release( goal_id="goal-a", todo_id="todo_legacy", ) - task_lease.write_lease( - lease_path, - { + lease_path.parent.mkdir(parents=True) + lease_path.write_text(json.dumps({ "schema_version": "task_lease_v0", "goal_id": "goal-a", "todo_id": "todo_legacy", @@ -245,8 +189,7 @@ def test_legacy_generation_and_unrelated_ttl_do_not_block_release( "expires_at": (now + timedelta(seconds=120)) .isoformat() .replace("+00:00", "Z"), - }, - ) + })) released = release_task_lease( runtime_root=runtime_root, diff --git a/tests/control_plane/test_todo_mutation_authority.py b/tests/control_plane/test_todo_mutation_authority.py index 011047b77b..3c1a169667 100644 --- a/tests/control_plane/test_todo_mutation_authority.py +++ b/tests/control_plane/test_todo_mutation_authority.py @@ -10,12 +10,18 @@ build_todo_runtime_shadow_projection, ) from loopx.control_plane.coordination.local_authority import ( - LocalCoordinationAuthorityRejection, + LocalCoordinationAuthorityRejection, LocalCoordinationAuthorityUnavailable, ) from loopx.control_plane.scheduler.monitor_poll_writeback import ( write_monitor_poll_todo_state, ) from loopx.status import parse_active_state_todos +import loopx.control_plane.todos.provider_terminal_lifecycle as terminal_provider +from loopx.control_plane.work_items.task_lease import ( + acquire_task_lease, inspect_task_lease, +) +from loopx.todos import list_goal_todos, archive_completed_todos +from canonical_authority_fixture import isolate_sqlite_runtime from loopx.todos import ( add_goal_todo, complete_goal_todo, @@ -35,6 +41,7 @@ def _write_fixture( multi_agent: bool = True, lifecycle_authority: list[dict] | None = None, handoff_mode: str = "soft_claim", + provider: str = "file", ) -> tuple[Path, Path]: repo = tmp_path / "repo" repo.mkdir() @@ -94,6 +101,7 @@ def _write_fixture( handoff_mode=handoff_mode, ), state_path=state, + provider=provider, ) return registry, state @@ -619,3 +627,278 @@ def test_delegated_orchestrator_can_complete_claimed_todo_with_reason( assert authority["authority_mode"] == "delegated_orchestration_override" assert authority["code"] == "terminal_transition" assert _agent_todo(state, todo["todo_id"])["status"] == "done" + + +def _canonical_todo(registry: Path, todo_id: str) -> dict: + return list_goal_todos(registry_path=registry, goal_id=GOAL_ID, todo_id=todo_id)["todo"] + + +def _canonical_snapshot(registry: Path, state: Path) -> tuple: + result = list_goal_todos(registry_path=registry, goal_id=GOAL_ID) + return result["authority_read"]["provider_revision"], result["todos"], state.read_bytes() + + +def _canonical_lease(registry: Path, tmp_path: Path, todo_id: str) -> dict | None: + result = inspect_task_lease( + registry_path=registry, runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, todo_id=todo_id, + ) + assert result["source_authority"] in {"file_v0", "sqlite_v0"} + assert result["legacy_fallback_used"] is False + return result.get("lease") + + +def _lease_path(tmp_path: Path, todo_id: str) -> Path: + return tmp_path / "runtime" / "goals" / GOAL_ID / "task-leases" / f"{todo_id}.json" + + +@pytest.mark.parametrize("status", ["deferred", "blocked", "done"]) +def test_canonical_claim_rejects_nonopen_status_atomically( + tmp_path: Path, canonical_workspace, status: str, +) -> None: + registry, state = canonical_workspace(tmp_path) + if status == "deferred": + todo = add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Wait for external capacity before claim.", status="deferred", + resume_when="capacity_available:network", + ) + else: + todo = _add_agent_todo(registry) + if status == "done": + complete_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], + agent_id=AUTHOR_AGENT, evidence="finished before the contested claim", + ) + else: + update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], + agent_id=AUTHOR_AGENT, status=status, + ) + before = _canonical_snapshot(registry, state) + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], + claim_only=True, claimed_by=AUTHOR_AGENT, agent_id=AUTHOR_AGENT, + ) + assert error.value.code == "todo_not_open" + assert error.value.payload["todo_status"] == status + assert _canonical_snapshot(registry, state) == before + assert _canonical_lease(registry, tmp_path, todo["todo_id"]) is None + + +def test_canonical_claim_rejects_user_todo_atomically(tmp_path: Path, canonical_workspace) -> None: + registry, state = canonical_workspace(tmp_path) + todo = add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="user", + text="Owner reviews the delivered change.", task_class="user_action", + bound_agent=AUTHOR_AGENT, + ) + before = _canonical_snapshot(registry, state) + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], + claim_only=True, claimed_by=AUTHOR_AGENT, agent_id=AUTHOR_AGENT, + ) + assert error.value.code == "todo_not_agent" + assert _canonical_snapshot(registry, state) == before + assert _canonical_todo(registry, todo["todo_id"])["bound_agent"] == AUTHOR_AGENT + + +def test_canonical_claim_cannot_reactivate_archived_work(tmp_path: Path, canonical_workspace) -> None: + registry, state = canonical_workspace(tmp_path) + todo = _add_agent_todo(registry) + complete_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], + agent_id=AUTHOR_AGENT, evidence="finished before archival", + ) + archive_completed_todos( + registry_path=registry, goal_id=GOAL_ID, max_active_done=0, dry_run=False, + ) + assert _canonical_todo(registry, todo["todo_id"])["archive_state"] == "archive" + before = _canonical_snapshot(registry, state) + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], + claim_only=True, claimed_by=AUTHOR_AGENT, agent_id=AUTHOR_AGENT, + ) + # Status eligibility precedes archive eligibility in the shared claim owner. + assert error.value.code == "todo_not_open" + assert _canonical_snapshot(registry, state) == before + + +def test_canonical_claim_rejects_role_mismatch_atomically(tmp_path: Path, canonical_workspace) -> None: + registry, state = canonical_workspace(tmp_path) + todo = _add_agent_todo(registry, claimed_by=None) + before = _canonical_snapshot(registry, state) + with pytest.raises(LocalCoordinationAuthorityRejection) as error: + update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], role="user", + claim_only=True, claimed_by=AUTHOR_AGENT, agent_id=AUTHOR_AGENT, + ) + assert error.value.code == "todo_role_mismatch" + assert error.value.payload["requested_role"] == "user" + assert error.value.payload["todo_role"] == "agent" + assert _canonical_snapshot(registry, state) == before + + +def test_completion_and_lease_release_recover_after_response_loss( + tmp_path: Path, canonical_workspace, monkeypatch: pytest.MonkeyPatch, +) -> None: + # Canonical completion releases its lease in the same commit; there is no + # independent post-commit release call to fail. Lose the real response instead. + registry, state = canonical_workspace(tmp_path, multi_agent=False, handoff_mode="hard_lease") + todo = _add_agent_todo(registry) + lease_key = "response-loss-instance" + acquire_task_lease( + registry_path=registry, runtime_root=tmp_path / "runtime", goal_id=GOAL_ID, + todo_id=todo["todo_id"], owner=AUTHOR_AGENT, idempotency_key=lease_key, ttl_seconds=600, + ) + request = dict( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], agent_id=AUTHOR_AGENT, + task_lease_idempotency_key=lease_key, task_lease_expected_version=1, + completion_turn_key="response-loss-turn", evidence="Original completion committed once.", + no_followup=True, + ) + native_effect = terminal_provider.effect_runtime_result + committed = [] + def lose_response(method, payload, **kwargs): + result = native_effect(method, payload, **kwargs) + if method == "coordination.local_authority.todo_terminal" and not committed: + assert result["status"] == "applied" + committed.append(result) + raise OSError("response lost after real canonical commit") + return result + with monkeypatch.context() as patch: + patch.setattr(terminal_provider, "effect_runtime_result", lose_response) + with pytest.raises(OSError, match="response lost"): + complete_goal_todo(**request) + assert len(committed) == 1 + assert _canonical_todo(registry, todo["todo_id"])["status"] == "done" + lease_after_commit = _canonical_lease(registry, tmp_path, todo["todo_id"]) + assert lease_after_commit["status"] == "released" + replay = complete_goal_todo(**request) + assert replay["ok"] is True and replay["idempotent_replay"] is True + assert _canonical_lease(registry, tmp_path, todo["todo_id"]) == lease_after_commit + after_recovery = _canonical_snapshot(registry, state) + assert complete_goal_todo(**request)["idempotent_replay"] is True + assert _canonical_snapshot(registry, state) == after_recovery + + +def test_completed_todo_rejects_metadata_reopen_and_new_execution( + tmp_path: Path, canonical_workspace, +) -> None: + registry, state = canonical_workspace(tmp_path, multi_agent=False, handoff_mode="hard_lease") + todo = _add_agent_todo(registry) + acquire_task_lease( + registry_path=registry, runtime_root=tmp_path / "runtime", goal_id=GOAL_ID, + todo_id=todo["todo_id"], owner=AUTHOR_AGENT, idempotency_key="first-instance", ttl_seconds=600, + ) + complete_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], agent_id=AUTHOR_AGENT, + task_lease_idempotency_key="first-instance", task_lease_expected_version=1, + completion_turn_key="first-turn", evidence="First completion releases its lease.", + ) + released = _canonical_lease(registry, tmp_path, todo["todo_id"]) + assert released["status"] == "released" + before = _canonical_snapshot(registry, state) + # A completed ordinary Todo has no metadata-reopen contract. Keep terminal + # history intact rather than silently resurrecting a retired execution. + with pytest.raises(LocalCoordinationAuthorityRejection) as rejected: + update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], agent_id=AUTHOR_AGENT, + status="open", note="Metadata cannot reopen a completed execution.", + ) + assert rejected.value.code == "unsupported_todo_update_target" + assert _canonical_snapshot(registry, state) == before + assert _canonical_lease(registry, tmp_path, todo["todo_id"]) == released + with pytest.raises((ValueError, LocalCoordinationAuthorityRejection)): + complete_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], agent_id=AUTHOR_AGENT, + task_lease_idempotency_key="first-instance", task_lease_expected_version=1, + completion_turn_key="second-turn", evidence="Released proof cannot authorize new execution.", + ) + assert _canonical_snapshot(registry, state) == before + assert _canonical_lease(registry, tmp_path, todo["todo_id"]) == released + + +def test_invalid_completion_keeps_existing_lease_and_state( + tmp_path: Path, canonical_workspace, +) -> None: + registry, state = canonical_workspace(tmp_path, multi_agent=False, handoff_mode="hard_lease") + todo = _add_agent_todo(registry) + lease_key = "post-fence-failure-instance" + acquire_task_lease( + registry_path=registry, + runtime_root=tmp_path / "runtime", + goal_id=GOAL_ID, + todo_id=todo["todo_id"], + owner=AUTHOR_AGENT, + idempotency_key=lease_key, + ttl_seconds=600, + ) + lease_before = _canonical_lease(registry, tmp_path, todo["todo_id"]) + state_before = _canonical_snapshot(registry, state) + + with pytest.raises(ValueError, match="successor_todo_ids"): + complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo["todo_id"], + agent_id=AUTHOR_AGENT, + task_lease_idempotency_key=lease_key, + task_lease_expected_version=1, + successor_todo_ids=["!!not-a-todo-id!!"], + evidence="invalid successor must leave state and lease unchanged", + no_followup=True, + ) + + assert _canonical_lease(registry, tmp_path, todo["todo_id"]) == lease_before + assert lease_before["status"] == "active" + assert _canonical_snapshot(registry, state) == state_before + + +@pytest.mark.parametrize("command", ["update", "complete"]) +def test_canonical_single_agent_owned_todo_requires_explicit_actor( + tmp_path: Path, canonical_workspace, command: str, +) -> None: + registry, state = canonical_workspace(tmp_path, multi_agent=False) + todo = _add_agent_todo(registry) + before = _canonical_snapshot(registry, state) + with pytest.raises(LocalCoordinationAuthorityRejection) as rejected: + if command == "update": + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, + todo_id=todo["todo_id"], note="Actorless owned mutation is forbidden") + else: + complete_goal_todo(registry_path=registry, goal_id=GOAL_ID, + todo_id=todo["todo_id"], evidence="Actorless owned mutation is forbidden") + assert rejected.value.code == "actor_required" + assert _canonical_snapshot(registry, state) == before + + +def test_canonical_update_revision_conflict_is_typed_without_actor_mutation( + tmp_path: Path, canonical_workspace, +) -> None: + registry, state = canonical_workspace(tmp_path) + todo = _add_agent_todo(registry) + before = _canonical_snapshot(registry, state) + with pytest.raises(LocalCoordinationAuthorityUnavailable) as rejected: + update_goal_todo( + registry_path=registry, goal_id=GOAL_ID, todo_id=todo["todo_id"], + agent_id=AUTHOR_AGENT, note="A stale writer cannot commit", + update_expected_provider_revision="stale-revision", + ) + assert rejected.value.code == "provider_revision_mismatch" + assert isinstance(rejected.value, LocalCoordinationAuthorityRejection) + assert rejected.value.payload["failure_kind"] == "decision_rejection" + assert _canonical_snapshot(registry, state) == before + + +@pytest.fixture(params=["file", "sqlite"]) +def canonical_workspace(request, tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + + def create(root, **kwargs): + return _write_fixture(root, provider=request.param, **kwargs) + + return create diff --git a/tests/control_plane_ts/authority_projection_fixture.ts b/tests/control_plane_ts/authority_projection_fixture.ts index 38e1851cd6..23bc6fe558 100644 --- a/tests/control_plane_ts/authority_projection_fixture.ts +++ b/tests/control_plane_ts/authority_projection_fixture.ts @@ -76,6 +76,9 @@ export function authorityProjectionFixture( const normalizedLeases = leases.map(lease => structuredClone(lease)).sort((left, right) => authorityUnicodeCompare(String(left.todo_id), String(right.todo_id))); return canonicalAuthorityObject({ + // Normal fixture work uses a current policy; legacy wire format is a + // separate axis. Migration/history tests must explicitly request legacy mode. + handoff_mode: "soft_claim", ...structuredClone(extras), goal_id: goalId, todos: normalizedTodos, diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index 4b6b752a0a..d3a0c64a1a 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -393,6 +393,78 @@ export function registerAuthorityStoreConformance( }); registerCommandObservationConformance(providerName, factory); registerClaimAcquisitionProofConformance(providerName, factory); + test(`${providerName} imported legacy policy requires upgrade before new execution`, async t => { + const {store} = await factory(t); + const todo = {todo_id:"todo_work",role:"agent",status:"open",done:false,text:"Imported work", + archive_state:"active",claimed_by:null,task_class:"advancement_task"}; + const lease = {schema_version:"task_lease_v0",goal_id:"goal-a",todo_id:"todo_work",owner:"agent-a", + idempotency_key:"old",version:1,lease_epoch:1,status:"active",write_scopes:[],acquire_ttl_seconds:600, + acquired_at:"2026-09-01T03:00:00Z",updated_at:"2026-09-01T03:00:00Z",expires_at:"2026-09-01T03:10:00Z"}; + assert.equal((await store.commitAuthority({expected_provider_revision:null, operation_id:"seed", + next_projection:authorityProjectionFixture("goal-a",[todo],[lease],"native",{handoff_mode:"legacy"}), + events:[],receipts:[]})).status,"applied"); + const command = {goal_id:"goal-a",todo_id:"todo_work",owner:"agent-a",idempotency_key:"old", + expected_version:1,ttl_seconds:600,write_scopes:[],registered_agents:["agent-a","agent-b"],now:new Date("2026-09-01T03:01:00Z")}; + const before = await store.loadAuthority(); + for (const operation of ["renew","transfer"] as const) { + const refused = await executeCanonicalTaskLeaseLifecycle(store,{...command,operation, + ...(operation === "transfer" ? {new_owner:"agent-b",new_idempotency_key:"new"} : {})}); + assert.equal(refused.reason_code,"handoff_mode_retired"); + assert.match(String(refused.reason),/handoff-mode plan-migration/); + assert.deepEqual(await store.loadAuthority(),before); + } + assert.equal((await executeCanonicalTaskLeaseLifecycle(store,{...command,operation:"release",ttl_seconds:null})).status,"applied"); + const released = await store.loadAuthority(); + const denied = await executeCanonicalTaskLeaseAcquire(store,{...command,idempotency_key:"new",expected_version:1}); + assert.equal(denied.reason_code,"handoff_mode_retired"); + assert.deepEqual(await store.loadAuthority(),released); + }); + + for (const operation of ["renew", "transfer"] as const) test(`${providerName} retired policy cannot replay acquisition or ${operation} as current proof`, async t => { + const {store} = await factory(t); + const todo = {todo_id: "todo_work", role: "agent", status: "open", done: false, + text: "Imported work", archive_state: "active", claimed_by: null, task_class: "advancement_task"}; + assert.equal((await store.commitAuthority({expected_provider_revision: null, operation_id: "seed", + next_projection: authorityProjectionFixture("goal-a", [todo], [], "native", {handoff_mode: "hard_lease"}), + events: [], receipts: []})).status, "applied"); + const command = {goal_id: "goal-a", todo_id: "todo_work", owner: "agent-a", idempotency_key: "original-execution", + expected_version: 0, ttl_seconds: 600, write_scopes: [], registered_agents: ["agent-a", "agent-b"], + now: new Date("2026-09-01T03:01:00Z")}; + const accepted = await executeCanonicalTaskLeaseAcquire(store, command); + assert.equal(accepted.status, "applied", JSON.stringify(accepted)); + const acquisitionOperation = String(accepted.operation_id); + const original = await store.readReceipt(acquisitionOperation); + const maintenance = {...command, operation, expected_version: 1, + ...(operation === "transfer" ? {new_owner: "agent-b", new_idempotency_key: "receiver-execution"} : {})}; + const maintained = await executeCanonicalTaskLeaseLifecycle(store, maintenance); + assert.equal(maintained.status, "applied", JSON.stringify(maintained)); + const originalMaintenance = await store.readReceipt(String(maintained.operation_id)); + const current = await store.loadAuthority(); + if (current.status !== "loaded") throw new Error("fixture unavailable"); + assert.equal((await store.commitAuthority({expected_provider_revision: current.provider_revision, + operation_id: "imported-policy", next_projection: {...current.head, handoff_mode: "legacy"}, + events: [], receipts: []})).status, "applied"); + const before = await store.loadAuthority(); + for (const replay of [command, {...command, idempotency_key: "fresh-execution", expected_version: 2}]) { + const refused = await executeCanonicalTaskLeaseAcquire(store, replay); + assert.equal(refused.status, "failed", JSON.stringify(refused)); + assert.equal(refused.reason_code, "handoff_mode_retired"); + assert.equal(refused.lease, undefined, "historical acceptance is not a current execution grant"); + assert.deepEqual(await store.readReceipt(acquisitionOperation), original); + assert.deepEqual(await store.loadAuthority(), before); + } + const replayedMaintenance = await executeCanonicalTaskLeaseLifecycle(store, maintenance); + assert.equal(replayedMaintenance.reason_code, "handoff_mode_retired"); + assert.equal(replayedMaintenance.lease, undefined); + assert.deepEqual(await store.readReceipt(String(maintained.operation_id)), originalMaintenance); + assert.deepEqual(await store.loadAuthority(), before); + const released = await executeCanonicalTaskLeaseLifecycle(store, {...command, + operation: "release", expected_version: 2, ttl_seconds: null, + ...(operation === "transfer" ? {owner: "agent-b", idempotency_key: "receiver-execution"} : {})}); + assert.equal(released.status, "applied", JSON.stringify(released)); + assert.deepEqual(await store.readReceipt(acquisitionOperation), original); + }); + registerClaimContentionConformance(providerName, factory); registerAuthorityScanConformance(providerName, factory); registerOwnershipObservationConformance(providerName, factory); @@ -451,14 +523,14 @@ export function registerAuthorityStoreConformance( assert.equal((await executeCoordinationTodoArchiveCompleted(store, request)).status, "replayed"); }); - for (const native of [false, true]) test(`${providerName} conformance: lease-free legacy-mode Monitor observation and successor (${native ? "native" : "legacy"})`, async (t) => { + for (const native of [false, true]) test(`${providerName} conformance: lease-free soft-claim Monitor observation and successor (${native ? "native" : "legacy"})`, async (t) => { const {store, contender} = await factory(t); const goal = "goal-monitor"; const fixture = productionScaleCoordinationFixture(goal, native ? "native" : "legacy"); const projection = structuredClone(fixture.projection); - // A lease-free observation is legal in legacy mode. Hard mode now requires + // A lease-free observation is legal in soft_claim. Hard mode requires // current execution proof; its positive/negative cases have their own fixture. - projection.handoff_mode = "legacy"; + projection.handoff_mode = "soft_claim"; const records = projection.todos as Record[]; const monitor = records.find(todo => todo.task_class === "continuous_monitor" && todo.status !== "done" && !(projection.leases as Record[]).some(lease => lease.todo_id === todo.todo_id)); @@ -1030,20 +1102,24 @@ export function registerAuthorityStoreConformance( assert.deepEqual(await store.loadAuthority(), after); }); - test(`${providerName} conformance: pre-upgrade terminal receipt recovers before current authority access`, async t => { + for (const delegated of [false, true]) test(`${providerName} conformance: original terminal receipt recovers before current authority access (delegated=${delegated})`, async t => { const {store} = await factory(t); const goalId = "goal-historical-terminal", turnKey = `${goalId}:agent-a:todo-terminal:original-turn`; const digest = createHash("sha256").update( `loopx-provider-terminal-operation-v0\0complete\0${goalId}\0todo-terminal\0${turnKey}`, ).digest("hex").slice(0, 32); + const projection = todoTerminalProjection(goalId); + // Delegate an idle soft-claim task; active lease fencing is an independent gate. + if (delegated) { projection.handoff_mode = "soft_claim"; projection.leases = []; } assert.equal((await store.commitAuthority({expected_provider_revision: null, operation_id: "seed-historical-terminal", - next_projection: todoTerminalProjection(goalId), events: [], receipts: []})).status, "applied"); + next_projection: projection, events: [], receipts: []})).status, "applied"); 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, + command: "complete" as const, actor_agent_id: delegated ? "agent-b" : "agent-a", registered_agents: ["agent-a", "agent-b"], + lifecycle_grants: delegated ? [{agent_id: "agent-b", actions: ["complete"], requires_reason: true}] : [], + authority_reason: delegated ? "Verified the original worker result" : null, decision_outcome: null, operation_identity: {kind: "explicit" as const, operation_id: `todo-terminal:${digest}`}, - lease_idempotency_key: "terminal-lease", lease_expected_version: 1, allow_user_gate_auto_acquire: false, + lease_idempotency_key: delegated ? null : "terminal-lease", lease_expected_version: delegated ? null : 1, allow_user_gate_auto_acquire: false, requested_no_followup: true, requested_completion_turn_key: turnKey, requested_completion_identity_source: "turn_settlement" as const, linked_successor_todo_ids: [], successor_intents: [], note: null, evidence: "Declared check passed", @@ -1054,6 +1130,16 @@ export function registerAuthorityStoreConformance( }; const applied = await executeCoordinationTodoTerminalLifecycle(store, request); assert.equal(applied.status, "applied", JSON.stringify(applied)); + const authority = applied.terminal_decision as JsonObject; + if (delegated) { + assert.equal(authority.authority_mode, "delegated_orchestration_override"); + assert.equal(authority.authority_reason, request.authority_reason); + const original = applied.original_receipt as JsonObject; + assert.equal(((original.result as JsonObject).terminal_decision as JsonObject).authority_reason, + request.authority_reason); + } else { + assert.equal(Object.hasOwn(authority, "authority_reason"), false); + } const before = await store.loadAuthority(); const historicalOnly = new Proxy(store, {get(target, key) { if (key === "loadAuthority" || key === "commitAuthority") return async () => { @@ -1067,6 +1153,12 @@ export function registerAuthorityStoreConformance( }, async () => {throw new Error("historical recovery precedes current source authorization");}); assert.equal(recovered.status, "replayed", JSON.stringify(recovered)); assert.deepEqual(recovered.original_receipt, applied.original_receipt); + assert.deepEqual(recovered.terminal_decision, applied.terminal_decision); + if (delegated) { + const changed = await executeCoordinationTodoTerminalLifecycle(historicalOnly, + {...request, authority_reason: "Replacement justification"}); + assert.equal(changed.reason_code, "coordination_operation_identity_mismatch"); + } assert.deepEqual(await store.loadAuthority(), before); assert.equal((await store.readReceipt(`todo-terminal-closeout:${digest}`)).status, "missing"); }); diff --git a/tests/control_plane_ts/command_observation_scenarios.ts b/tests/control_plane_ts/command_observation_scenarios.ts index ffac588d5f..475722bfc8 100644 --- a/tests/control_plane_ts/command_observation_scenarios.ts +++ b/tests/control_plane_ts/command_observation_scenarios.ts @@ -29,7 +29,7 @@ export async function commandObservationScenario(kind: CommandObservationCase, s task_class: "advancement_task", action_kind: "implement", status: "open", done: false, archive_state: "active", required_write_scopes: ["command-observation/**"]}; let projection = authorityProjectionFixture(goal, [...f.projection.todos as JsonObject[], todo], - f.projection.leases as JsonObject[], schema, {handoff_mode: "legacy"}); + f.projection.leases as JsonObject[], schema, {handoff_mode: "soft_claim"}); const monitor = productionScaleLeasedMonitorFixture(goal, schema); const acquire = productionScaleLeaseAcquisitionFixture(goal, schema); const lifecycle = productionScaleLeaseLifecycleFixture(goal, schema); diff --git a/tests/control_plane_ts/coordination_command_fixture.ts b/tests/control_plane_ts/coordination_command_fixture.ts index 3dd3967c87..77322a59c4 100644 --- a/tests/control_plane_ts/coordination_command_fixture.ts +++ b/tests/control_plane_ts/coordination_command_fixture.ts @@ -21,7 +21,7 @@ export async function coordinationCommandFixture(store: AuthorityStore, command: const projection = fixture.projection; // This recovery arm deliberately has no Monitor execution lease. The leased // arm separately proves current proof and historical replay in hard mode. - if (command === "monitor") projection.handoff_mode = "legacy"; + if (command === "monitor") projection.handoff_mode = "soft_claim"; const todos = projection.todos as JsonObject[]; const leased = new Set((projection.leases as JsonObject[]).map(row => row.todo_id)); const claimTodo = todos.find(row => row.role === "agent" && row.status === "open" && diff --git a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts index 7393a5773b..0af34ead17 100644 --- a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts +++ b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts @@ -163,7 +163,7 @@ for (const provider of ["file", "sqlite"] as const) { for (const provider of ["file", "sqlite"] as const) { for (const mode of ["legacy", "hard_lease"] as const) { - test(`${provider}/${mode}: owner deferral releases its grant atomically and can resume`, + test(`${provider}/${mode}: ${mode === "legacy" ? "retired policy rejects deferral with retained proof" : "owner deferral releases its grant atomically and can resume"}`, {skip: provider === "sqlite" && sqliteSkip}, async () => { const store = await seeded(provider, {status: "open", done: false}, oldLease("active", "2026-09-06T00:00:00Z")); @@ -173,6 +173,15 @@ for (const provider of ["file", "sqlite"] as const) { const input = {...resume("defer-owner"), lease_idempotency_key: "old-execution", lease_expected_version: 3, planning_intent: {status: "deferred", resume_when: "todo_done:todo_dependency", reason: "Dependency is pending"}}; const before = await read(store); + if (mode === "legacy") { + for (const dry_run of [true, false]) { + const rejected = await executeCoordinationTodoUpdate(store, {...input, dry_run}); + assert.equal(rejected.reason_code, "handoff_mode_retired"); + assert.deepEqual(await read(store), before); + assert.equal((await store.readReceipt(input.operation_id)).status, "missing"); + } + return; + } for (const change of [{lease_expected_version: 2}, {lease_idempotency_key: "foreign"}, {actor_agent_id: "agent-b"}, {lease_idempotency_key: null, lease_expected_version: null}, {patch: {text: "Changed work"}}, {planning_intent: {...input.planning_intent, completion_criteria: "New contract"}}]) { diff --git a/tests/control_plane_ts/goal_acceptance_runtime.test.ts b/tests/control_plane_ts/goal_acceptance_runtime.test.ts index eefa2d3a09..a49dab559a 100644 --- a/tests/control_plane_ts/goal_acceptance_runtime.test.ts +++ b/tests/control_plane_ts/goal_acceptance_runtime.test.ts @@ -6,6 +6,7 @@ import {join} from "node:path"; import test, {type TestContext} from "node:test"; import {Pool} from "pg"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import type {HandoffMode} from "../../loopx/control_plane/coordination/handoff_mode_vocabulary.ts"; import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {PostgreSqlAuthorityStore, installPostgreSqlAuthorityStoreSchema} from "../../loopx/control_plane/coordination/postgresql_authority_store.ts"; @@ -35,7 +36,7 @@ function acceptance(records: JsonObject[], bound = true): JsonObject { } function projection(records: JsonObject[], state?: JsonObject): JsonObject { records = [...records].sort((a, b) => String(a.todo_id).localeCompare(String(b.todo_id))); - return {goal_id: "goal-a", handoff_mode: "legacy", todos: records, leases: [], + return {goal_id: "goal-a", handoff_mode: "soft_claim", todos: records, leases: [], todo_read_model: coordinationTodoReadModel(records, "loopx_todo_domain_read_record_v0"), unrelated_extension: {value: "retained"}, ...(state ? {goal_acceptance: state} : {})}; } @@ -64,7 +65,7 @@ async function replace(store: AuthorityStore, head: JsonObject, operation_id: st assert.equal((await store.commitAuthority({operation_id, expected_provider_revision: current.provider_revision, next_projection: head, events: [], receipts: []})).status, "applied"); } -async function seeded(t: TestContext, provider: string, state: "bound" | "unbound" | "off", extra: JsonObject = {}) { +async function seeded(t: TestContext, provider: string, state: "bound" | "unbound" | "off", extra: JsonObject = {}, mode: HandoffMode = "soft_claim") { const root = await mkdtemp(join(tmpdir(), "loopx-acceptance-runtime-")); t.after(() => rm(root, {recursive: true, force: true})); let store: AuthorityStore; @@ -80,7 +81,8 @@ async function seeded(t: TestContext, provider: string, state: "bound" | "unboun store = new PostgreSqlAuthorityStore(database, {tenant_id: `acceptance-${randomUUID()}`, goal_id: "goal-a"}); } else store = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a"); const records = [todo(extra)]; - const head = projection(records, state === "off" ? undefined : acceptance(records, state === "bound")); + const head = {...projection(records, state === "off" ? undefined : acceptance(records, state === "bound")), + handoff_mode: mode}; assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, next_projection: head, events: [], receipts: []})).status, "applied"); return {store, root, head}; @@ -104,6 +106,9 @@ for (const provider of ["file", ...(process.env.LOOPX_TEST_POSTGRES_URL ? ["post assert.equal(plan.status, "execute_validation"); assert.equal((plan.validation_effect as JsonObject).validation_label, "ordinary-check"); assert.deepEqual(plan.goal_acceptance_validation_effects ?? [], []); + // Lease admission is exercised with hard_lease; soft_claim intentionally has no lease grant. + const current = await loaded(store); + await replace(store, {...current.head, handoff_mode: "hard_lease"}, "require-leased-execution"); assert.equal((await executeCanonicalTaskLeaseAcquire(store, acquire)).status, "applied"); assert.equal(acceptanceWorkGuard((await loaded(store)).head, "goal-a", "todo_probe")?.allowed, false); }); @@ -123,8 +128,11 @@ for (const provider of ["file", ...(process.env.LOOPX_TEST_POSTGRES_URL ? ["post const before = await loaded(store); assert.equal((await executeCoordinationTodoClaim(store, {...claim, actor_agent_id: "intruder"})).reason_code, "actor_not_registered"); assert.equal((await executeCoordinationTodoClaim(store, claim)).reason_code, "goal_acceptance_unbound"); - assert.equal((await executeCanonicalTaskLeaseAcquire(store, {...acquire, owner: "intruder"})).reason_code, "owner_not_registered"); - assert.equal((await executeCanonicalTaskLeaseAcquire(store, acquire)).reason_code, "goal_acceptance_unbound"); + const leased = await seeded(t, provider, "unbound", {}, "hard_lease"); + const leaseBefore = await loaded(leased.store); + assert.equal((await executeCanonicalTaskLeaseAcquire(leased.store, {...acquire, owner: "intruder"})).reason_code, "owner_not_registered"); + assert.equal((await executeCanonicalTaskLeaseAcquire(leased.store, acquire)).reason_code, "goal_acceptance_unbound"); + assert.deepEqual(await loaded(leased.store), leaseBefore); assert.equal((await executeCoordinationTodoTerminalLifecycle(store, terminal)).reason_code, "goal_acceptance_unbound"); assert.deepEqual(await loaded(store), before); assert.equal((await store.readReceipt("complete")).status, "missing"); @@ -164,7 +172,7 @@ for (const provider of ["file", ...(process.env.LOOPX_TEST_POSTGRES_URL ? ["post }); test(`${provider}: acquisition replay checks current acceptance after current lease proof`, async t => { - const {store} = await seeded(t, provider, "bound"); + const {store} = await seeded(t, provider, "bound", {}, "hard_lease"); assert.equal((await executeCanonicalTaskLeaseAcquire(store, acquire)).status, "applied"); const current = await loaded(store); await replace(store, {...current.head, goal_acceptance: acceptance(current.head.todos as JsonObject[], false)}, "unbind"); diff --git a/tests/control_plane_ts/host_process_group.test.ts b/tests/control_plane_ts/host_process_group.test.ts index 972a504ef1..4dae80cc72 100644 --- a/tests/control_plane_ts/host_process_group.test.ts +++ b/tests/control_plane_ts/host_process_group.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; -import {execFileSync} from "node:child_process"; +import {execFileSync, spawn} from "node:child_process"; +import {once} from "node:events"; +import {performance} from "node:perf_hooks"; import test from "node:test"; import {hasLiveGroupMembers, waitForProcessGroupStop} from "../../loopx/control_plane/turn_driver/host_process_group.ts"; @@ -10,15 +12,39 @@ test("cleanup observation excludes only zombies, not sleeping or stopped members } assert.equal(hasLiveGroupMembers("100 S\n", 42), false); assert.throws(() => hasLiveGroupMembers("not a process row\n", 42), /invalid.*observation/); + for (const missing of ["", "\n \t"]) { + assert.throws(() => hasLiveGroupMembers(missing, 42), /invalid.*observation/); + } }); -test("an observable live group fails closed at the bounded cleanup deadline", {skip: process.platform === "win32"}, async () => { - // Observe our own live group with signal zero; do not terminate any process. - const pgid = Number(execFileSync("ps", ["-p", String(process.pid), "-o", "pgid="], {encoding: "utf8"}).trim()); - assert.ok(Number.isSafeInteger(pgid) && pgid > 0); +test("an observable live group fails closed at the bounded cleanup deadline", {skip: process.platform === "win32"}, async t => { + // Own a dedicated group, like the Host supervisor. An inherited group can + // be 1 in a PID namespace, where kill(-1, 0) has Linux's all-process meaning. + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000); process.stdout.write('ready');"], + {detached: true, stdio: ["ignore", "pipe", "ignore"]}); + t.after(async () => { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + child.kill("SIGKILL"); // Only this test's child; it creates no descendants. + await exited; + }); + await once(child, "spawn"); + const [ready] = await once(child.stdout!, "data"); + assert.equal(ready.toString(), "ready"); + const pgid = Number(execFileSync("ps", ["-p", String(child.pid), "-o", "pgid="], {encoding: "utf8"}).trim()); + assert.ok(Number.isSafeInteger(pgid) && pgid > 1); + assert.equal(pgid, child.pid); await assert.rejects(waitForProcessGroupStop(pgid), /did not stop before cleanup deadline/); }); +test("an absence observation after the cleanup budget cannot certify stop", {skip: process.platform === "win32"}, async t => { + t.mock.method(process, "kill", () => true); + let clockReads = 0; + t.mock.method(performance, "now", () => ++clockReads <= 2 ? 0 : 1001); + // Real ps observes no such group, but its result arrives after the budget. + await assert.rejects(waitForProcessGroupStop(2147483647), /did not stop before cleanup deadline/); +}); + test("missing process-state observation cannot certify cleanup", {skip: process.platform === "win32"}, async t => { t.mock.method(process, "kill", () => true); // Signal zero sees an existing group. const previousPath = process.env.PATH; diff --git a/tests/control_plane_ts/local_authority_runtime.test.ts b/tests/control_plane_ts/local_authority_runtime.test.ts index 1d93705b66..32f8d8c0ef 100644 --- a/tests/control_plane_ts/local_authority_runtime.test.ts +++ b/tests/control_plane_ts/local_authority_runtime.test.ts @@ -1328,7 +1328,7 @@ test("receipt-only claims revalidate CAS contention and recover a lost commit re await engageCanonicalRoute(root); const directory = join(root, "authority", "file-v0"); const store = new FileAuthorityStore(directory, "goal-a"); - const projection = withTodoReadModel({goal_id: "goal-a", todos: [ + const projection = withTodoReadModel({goal_id: "goal-a", handoff_mode: "soft_claim", todos: [ todoRecord({claimed_by: "agent-a"}), ], leases: []}); await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, diff --git a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts index c2290049e9..4841898d76 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -181,7 +181,7 @@ if (database && installed) { status: "open", done: false, archive_state: "active", text: "[P2] Existing work", title: "Existing work", priority: "P2", task_class: "advancement_task"}]; await store.commitAuthority({operation_id: "seed-priority", expected_provider_revision: null, - events: [], receipts: [], next_projection: {goal_id: options.goal_id, todos, leases: [], + events: [], receipts: [], next_projection: {goal_id: options.goal_id, handoff_mode: "soft_claim", todos, leases: [], todo_read_model: {schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, todo_count: 1, records_sha256: canonicalAuthoritySha256(todos), contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}}}); const request = {goal_id: options.goal_id, todo_id: "todo_priority", expected_role: "agent", diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.ts b/tests/control_plane_ts/production_scale_coordination_fixture.ts index 41bf57a013..cc073a1df7 100644 --- a/tests/control_plane_ts/production_scale_coordination_fixture.ts +++ b/tests/control_plane_ts/production_scale_coordination_fixture.ts @@ -590,7 +590,7 @@ export function productionScaleCompletedMonitorFixture(goalId: string, status: retainedLease === "released" ? "released" : "active", ...(retainedLease === "expired" ? {expires_at: "2026-09-01T00:20:00Z"} : {}), ...(retainedLease === "released" ? {released_at: "2026-09-01T00:30:00Z"} : {})}]), - schema, {source_authority: "synthetic_production_scale_fixture", handoff_mode: retainedLease === "absent" ? "legacy" : "hard_lease"})}; + schema, {source_authority: "synthetic_production_scale_fixture", handoff_mode: retainedLease === "absent" ? "soft_claim" : "hard_lease"})}; } /** User decisions act on an exact dependent inside the full mixed graph. @@ -661,7 +661,7 @@ export function productionScaleConsumerScopeFixture(goalId: string, schema: Auth text: "Review a synthetic result", source_section: "User Todo", index: 2000 + index, ...item})); return {...fixture, projection: authorityProjectionFixture(goalId, [...fixture.projection.todos as Record[], ...extra], - fixture.projection.leases as Record[], schema, {handoff_mode: "legacy"})}; + fixture.projection.leases as Record[], schema, {handoff_mode: "soft_claim"})}; } /** Capability-owned bucket inside a full mixed graph, including a historical diff --git a/tests/control_plane_ts/retained_lease_receipts.test.ts b/tests/control_plane_ts/retained_lease_receipts.test.ts new file mode 100644 index 0000000000..94c70dbbbb --- /dev/null +++ b/tests/control_plane_ts/retained_lease_receipts.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import {mkdir, mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; +import {dirname, join} from "node:path"; +import {tmpdir} from "node:os"; +import test from "node:test"; +import {atomicWriteJson} from "../../loopx/control_plane/effect_runtime_io.ts"; +import {executeTaskLeaseLifecycle, TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION} from "../../loopx/control_plane/work_items/task_lease_lifecycle.ts"; + +// Fixed 1.x transaction bytes: only $ROOT relocates. The candidate never +// generates its own historical hashes, identities, tokens or generations. +const history = JSON.parse(await readFile(new URL( + "../control_plane/fixtures/retained_lease_receipts_1x.json", import.meta.url), "utf8")); +async function restore(t: test.TestContext, name: string) { + assert.equal(history.source_commit, "60d833b13c85135587b725013b247e827663ffb9"); + const root = await mkdtemp(join(tmpdir(), "loopx-retained-1x-")); + t.after(() => rm(root, {recursive: true, force: true})); + const original = JSON.parse(JSON.stringify(history.scenarios[name]).replaceAll("$ROOT", root)); + for (const [relative, value] of Object.entries(original.files)) { + const path = join(root, "runtime", relative); + await mkdir(dirname(path), {recursive: true}); + await writeFile(path, JSON.stringify(value)); + } + const leasePath = join(root, "runtime/goals/goal-a/task-leases/todo_target.json"); + const receipt = Object.keys(original.files).find(path => path.includes("/.lifecycle")); + return {...original, root, leasePath, + receiptPath: receipt ? join(root, "runtime", receipt) : null}; +} + +for (const operation of ["renew", "transfer"] as const) { + for (const phase of ["committed", "prepared"] as const) { + for (const later of [false, true]) { + test(`1.x ${operation} ${phase} recovery preserves ${later ? "later" : "original"} lease`, async t => { + const f = await restore(t, operation); + const receipt = JSON.parse(await readFile(f.receiptPath!, "utf8")); + await atomicWriteJson(f.receiptPath!, {...receipt, state: phase}); + if (later) { + const lease = JSON.parse(await readFile(f.leasePath, "utf8")); + await atomicWriteJson(f.leasePath, {...lease, version: 9, lease_epoch: 9, idempotency_key: "later"}); + } + const before = await readFile(f.leasePath, "utf8"); + const result = await executeTaskLeaseLifecycle(f.request, { + now: () => new Date("2026-09-01T03:03:00Z"), + beforeWrite: () => assert.fail("receipt recovery must not repeat a lease write"), + }); + assert.equal(result.ok, !later, JSON.stringify(result)); + if (!later) { + assert.equal(result.idempotent, true); + assert.equal(JSON.parse(await readFile(f.receiptPath!, "utf8")).state, "committed"); + } + assert.equal(await readFile(f.leasePath, "utf8"), before); + }); + } + } +} + +test("1.x original release retains CAS, expiry cleanup and original generation", async t => { + const f = await restore(t, "original"); + const before = await readFile(f.leasePath, "utf8"); + const release = {...f.request, schema_version: TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION, + operation: "release", expected_version: 1, authority: null}; + for (const change of [{owner: "agent-b"}, {idempotency_key: "other"}, {expected_version: 0}]) { + assert.equal((await executeTaskLeaseLifecycle({...release, ...change})).ok, false); + assert.equal(await readFile(f.leasePath, "utf8"), before); + } + const result = await executeTaskLeaseLifecycle(release, {now: () => new Date("2026-09-02T00:00:00Z")}); + assert.equal(result.released, true, JSON.stringify(result)); + const after = await readFile(f.leasePath, "utf8"); + const lease = JSON.parse(after), original = JSON.parse(before); + assert.equal(lease.status, "released"); + assert.equal(lease.version, original.version); + assert.equal(lease.lease_epoch, original.lease_epoch); + assert.equal((await executeTaskLeaseLifecycle(release)).idempotent, true); + assert.equal(await readFile(f.leasePath, "utf8"), after); +}); + +test("1.x held receipt repairs lost close response after the exact release committed", async t => { + const f = await restore(t, "terminal-held"); + const original = JSON.parse(await readFile(f.receiptPath!, "utf8")); + const close = {...f.request, operation: "fence_close", authority: null, + owner: null, idempotency_key: null, expected_version: null, + lock_token: original.lock_token, fence_operation_id: original.operation_id, + committed: true, release_lease: true, fence_owner: original.fence_owner, + fence_idempotency_key: original.fence_idempotency_key, + fence_expected_version: original.fence_expected_version, + fence_expected_lease_epoch: original.fence_expected_lease_epoch}; + // Independent post-write/pre-close crash window. No held lock is invented: + // only a released matching generation may finish this old close receipt. + const lease = JSON.parse(await readFile(f.leasePath, "utf8")); + const before = await executeTaskLeaseLifecycle(close); + assert.equal(before.error_code, "fence_token_invalid"); + assert.deepEqual(JSON.parse(await readFile(f.leasePath, "utf8")), lease); + await atomicWriteJson(f.leasePath, {...lease, status: "released", released_at: "2026-09-01T03:03:00Z"}); + const first = await executeTaskLeaseLifecycle(close); + assert.equal(first.ok, true, JSON.stringify(first)); + assert.equal(first.released, true); + const after = await readFile(f.leasePath, "utf8"); + assert.equal((await executeTaskLeaseLifecycle(close)).idempotent, true); + assert.equal(await readFile(f.leasePath, "utf8"), after); +}); 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 4ab851d64a..75389894ea 100644 --- a/tests/control_plane_ts/shadow_native_writer_boundary.test.ts +++ b/tests/control_plane_ts/shadow_native_writer_boundary.test.ts @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import fs, { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import test from "node:test"; +import test, {type TestContext} from "node:test"; +import {syncBuiltinESMExports} from "node:module"; import { atomicWriteJson } from "../../loopx/control_plane/effect_runtime_io.ts"; import { withCanonicalWriter } from "../../loopx/control_plane/coordination/local_authority_write.ts"; import { @@ -192,23 +193,70 @@ test("fence engagement refuses malformed durable maintenance before publishing", await assert.rejects(access(legacyCoordinationWriterFencePath(root, "goal-a"))); }); +// Observe a real failed exclusive open, rather than assuming an operation has +// reached its lock after a wall-clock sleep. Slow startup must not let a broken +// lock path pass before the test releases the primary writer. +async function whileWriterContended( + t: TestContext, target: string, invoke: () => Promise>, + checkBlocked: () => Promise, +): Promise> { + let pending: Promise> | undefined; + try { + await withFileMutationLock(target, async () => { + const heldPath = await fs.realpath(`${target}.ts-effect.lock`); + let observed!: () => void; + const contention = new Promise(resolve => { observed = resolve; }); + const originalOpen = fs.open; + const probe = t.mock.method(fs, "open", async (...args: Parameters) => { + try { + return await originalOpen(...args); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST" && + await fs.realpath(String(args[0])) === heldPath) observed(); + throw error; + } + }); + syncBuiltinESMExports(); + try { + pending = invoke(); + const first = await Promise.race([ + contention.then(() => "contended"), + pending.then(() => "completed"), + ]); + assert.equal(first, "contended", "the fence must encounter the active primary writer lock"); + await checkBlocked(); + } finally { + probe.mock.restore(); + syncBuiltinESMExports(); + } + }); + return await pending!; + } finally { + // The real holder is released even after a failed assertion. Drain the + // operation before fixture cleanup, preserving the original test failure. + await pending?.catch(() => undefined); + } +} + for (const kind of ["todo", "state", "lease"] as const) { - test(`fence engagement waits for an existing ${kind} writer before publication`, async (t) => { - const root = await mkdtemp(join(tmpdir(), "loopx-fence-lock-")); - t.after(() => rm(root, {recursive: true, force: true})); - const statePath = join(root, "ACTIVE_GOAL_STATE.md"); - await writeFile(statePath, "---\ngoal_id: goal-a\n---\n"); - const lock = kind === "todo" ? legacyCoordinationTodoLockPath(root, "goal-a") : kind === "state" ? statePath : taskLeaseLockPath({runtime_root: root, goal_id: "goal-a"}); - let pending: Promise> | undefined; - let completed = false; - await withFileMutationLock(lock, async () => { - pending = engageLegacyCoordinationWriterFence(fenceRequest(root)).then((result) => {completed = true; return result;}); - await new Promise((resolve) => setTimeout(resolve, 100)); - assert.equal(completed, false, "the fence must not pass an active primary writer"); - await assert.rejects(access(legacyCoordinationWriterFencePath(root, "goal-a"))); + for (const startupDelay of [0, 200]) { + const suffix = startupDelay ? " after delayed startup" : ""; + test(`fence engagement waits for an existing ${kind} writer before publication${suffix}`, {timeout: 10_000}, async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-fence-lock-")); + t.after(() => rm(root, {recursive: true, force: true})); + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + await writeFile(statePath, "---\ngoal_id: goal-a\n---\n"); + const lock = kind === "todo" ? legacyCoordinationTodoLockPath(root, "goal-a") : kind === "state" ? statePath : taskLeaseLockPath({runtime_root: root, goal_id: "goal-a"}); + const result = await whileWriterContended(t, lock, + async () => { + if (startupDelay) await new Promise(resolve => setTimeout(resolve, startupDelay)); + return engageLegacyCoordinationWriterFence(fenceRequest(root)); + }, + async () => { await assert.rejects(access(legacyCoordinationWriterFencePath(root, "goal-a"))); }); + assert.equal(result.status, "applied"); + assert.equal(JSON.parse(await readFile(legacyCoordinationWriterFencePath(root, "goal-a"), "utf8")).fence_id, "fence-a"); }); - assert.equal((await pending)!.status, "applied"); - }); + } } test("fence engagement requires the actual source state path", async (t) => { diff --git a/tests/control_plane_ts/task_lease_eligibility.test.ts b/tests/control_plane_ts/task_lease_eligibility.test.ts index ba27947a2e..840d017d7f 100644 --- a/tests/control_plane_ts/task_lease_eligibility.test.ts +++ b/tests/control_plane_ts/task_lease_eligibility.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import {evaluateTaskLeaseAcquireDecision} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; -import {decideTaskLeaseLifecycle} from "../../loopx/control_plane/work_items/task_lease_lifecycle_decision.ts"; +import {decideTaskLeaseLifecycle, evaluateTaskLeaseLifecycleDecision} from "../../loopx/control_plane/work_items/task_lease_lifecycle_decision.ts"; import {evaluateTaskLeaseOwnerEligibility, leaseOwnerRejection, type LeaseEligibilityTodo} from "../../loopx/control_plane/work_items/task_lease_eligibility.ts"; import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; @@ -100,3 +100,12 @@ for (const generation of ["version", "lease_epoch"] as const) { assert.equal(result.code, "lease_generation_exhausted"); assert.equal(result.outcome, "rejected"); }); } + +test("lease decision wires accept only the registered historical/current policy vocabulary", () => { + for (const mode of ["future_policy", "hard_lease_extra", "Legacy", ""]) { + assert.throws(() => evaluateTaskLeaseAcquireDecision({...acquire(false), handoff_mode: mode}), /handoff_mode/); + assert.throws(() => evaluateTaskLeaseLifecycleDecision({handoff_mode: mode, registered_agents: ["agent-a"], + todo, lease, command: {operation: "renew", owner: "agent-a", idempotency_key: "first", + expected_version: 1, ttl_seconds: 120, new_owner: null, new_idempotency_key: null}}), /handoff_mode/); + } +}); diff --git a/tests/control_plane_ts/task_lease_inspection.test.ts b/tests/control_plane_ts/task_lease_inspection.test.ts index ea2242b27b..a8dfce3745 100644 --- a/tests/control_plane_ts/task_lease_inspection.test.ts +++ b/tests/control_plane_ts/task_lease_inspection.test.ts @@ -32,7 +32,7 @@ const installed = database ? installPostgreSqlAuthorityStoreSchema(database, `po test.after(async () => {await pool?.end();}); type Provider = "legacy" | "file" | "sqlite" | "postgresql"; -async function fixture(t: test.TestContext, provider: Provider, schema: "native" | "legacy" = "native") { +async function fixture(t: test.TestContext, provider: Provider, schema: "native" | "legacy" = "native", mode = "hard_lease") { const root = await mkdtemp(join(tmpdir(), "lease-inspect-")), goal = "inspect-goal"; t.after(() => rm(root, {recursive: true, force: true})); const registry = join(root, "registry.json"), registryText = "synthetic registration source"; @@ -77,7 +77,7 @@ async function fixture(t: test.TestContext, provider: Provider, schema: "native" if (item.todo !== null) todos.push({...todo, todo_id: `todo_inspect_${item.name}`, ...item.todo}); if (item.lease !== null) leases.push({...lease, todo_id: `todo_inspect_${item.name}`, expires_at: "2026-09-13T10:10:00Z", ...item.lease}); } - const projection = authorityProjectionFixture(goal, todos, leases, schema, {handoff_mode: "hard_lease"}); + const projection = authorityProjectionFixture(goal, todos, leases, schema, {handoff_mode: mode}); const authority = {handoff_mode: "hard_lease", registered_agent_candidates: [fixture.registered_agents], todos: todos.filter(row => row.archive_state === "active"), todo_projection_error: null, source_receipts: [{source_id: "registry", path: registry, state: "file", sha256: createHash("sha256").update(registryText).digest("hex")}]}; @@ -132,8 +132,9 @@ for (const provider of ["legacy", "file", "sqlite", "postgresql"] as const) { for (const item of cases) { const result = await inspectTaskLease({...request, todo_id: `todo_inspect_${item.name}`}, {now: () => NOW, authorityProvider}); assert.equal(result.ok, true, JSON.stringify(result)); - assert.equal(result.active, item.active, item.name); - assert.deepEqual(result.executor_constraint, item.constraint, item.name); + assert.equal(result.active, provider === "legacy" ? false : item.active, item.name); + assert.deepEqual(result.executor_constraint, provider === "legacy" + ? {effective: false, reason: "canonical_authority_required"} : item.constraint, item.name); if (item.name === "repository-bound") assert.equal((result.lease as JsonObject).write_repository, "git:github.com/team/a"); if (store && before?.status === "loaded") { assert.equal(result.source_authority, `${provider}_v0`); @@ -170,13 +171,30 @@ for (const fault of ["registration", "route", "clock", "field"] as const) { }); } -test("legacy inspection preserves projection errors and rejects malformed active expiry", async t => { +test("retained source inspection ignores retired Todo admission and validates the original lease", async t => { const {request, root, goal} = await fixture(t, "legacy"); const unavailable = await inspectTaskLease({...request, authority: {...request.authority, todo_projection_error: {code: "todo_projection_unavailable", message: "source cannot be projected"}}}, {now: () => NOW}); assert.equal(unavailable.active, false); - assert.deepEqual(unavailable.executor_constraint, {effective: false, reason: "todo_projection_unavailable"}); + assert.deepEqual(unavailable.executor_constraint, {effective: false, reason: "canonical_authority_required"}); const lease = {schema_version: "task_lease_v0", status: "active", owner: "agent-a", expires_at: "broken"}; await writeFile(join(root, "goals", goal, "task-leases", `${request.todo_id}.json`), JSON.stringify(lease)); assert.equal((await inspectTaskLease(request, {now: () => NOW})).error_code, "corrupt_lease"); }); + +for (const provider of ["file", "sqlite", "postgresql"] as const) { + test(`${provider} retired legacy policy reports retained leases without effective execution`, + {skip: (provider === "postgresql" && !pool) || (provider === "sqlite" && !sqliteQualified)}, async t => { + const {request, store, authorityProvider} = await fixture(t, provider, "native", "legacy"); + const before = await store!.loadAuthority(); + const result = await inspectTaskLease(request, {now: () => NOW, authorityProvider}); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.handoff_mode, "legacy"); + assert.equal(result.active, false); + assert.equal((result.execution_policy as JsonObject).supported, false); + assert.equal((result.execution_policy as JsonObject).execution_authority_granted, false); + assert.deepEqual(result.executor_constraint, {effective: false, reason: "handoff_mode_retired"}); + assert.equal((result.lease as JsonObject).status, "active", "historical record is not rewritten"); + assert.deepEqual(await store!.loadAuthority(), before); + }); +} diff --git a/tests/control_plane_ts/terminal_reentry_conformance.ts b/tests/control_plane_ts/terminal_reentry_conformance.ts index 70dd5a273e..0659c67ba0 100644 --- a/tests/control_plane_ts/terminal_reentry_conformance.ts +++ b/tests/control_plane_ts/terminal_reentry_conformance.ts @@ -18,84 +18,92 @@ import type {AuthorityStoreConformanceFactory} from "./authority_store_conforman export function registerTerminalReentryConformance(provider: string, factory: AuthorityStoreConformanceFactory): void { for (const schema of ["legacy", "native"] as const) { - test(`${provider}: imported untyped completion repairs once before terminal closeout (${schema})`, async t => { - const {store, contender} = await factory(t); - const todo: JsonObject = {schema_version: schema === "native" ? "todo_domain_item_v0" : "todo_item_v0", - todo_id: "todo_untyped", role: "agent", status: "done", done: true, archive_state: "active", - text: "Repair imported terminal metadata", claimed_by: "agent-a", completion_turn_key: "original-turn", - note: "Original note", evidence: "Original evidence", reason: "Original reason", - last_actor_agent_id: "agent-before", updated_at: "2026-09-01T01:00:00Z", - completion_receipt_id: "tcw_original", completed_at: "2026-09-01T00:00:00Z", - ...(schema === "legacy" ? {source_section: "Agent Todo"} : {})}; - await store.commitAuthority({operation_id: "import-untyped", expected_provider_revision: null, - next_projection: authorityProjectionFixture("goal-a", [todo], [], schema, {handoff_mode: "soft_claim"}), - events: [], receipts: [{original_import: true}]}); - const producerPolicy = { - schema_version: "loopx_todo_completion_policy_request_v0", - goal_id: "goal-a", agent_model: "peer_v1", claimed_by: null, - registered_agents: ["agent-a", "agent-b"], next_claimed_by: null, - next_agent_todo: null, next_continuation_policy: null, - next_excluded_agents: [], self_merged: false, evidence: null, - linked_successors: [], - }; - const request: CoordinationTodoTerminalLifecycleInput = { - goal_id: "goal-a", todo_id: "todo_untyped", expected_role: "agent", command: "complete", - operation_identity: {kind: "completion_turn"}, - 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, - allow_user_gate_auto_acquire: true, requested_no_followup: false, - requested_completion_turn_key: "original-turn", requested_completion_identity_source: "turn_settlement", - linked_successor_todo_ids: [], successor_intents: [], note: null, evidence: null, reason: null, - clear_claim: false, validation_declaration: null, validation_receipt: null, - completion_policy_request: producerPolicy, - dry_run: false, now: new Date("2026-09-07T07:00:00Z"), - }; - const before = await store.loadAuthority(), importedReceipt = await store.readReceipt("import-untyped"); - const rejectedChanges = [{requested_no_followup: true}, {actor_agent_id: "agent-b"}, - {requested_completion_turn_key: "other-turn"}, - {note: "Replace original note"}, {evidence: "Replace original evidence"}, - {reason: "Replace original reason"}, {clear_claim: true}, {decision_outcome: "approve"}, - {completion_policy_request: {...producerPolicy, claimed_by: "agent-a"}}, - {successor_intents: [{role: "agent", text: "Cannot add work during repair"}]}] satisfies - readonly Partial[]; - for (const change of rejectedChanges) { - assert.equal((await execute(store, {...request, ...change})).status, "failed", JSON.stringify(change)); + for (const policyClaim of [null, "agent-a"]) { + test(`${provider}: imported untyped completion repairs once before terminal closeout (${schema}, claim=${policyClaim})`, async t => { + const {store, contender} = await factory(t); + const todo: JsonObject = {schema_version: schema === "native" ? "todo_domain_item_v0" : "todo_item_v0", + todo_id: "todo_untyped", role: "agent", status: "done", done: true, archive_state: "active", + text: "Repair imported terminal metadata", claimed_by: "agent-a", completion_turn_key: "original-turn", + note: "Original note", evidence: "Original evidence", reason: "Original reason", + last_actor_agent_id: "agent-before", updated_at: "2026-09-01T01:00:00Z", + completion_receipt_id: "tcw_original", completed_at: "2026-09-01T00:00:00Z", + ...(schema === "legacy" ? {source_section: "Agent Todo"} : {})}; + await store.commitAuthority({operation_id: "import-untyped", expected_provider_revision: null, + next_projection: authorityProjectionFixture("goal-a", [todo], [], schema, {handoff_mode: "soft_claim"}), + events: [], receipts: [{original_import: true}]}); + const producerPolicy = { + schema_version: "loopx_todo_completion_policy_request_v0", + goal_id: "goal-a", agent_model: "peer_v1", claimed_by: policyClaim, + registered_agents: ["agent-a", "agent-b"], next_claimed_by: null, + next_agent_todo: null, next_continuation_policy: null, + next_excluded_agents: [], self_merged: false, evidence: null, + linked_successors: [], + }; + const request: CoordinationTodoTerminalLifecycleInput = { + goal_id: "goal-a", todo_id: "todo_untyped", expected_role: "agent", command: "complete", + operation_identity: {kind: "completion_turn"}, + 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, + allow_user_gate_auto_acquire: true, requested_no_followup: false, + requested_completion_turn_key: "original-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: null, evidence: null, reason: null, + clear_claim: false, validation_declaration: null, validation_receipt: null, + completion_policy_request: producerPolicy, + dry_run: false, now: new Date("2026-09-07T07:00:00Z"), + }; + const before = await store.loadAuthority(), importedReceipt = await store.readReceipt("import-untyped"); + const rejectedChanges = [{requested_no_followup: true}, {actor_agent_id: "agent-b"}, + {requested_completion_turn_key: "other-turn"}, + {note: "Replace original note"}, {evidence: "Replace original evidence"}, + {reason: "Replace original reason"}, {clear_claim: true}, {decision_outcome: "approve"}, + {completion_policy_request: {claimed_by: "agent-a"}}, + {completion_policy_request: {...producerPolicy, claimed_by: "agent-b"}}, + {completion_policy_request: {...producerPolicy, goal_id: "other-goal"}}, + {completion_policy_request: {...producerPolicy, registered_agents: ["agent-a"]}}, + {completion_policy_request: {...producerPolicy, evidence: "Fresh policy evidence"}}, + {completion_policy_request: {...producerPolicy, next_agent_todo: "Fresh policy successor"}}, + {completion_policy_request: {...producerPolicy, unexpected: true}}, + {successor_intents: [{role: "agent", text: "Cannot add work during repair"}]}] satisfies + readonly Partial[]; + for (const change of rejectedChanges) { + assert.equal((await execute(store, {...request, ...change})).status, "failed", JSON.stringify(change)); + assert.deepEqual(await store.loadAuthority(), before); + } + const preview = await execute(store, {...request, dry_run: true}); + assert.equal(preview.status, "planned", JSON.stringify(preview)); + assert.equal(preview.changed, true); assert.deepEqual(await store.loadAuthority(), before); - } - const preview = await execute(store, {...request, dry_run: true}); - assert.equal(preview.status, "planned", JSON.stringify(preview)); - assert.equal(preview.changed, true); - assert.deepEqual(await store.loadAuthority(), before); - const repair = await execute(store, request); - assert.equal(repair.status, "applied", JSON.stringify(repair)); - assert.equal(repair.changed, true); - assert.equal(repair.completion_continuation, "active_goal"); - const repaired = await store.loadAuthority(); - assert.equal(repaired.status, "loaded"); - if (repaired.status !== "loaded") throw new Error("repair head missing"); - assert.equal(before.status, "loaded"); - if (before.status !== "loaded") throw new Error("imported head missing"); - const repairedRows = repaired.head.todos, importedRows = before.head.todos; - if (!Array.isArray(repairedRows) || repairedRows.length !== 1 || - !Array.isArray(importedRows) || importedRows.length !== 1) { - throw new Error("imported repair Todo missing"); - } - const item = canonicalAuthorityObject(repairedRows[0], "repaired imported Todo"); - const imported = canonicalAuthorityObject(importedRows[0], "original imported Todo"); - assert.equal(item.completion_continuation, "active_goal"); - assert.equal(item.completion_turn_key, "original-turn"); - assert.equal(item.completed_at, todo.completed_at); - assert.equal(item.completion_receipt_id, todo.completion_receipt_id); - assert.deepEqual(item, {...imported, completion_continuation: "active_goal"}); - assert.deepEqual(await store.readReceipt("import-untyped"), importedReceipt); - assert.equal((await execute(contender, request)).status, "replayed"); - assert.deepEqual(await store.loadAuthority(), repaired); - const closed = await execute(store, {...request, requested_no_followup: true, - operation_identity: {kind: "completion_turn"}}); - assert.equal(closed.status, "applied", JSON.stringify(closed)); - assert.equal(closed.completion_continuation, "no_followup"); - assert.equal(closed.completion_recovery, "same_turn_terminal_closeout"); - }); + const repair = await execute(store, request); + assert.equal(repair.status, "applied", JSON.stringify(repair)); + assert.equal(repair.changed, true); + assert.equal(repair.completion_continuation, "active_goal"); + const repaired = await store.loadAuthority(); + assert.equal(repaired.status, "loaded"); + if (repaired.status !== "loaded") throw new Error("repair head missing"); + assert.equal(before.status, "loaded"); + if (before.status !== "loaded") throw new Error("imported head missing"); + const repairedRows = repaired.head.todos, importedRows = before.head.todos; + if (!Array.isArray(repairedRows) || repairedRows.length !== 1 || + !Array.isArray(importedRows) || importedRows.length !== 1) { + throw new Error("imported repair Todo missing"); + } + const item = canonicalAuthorityObject(repairedRows[0], "repaired imported Todo"); + const imported = canonicalAuthorityObject(importedRows[0], "original imported Todo"); + assert.equal(item.completion_continuation, "active_goal"); + assert.equal(item.completion_turn_key, "original-turn"); + assert.equal(item.completed_at, todo.completed_at); + assert.equal(item.completion_receipt_id, todo.completion_receipt_id); + assert.deepEqual(item, {...imported, completion_continuation: "active_goal"}); + assert.deepEqual(await store.readReceipt("import-untyped"), importedReceipt); + assert.equal((await execute(contender, request)).status, "replayed"); + assert.deepEqual(await store.loadAuthority(), repaired); + const closed = await execute(store, {...request, requested_no_followup: true, + operation_identity: {kind: "completion_turn"}}); + assert.equal(closed.status, "applied", JSON.stringify(closed)); + assert.equal(closed.completion_continuation, "no_followup"); + assert.equal(closed.completion_recovery, "same_turn_terminal_closeout"); + }); + } test(`${provider}: imported untyped completion repair bypasses fresh validators (${schema})`, async t => { const {store} = await factory(t); const declaration = {validation_command: null, validation_command_argv: ["true"], diff --git a/tests/control_plane_ts/todo_monitor_poll.test.ts b/tests/control_plane_ts/todo_monitor_poll.test.ts index c2416ee31b..0a5b474a43 100644 --- a/tests/control_plane_ts/todo_monitor_poll.test.ts +++ b/tests/control_plane_ts/todo_monitor_poll.test.ts @@ -16,7 +16,7 @@ async function seeded(overrides: JsonObject = {}, extras: JsonObject[] = []) { status: "open", done: false, text: "Observe external progress", archive_state: "active", task_class: "continuous_monitor", target_key: "watch-a", cadence: "1h", ...overrides}, ...extras]; await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, - events: [], receipts: [], next_projection: {goal_id: "goal-a", todos: records, leases: [], + events: [], receipts: [], next_projection: {goal_id: "goal-a", handoff_mode: "soft_claim", todos: records, leases: [], todo_read_model: {schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, todo_count: records.length, records_sha256: canonicalAuthoritySha256(records), contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}}}); const request = {goal_id: "goal-a", operation_id: "poll-a", actor_agent_id: "agent-a", diff --git a/tests/control_plane_ts/todo_terminal_decision.test.ts b/tests/control_plane_ts/todo_terminal_decision.test.ts index 08d755ab50..553a066ef9 100644 --- a/tests/control_plane_ts/todo_terminal_decision.test.ts +++ b/tests/control_plane_ts/todo_terminal_decision.test.ts @@ -17,7 +17,7 @@ function request(overrides: Record = {}) { return { schema_version: COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA, command: "complete", - handoff_mode: "legacy", + handoff_mode: "soft_claim", registered_agents: ["agent-a", "agent-b"], lifecycle_grants: [], todo: { @@ -104,10 +104,10 @@ test("explicit missing Todo is a typed refusal; missing or malformed wire fields } }); -test("snapshot admission preserves inactive holders and claim-neutral mutation effects", () => { +test("current-policy admission preserves inactive holders and claim-neutral mutation effects", () => { const holder = {present: true, active: true, status: "active", owner: "agent-a", idempotency_key: "holder", version: 3, lease_epoch: 1, write_scopes: []}; - for (const mode of ["legacy", "soft_claim", "hard_lease"]) { + for (const mode of ["soft_claim", "hard_lease"]) { for (const lease of [null, holder, {...holder, active: false, status: "expired"}, {...holder, active: false, status: "released"}]) { const result = evaluateCoordinationTodoMutationDecision(mutation({handoff_mode: mode, lease})); @@ -151,9 +151,9 @@ test("bound User action closure is administrative, not a fabricated execution le lease: {...lease, active: false, status: "expired"}}).next_lease?.status, "released"); }); -test("update admission shares actor rules without inventing terminal effects", () => { +test("current-policy update admission shares actor rules without inventing terminal effects", () => { const base = mutation({ todo: { ...request().todo as object, claimed_by: null } }); - for (const mode of ["legacy", "soft_claim", "hard_lease"]) { + for (const mode of ["soft_claim", "hard_lease"]) { const edited = evaluateCoordinationTodoMutationDecision({ ...base, handoff_mode: mode }); assert.equal(edited.outcome, "apply"); assert.equal(edited.next_todo_status, "open"); @@ -440,3 +440,41 @@ test("hard-lease divergence and terminal replay fail closed in the established o assert.equal(replayed.code, "terminal_replay"); assert.equal(replayed.idempotent, true); }); +test("unneeded malformed delegation cannot block ordinary actor authority", () => { + for (const evaluate of [evaluateCoordinationTodoTerminalDecision, evaluateCoordinationTodoMutationDecision]) { + const build = evaluate === evaluateCoordinationTodoTerminalDecision ? request : mutation; + const invalid = [{agent_id: "agent-b", actions: [], requires_reason: true}]; + const own = build({handoff_mode: "soft_claim", lifecycle_grants: invalid}); + assert.equal(evaluate(own).outcome, "apply"); + assert.equal(evaluate({...own, actor_agent_id: "unknown"}).code, "actor_not_registered"); + assert.equal(evaluate({...own, todo: {...own.todo, excluded_agents: ["agent-a"]}}).code, "actor_excluded"); + // This caller really needs delegation: malformed authority must fail closed. + assert.throws(() => evaluate({...own, actor_agent_id: "agent-b"}), /supported actions/); + const granted = {...own, actor_agent_id: "agent-b", authority_reason: "reviewed worker result", + lifecycle_grants: [{agent_id: "agent-b", actions: [own.authority_action]}]}; + assert.equal(evaluate(granted).authority_mode, "delegated_orchestration_override"); + assert.equal(evaluate({...granted, authority_reason: null}).code, "delegation_reason_required"); + assert.throws(() => evaluate({...granted, lifecycle_grants: [ + {agent_id: "agent-b", actions: [own.authority_action], requires_reason: null}, + ]}), /requires_reason/); + } +}); + +test("retired legacy policy cannot authorize fresh lifecycle or execution proof", () => { + const lease = {present: true, active: true, status: "active", owner: "agent-a", + idempotency_key: "retained", version: 3, lease_epoch: 1, write_scopes: []}; + for (const proof of [null, lease]) { + for (const command of ["claim", "update", "complete", "supersede"]) { + const terminal = command === "complete" || command === "supersede"; + const build = terminal ? request : mutation; + const evaluate = terminal ? evaluateCoordinationTodoTerminalDecision : evaluateCoordinationTodoMutationDecision; + const result = evaluate(build({command, authority_action: command, handoff_mode: "legacy", + lease: proof, lease_idempotency_key: proof === null ? null : "retained", + lease_expected_version: proof === null ? null : 3})); + assert.equal(result.outcome, "rejected"); + assert.equal(result.code, "handoff_mode_retired"); + assert.equal(result.next_lease, null); + assert.equal(result.next_todo_status, null); + } + } +}); diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 52b5220089..5390a2afa3 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -242,7 +242,7 @@ async function seeded(overrides: Record = {}) { const records = [todo(overrides)]; if (records[0]!.claimed_by === null) Reflect.deleteProperty(records[0]!, "claimed_by"); await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, - events: [], receipts: [], next_projection: {goal_id: "goal-a", todos: records, leases: [], + events: [], receipts: [], next_projection: {goal_id: "goal-a", handoff_mode: "soft_claim", todos: records, leases: [], todo_read_model: {schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, todo_count: 1, records_sha256: canonicalAuthoritySha256(records), contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}}}); @@ -952,6 +952,15 @@ test("retained execution fences distinguish recovery; soft released owner copy n expires_at: state === "expired" ? "2026-09-04T00:00:00Z" : "2026-09-06T00:00:00Z", write_scopes: []}]}}); const before = await store.loadAuthority(); const result = await executeCoordinationTodoUpdate(store, {...request, dry_run: true}); + if (mode === "legacy") { + assert.equal(result.reason_code, "handoff_mode_retired"); + assert.match(String(result.reason), /plan-migration\/migrate/); + assert.equal(result.recovery, undefined, "retired policy cannot offer executable proof or acquisition"); + assert.equal(JSON.stringify(result).includes("private-execution-key"), false); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); + continue; + } if (mode === "soft_claim" && state === "released" && owner === "agent-a") { assert.equal(result.status, "planned"); assert.deepEqual(await store.loadAuthority(), before); @@ -1043,7 +1052,7 @@ test("recovery preserves stale-proof rejection and never offers foreign proof", const head = await store.loadAuthority(); if (head.status !== "loaded") assert.fail("missing fixture"); await store.commitAuthority({operation_id: "lease", expected_provider_revision: head.provider_revision, - events: [], receipts: [], next_projection: {...head.head, handoff_mode: "legacy", + events: [], receipts: [], next_projection: {...head.head, handoff_mode: "hard_lease", leases: [{todo_id: "todo_a", owner: "agent-a", idempotency_key: "current-key", version: 4, lease_epoch: 1, status: "active", expires_at: "2026-09-06T00:00:00Z", write_scopes: []}]}}); const before = await store.loadAuthority(); @@ -1057,7 +1066,7 @@ test("recovery preserves stale-proof rejection and never offers foreign proof", const head = await store.loadAuthority(); if (head.status !== "loaded") assert.fail("missing fixture"); await store.commitAuthority({operation_id: "foreign-lease", expected_provider_revision: head.provider_revision, - events: [], receipts: [], next_projection: {...head.head, handoff_mode: "legacy", + events: [], receipts: [], next_projection: {...head.head, handoff_mode: "hard_lease", leases: [{todo_id: "todo_a", owner: "agent-b", idempotency_key: "foreign-key", version: 3, lease_epoch: 1, status: "active", expires_at: "2026-09-06T00:00:00Z", write_scopes: []}]}}); const before = await store.loadAuthority(); @@ -1073,10 +1082,10 @@ test("recovery preserves stale-proof rejection and never offers foreign proof", test("inactive history does not invent an executable lease acquisition", async () => { for (const [overrides, intent, mode, expected] of [ - [{status: "blocked"}, {}, "legacy", "resolve_acquire_rejection"], - [{claimed_by: null}, {}, "legacy", "reconcile_lease_owner"], - [{}, {required_capabilities: ["code_review"]}, "legacy", "resolve_lifecycle_edit"], - [{}, {status: "blocked"}, "legacy", "resolve_lifecycle_edit"], + [{status: "blocked"}, {}, "hard_lease", "resolve_acquire_rejection"], + [{claimed_by: null}, {}, "hard_lease", "reconcile_lease_owner"], + [{}, {required_capabilities: ["code_review"]}, "hard_lease", "resolve_lifecycle_edit"], + [{}, {status: "blocked"}, "hard_lease", "resolve_lifecycle_edit"], [{status: "blocked"}, {}, "soft_claim", "resolve_acquire_rejection"], ] as const) { const {store, request} = await seeded(overrides); @@ -1102,7 +1111,7 @@ for (const blocker of ["scope", "acceptance"]) test(`recovery respects ${blocker const other = todo({todo_id: "todo_other", claimed_by: "agent-b"}); const todos = [...head.head.todos as Record[], other]; await store.commitAuthority({operation_id: "scope-conflict", expected_provider_revision: head.provider_revision, - events: [], receipts: [], next_projection: {...head.head, handoff_mode: "legacy", todos, + events: [], receipts: [], next_projection: {...head.head, handoff_mode: "hard_lease", todos, todo_read_model: {schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, todo_count: 2, records_sha256: canonicalAuthoritySha256(todos), contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}, leases: [ diff --git a/tests/presentation/test_chat_bundle.py b/tests/presentation/test_chat_bundle.py index 07f83fec3b..339fd86265 100644 --- a/tests/presentation/test_chat_bundle.py +++ b/tests/presentation/test_chat_bundle.py @@ -240,8 +240,12 @@ def test_same_asset_name_different_bytes_cannot_overwrite_current(source, tmp_pa @pytest.mark.parametrize("checksum_valid", [True, False]) +@pytest.mark.parametrize( + ("target", "predecessor"), + [("v1.1.0", "v1.0.5"), ("v2.0.0a1", "v1.2.0")], +) def test_release_predecessor_is_stable_older_and_checksum_verified( - tmp_path, monkeypatch, checksum_valid + tmp_path, monkeypatch, checksum_valid, target, predecessor ): def release(tag, *, draft=False, prerelease=False): return { @@ -257,6 +261,7 @@ def release(tag, *, draft=False, prerelease=False): release("v1.0.5"), release("v1.1.0"), release("v1.2.0"), + release("v2.0.0"), release("v1.0.9", draft=True), release("v1.0.8", prerelease=True), release("desktop-main-123"), @@ -290,13 +295,13 @@ def download(command, **kwargs): ), ) if checksum_valid: - builder.release_build("v1.1.0", "fixture/repository") + builder.release_build(target, "fixture/repository") assert builds == ["old code"] else: with pytest.raises(RuntimeError, match="checksum mismatch"): - builder.release_build("v1.1.0", "fixture/repository") + builder.release_build(target, "fixture/repository") assert builds == [] - assert downloads == ["v1.0.5"] + assert downloads == [predecessor] def test_release_predecessor_supports_prerelease_target(monkeypatch): diff --git a/tests/test_delegation_lease_lifetime.py b/tests/test_delegation_lease_lifetime.py index 850fc39950..32c58634a5 100644 --- a/tests/test_delegation_lease_lifetime.py +++ b/tests/test_delegation_lease_lifetime.py @@ -435,3 +435,39 @@ def test_real_stop_leaves_a_newer_generation_alone(service, monkeypatch): assert receipt["stop"]["settled"]["lease"] == "not_owed" held = inspect(runner)["lease"] assert held["status"] == "active" and held["idempotency_key"] == "new-execution" + + +@pytest.mark.parametrize("status", ["prepared", "running"]) +def test_retired_policy_blocks_original_delegation_before_host_launch(service, monkeypatch, status): + root, runner = service + monkeypatch.setattr(runner, "_spawn", lambda _: None) + operation_id = "retired-policy-" + status + runner.start("analysis", operation_id, brief()) + path = runner.path(operation_id) + row = _read(path) + if status == "running": + row["status"] = status + row["task_lease"] = {"required": False, "handoff_mode": "legacy"} + runner._fenced_write(path, row) + # Original operation records may outlive a pre-cutoff ownership snapshot. + # Install those historical facts only in this disposable test provider. + program = """ +import {openLocalAuthorityStore} from "./loopx/control_plane/coordination/local_authority_provider.ts"; +const [root, goal] = process.argv.slice(1); +const store = await openLocalAuthorityStore(root, goal); +const state = await store.loadAuthority(); +const result = await store.commitAuthority({expected_provider_revision: state.provider_revision, + operation_id: "retained-policy-fixture", events: [], receipts: [], + next_projection: {...state.head, handoff_mode: "legacy"}}); +if (result.status !== "applied") throw new Error(JSON.stringify(result)); +""" + installed = subprocess.run(["node", "--no-warnings", "--experimental-sqlite", "--experimental-strip-types", + "--input-type=module", "-e", program, str(runner.root), runner.goal_id], + capture_output=True, text=True, timeout=30) + assert installed.returncode == 0, installed.stderr + before = read_canonical_todos_if_promoted(runtime_root=runner.root, goal_id=runner.goal_id, include_leases=True) + with pytest.raises(ValueError, match="historical only"): + runner._execute(path, row, runner.binding("analysis")) + assert not list(root.rglob("host-invocations")), "an old no-lease record must not start its model Host" + assert read_canonical_todos_if_promoted(runtime_root=runner.root, goal_id=runner.goal_id, include_leases=True) == before + assert _read(path)["status"] == status diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index 915ea98d54..0eed761ed1 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -712,10 +712,11 @@ def write_source_journal() -> None: assert calls == {"writeback": 0, "spend": 0, "scheduler": 0} -def test_host_result_requires_bounded_public_material_fields() -> None: +@pytest.mark.parametrize("field", ["raw_trajectory", "agent_vision"]) +def test_host_result_requires_bounded_public_material_fields(field: str) -> None: plan = _plan() result = _host_result(plan) - result["raw_trajectory"] = "not allowed" + result[field] = "not allowed" validation = validate_loopx_turn_host_result(plan, result) @@ -723,6 +724,40 @@ def test_host_result_requires_bounded_public_material_fields() -> None: assert "unsupported host result fields" in " ".join(validation["errors"]) +@pytest.mark.parametrize("packet, expected_error", [ + (None, "cached agent_vision must be a JSON object"), + ([], "cached agent_vision must be a JSON object"), + ("not an object", "cached agent_vision must be a JSON object"), + ({"vision_patch": {"acceptance_summary": "x" * 421}}, "acceptance_summary"), +]) +def test_cached_host_result_revalidates_decoded_vision( + packet: object, expected_error: str, +) -> None: + plan = _plan() + result = _host_result(plan) + result.update(result_kind="replan_required", path_delta_mode="material_replan", + agent_vision=packet) + result.pop("vision_unchanged_reason", None) + + validation = validate_loopx_turn_host_result(plan, result, from_journal=True) + + assert validation["ok"] is False + assert expected_error in " ".join(validation["errors"]) + + +def test_cached_host_result_rejects_conflicting_vision_forms() -> None: + plan = _plan() + result = _host_result(plan) + result.update(result_kind="replan_required", path_delta_mode="material_replan", + agent_vision={}, agent_vision_json="{}") + result.pop("vision_unchanged_reason", None) + + validation = validate_loopx_turn_host_result(plan, result, from_journal=True) + + assert validation["ok"] is False + assert "cached vision cannot carry both packet forms" in " ".join(validation["errors"]) + + def test_child_receipt_schema_excludes_registered_peer_authority() -> None: schema = child_execution_receipts_json_schema() item_schema = schema["items"]