From 70676e45c30e08aa2c5458b9e2b3da785e8cc2be Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 12 Oct 2026 11:57:12 +0800 Subject: [PATCH 01/80] Preserve validated local freshness recovery candidate Signed-off-by: Tartar --- .../rfcs/loopx-overall-roadmap-v0.md | 25 + .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 16 + docs/project-agent-todo-contract.md | 25 + docs/reference/glossary.md | 6 +- .../goal-vision-replan-contract-v0.md | 308 ++++++++- .../project_lifecycle_refresh_state.py | 17 +- loopx/cli_commands/quota.py | 170 +++-- loopx/cli_commands/quota_context.py | 6 + loopx/cli_commands/quota_failure_report.py | 4 + loopx/cli_commands/quota_request.py | 4 + loopx/cli_commands/status.py | 21 +- loopx/cli_commands/support_control.py | 15 +- loopx/cli_commands/todo.py | 22 +- .../cli_commands/todo_argument_validation.py | 4 + loopx/cli_commands/todo_event.py | 7 +- loopx/cli_commands/todo_registration.py | 1 + loopx/cli_commands/turn.py | 10 +- loopx/cli_commands/turn_run_once.py | 116 +++- loopx/cli_commands/turn_todo_writeback.py | 8 + loopx/contract.py | 2 +- .../coordination_state_contract.generated.ts | 1 + .../coordination_state_contract_generated.py | 1 + .../coordination_state_contract_v0.json | 1 + .../coordination/local_authority_runtime.ts | 17 +- .../coordination/todo_delivery_context.ts | 177 +++++ .../coordination/todo_terminal_lifecycle.ts | 90 ++- .../coordination/todo_terminal_reentry.ts | 18 +- loopx/control_plane/effect_runtime.py | 13 +- .../control_plane/effect_runtime_handlers.ts | 4 + loopx/control_plane/effect_runtime_server.ts | 1 + .../control_plane/effect_runtime_snapshot.ts | 18 +- .../goals/checkpoint_authority.ts | 57 +- .../control_plane/goals/checkpoint_commit.ts | 133 +++- .../goals/checkpoint_context_io.py | 621 +++++++++++++++++- .../goals/checkpoint_read_context.ts | 376 ++++++++++- loopx/control_plane/heartbeat/agent.py | 8 +- loopx/control_plane/heartbeat/builder.py | 4 +- .../control_plane/host_adapter_settlement.py | 33 +- loopx/control_plane/quota/goal_boundary.py | 33 +- .../control_plane/quota/heartbeat_receipt.py | 8 + .../quota/heartbeat_receipt_identity.ts | 38 ++ loopx/control_plane/quota/refresh_recovery.ts | 10 + loopx/control_plane/quota/settlement.py | 12 + loopx/control_plane/quota/settlement_cli.py | 20 + .../quota/settlement_readback.ts | 34 + loopx/control_plane/runtime/document_io.py | 21 +- .../runtime/private_permissions.py | 193 ++++++ .../scheduler/execution_context.py | 7 +- loopx/control_plane/status/collection.py | 3 + loopx/control_plane/status/first_delivery.py | 26 + loopx/control_plane/todos/completion_state.ts | 20 +- .../todos/completion_transaction.py | 9 +- .../todos/completion_transaction.ts | 20 +- .../todos/completion_validation.py | 8 +- .../todos/completion_validation_plan.ts | 62 +- .../todos/completion_validation_store.py | 10 +- loopx/control_plane/todos/contract.py | 14 +- loopx/control_plane/todos/field_update.ts | 7 +- loopx/control_plane/todos/mutation_api.py | 3 + .../control_plane/todos/mutation_response.py | 3 + .../todos/projection_document.py | 23 +- .../todos/provider_terminal_lifecycle.py | 95 ++- .../todos/read_legacy_codec.json | 5 +- .../turn_driver/checkpoint_continuation.py | 115 ++++ loopx/control_plane/turn_driver/codex_cli.py | 14 + .../turn_driver/codex_operation_host.py | 23 +- .../turn_driver/codex_sessions.py | 10 +- .../turn_driver/command_validation.py | 4 +- loopx/control_plane/turn_driver/executor.py | 215 +++++- .../turn_driver/first_delivery.ts | 54 ++ .../turn_driver/host_candidate.py | 19 + .../turn_driver/host_todo_completion.ts | 101 ++- loopx/control_plane/turn_driver/settlement.py | 6 +- .../control_plane/turn_driver/turn_journal.ts | 2 +- .../turn_driver/turn_journal_effects.ts | 95 ++- loopx/dsh_goal_mode/turn_host_adapter.py | 7 +- loopx/file_lock.py | 15 +- loopx/goal_mode_mcp.py | 109 ++- loopx/quota.py | 18 +- loopx/semantics/project_registry_io.py | 8 +- loopx/semantics/vocabulary_v0.json | 14 +- loopx/state_refresh.py | 56 +- scripts/generate_turn_contract.py | 11 +- .../test_turn_contract_generation.py | 14 + .../test_quota_closeout_query_failure.py | 2 +- tests/control_plane/checkpoint_process.py | 5 +- .../control_plane/host_checkpoint_fixture.py | 48 ++ .../test_checkpoint_provider_fence.py | 24 +- .../test_checkpoint_read_context.py | 135 +++- tests/control_plane/test_cli_output_budget.py | 56 +- .../test_declared_terminal_settlement_cli.py | 478 +++++++++++++- .../test_default_checkpoint_continuation.py | 301 +++++++++ .../test_default_guard_admission.py | 184 ++++++ .../test_default_result_freshness.py | 451 +++++++++++++ .../test_default_turn_recovery.py | 350 ++++++++++ .../test_effect_runtime_host_permission.py | 17 + .../test_first_delivery_faults.py | 66 ++ .../control_plane/test_first_delivery_mcp.py | 92 +++ .../test_first_delivery_observation.py | 92 +++ .../test_first_delivery_terminal.py | 128 ++++ .../test_heartbeat_agent_input.py | 3 + .../test_heartbeat_notification_rule.py | 17 +- .../test_heartbeat_registry_route.py | 23 +- ...host_adapter_settlement_step_vocabulary.py | 56 +- ...test_quota_authority_settlement_journey.py | 163 +++-- .../test_quota_cli_projection.py | 17 + .../test_quota_settlement_cli.py | 142 ++-- .../test_scheduler_execution_context.py | 26 + .../test_todo_completion_validation.py | 20 +- .../test_todo_projection_recovery.py | 13 + .../test_windows_snapshot_privacy.py | 127 ++++ tests/control_plane/windows_private_acl.py | 26 + .../automation_cadence.test.ts | 1 + .../checkpoint_commit_probe.ts | 31 +- .../checkpoint_read_context.test.ts | 306 ++++++++- .../content_digest_single_owner.test.ts | 1 + .../effect_runtime_snapshot.test.ts | 41 +- .../first_delivery_projection.test.ts | 62 ++ .../goal_acceptance_authority.test.ts | 8 +- .../host_todo_completion.test.ts | 40 ++ .../quota_settlement_readback.test.ts | 43 ++ .../terminal_source_conformance.ts | 209 +++++- .../todo_completion_state.test.ts | 21 + .../todo_completion_transaction.test.ts | 12 + .../turn_journal_effects.test.ts | 80 +++ tests/test_codex_operation_host.py | 62 +- tests/test_file_lock_windows_atomic.py | 128 ++++ tests/test_goal_mode_mcp_settlement.py | 73 +- tests/test_kunluncode_goal_mode.py | 5 + tests/test_usage_ping.py | 22 + 130 files changed, 7647 insertions(+), 514 deletions(-) create mode 100644 loopx/control_plane/coordination/todo_delivery_context.ts create mode 100644 loopx/control_plane/runtime/private_permissions.py create mode 100644 loopx/control_plane/status/first_delivery.py create mode 100644 loopx/control_plane/turn_driver/checkpoint_continuation.py create mode 100644 loopx/control_plane/turn_driver/first_delivery.ts create mode 100644 tests/control_plane/host_checkpoint_fixture.py create mode 100644 tests/control_plane/test_default_checkpoint_continuation.py create mode 100644 tests/control_plane/test_default_guard_admission.py create mode 100644 tests/control_plane/test_default_result_freshness.py create mode 100644 tests/control_plane/test_default_turn_recovery.py create mode 100644 tests/control_plane/test_first_delivery_faults.py create mode 100644 tests/control_plane/test_first_delivery_mcp.py create mode 100644 tests/control_plane/test_first_delivery_observation.py create mode 100644 tests/control_plane/test_first_delivery_terminal.py create mode 100644 tests/control_plane/test_windows_snapshot_privacy.py create mode 100644 tests/control_plane/windows_private_acl.py create mode 100644 tests/control_plane_ts/first_delivery_projection.test.ts create mode 100644 tests/test_file_lock_windows_atomic.py diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 0a591ec476..9527428c75 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -1439,6 +1439,31 @@ independent admission without weakening task-level validation. See the [acceptance contract](../../reference/goal-acceptance-observations.md#owner-authorized-contract-v0). This narrows a local recovery gap, not the full R1/R2 coordination acceptance. +**R1 first delivery checkpoint.** New ordinary CLI/MCP/managed work admissions +persist their original result basis before execution and enforce freshness at +the existing Todo owner. Default identity acquisition restores the unfinished +Turn; settled work releases a new work identity. Relevant Todo, dependencies, +acceptance and source changes require candidate revalidation, while unrelated +revisions do not. Original successful receipts survive response loss and cold +recovery. Legacy Markdown uses its existing locks; File/SQLite and PostgreSQL +result transactions retain their typed head/CAS owner. See the +[operating protocol](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness). +New supported admissions also persist the default current-Agent checkpoint +continuation contract. Required checkpoints use the actual post-result/full +frontier read; MCP returns it to the current tool caller, while managed execution +resumes its original Host/session. Noncompletion outcomes retain their original +receipt and use the existing supplement owner. Related direction changes require +only a new direction judgment, preserving successful results and single spend. +Ordinary terminal completion uses the existing same-Turn upgrade after checkpoint +and spend. Historical result-only, explicit first-delivery and supplement Turns +retain their persisted contract. Complete changed-frontier recovery, packaged +frontend/Lark journeys, mixed-version rollback and live-model utility remain +unfinished first-stage acceptance. Source-session remains lifecycle-only and +ordinary Python PostgreSQL execution still lacks its service-owned factory. +An independent reviewer needs a separately registered optional contract. These +defaults do not close R1, certify Goal completion, establish distributed atomicity +or demonstrate model-outcome gains. + **R1 transaction checkpoint.** Team-plan admission and whole-batch planning now live in `work_items/team_plan.ts`. Confirmation assigns all admitted lanes in one write with a durable operation receipt; identity is proposal + lane, never Todo text. File/SQLite authority uses the existing CAS and receipt owner; legacy Markdown writes the records and immutable receipt together under its existing fence and lock. Exact replay reads historical results even after a receiver changes, completes or deletes work. A precommit failure creates no lane prefix, and pending canonical display delivery requires recovery before Chat reports verified success. The card names partial assignments and gaps; quota/stop remain advisory and an explicit enforcement claim is rejected. Agent-originated settlement binds the same state basis at its journal's first write and re-reads it at settlement; a plan whose basis is missing or moved, or whose every lane is a gap, is a typed failed receipt that creates no Todo and replays unchanged. This closes the local assignment/retry portion of F4, not R1's collaboration acceptance. Registered receivers are assigned without being impersonated as authors; agent-originated settlement cannot assign another peer without owner confirmation. Assignment does not attest receiver adoption, a lease, execution, dependency consumption or independent acceptance. Do not add a second confirmation to ordinary already-authorized work. Gap resolution requires new explicit intent; replay must not silently extend the confirmed subset. The fingerprint binds current local state and canonical revision, not a full shared Goal-intent transaction. Local registry and active-state guards now remain held through the typed commit: native token claims survive caller exit, changed source bytes reject new assignments, and expired handoffs can only replay an exact committed receipt. Registry contention returns a retryable failure without creating work; canonical display recovery runs after the source guards release. Failed retry-safe Team Plan proposals retain their original review identity after Goal or manager workspace reload; retry revalidates current source facts and recovers existing assignments rather than generating a new proposal. Stale, gated and unsafe failures remain unavailable for this recovery, including in the already-open drawer. Chat recovery resolves the current registered Goal and source before receipt lookup; a removed Goal or unreadable bound source can block recovery. Canonical display recovery follows the current registered source, so this slice does not recover a deleted source binding. R2/R3/R4 still own executor qualification, receiver adoption/result return and shared intent/authorization; the cross-host Turn lease is not a plan barrier. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index b029f8b231..98ccb76494 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -940,6 +940,22 @@ lifecycle。 见[验收合同](../../reference/goal-acceptance-observations.md#owner-authorized-contract-v0)。 这修复局部恢复缺口,不代表 R1/R2 协同整体验收完成。 +**R1 首次交付检查点。** 新的普通 CLI/MCP/managed 工作准入在执行前持久化原始结果依据, +并由既有 Todo owner 强制验证其新鲜度。默认身份获取恢复未完成的原 Turn; +已结算工作释放后才获取新的工作身份。相关 Todo、依赖、验收及来源变化要求重新验证候选, +无关 revision 不触发失效。原成功回执在响应丢失和冷恢复后保留。legacy Markdown 复用原锁; +File/SQLite 与 PostgreSQL 结果事务保留其类型化 head/CAS owner。见 +[操作协议](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness)。 +新的已支持准入同时持久化默认的当前 Agent 检查点续跑合同。必需检查点读取真实的提交后 +完整 frontier;MCP 将其交给当前工具调用者,managed 执行则恢复原 Host/session。 +未完成结果保留原回执并复用既有 supplement owner。相关方向变化只要求重新判断方向, +保留成功结果和一次扣额。普通终态完成在检查点及扣额后复用原 Turn 的既有升级路径。 +历史 result-only、显式 first-delivery 和 supplement Turn 保留各自已持久化的合同。 +完整的 frontier 变化恢复、打包 frontend/Lark 旅程、混合版本回滚和真实模型效用,仍是未完成的 +首阶段验收。source-session 仍只支持生命周期操作,普通 Python PostgreSQL 执行仍缺少 +服务 owner 提供的 factory。独立 reviewer 需要另行注册的可选合同。这些默认行为不关闭 R1, +不认证 Goal 已完成,不建立分布式原子性,也不证明模型结果收益。 + **R1 事务检查点。** 团队计划准入与整批规划现在归 `work_items/team_plan.ts`。确认后,全部已准入 lane 与持久操作回执一次提交;身份由 proposal + lane 决定,不再由 Todo 文本决定。File/SQLite 权威复用现有 CAS 与回执 owner;legacy Markdown 在原有 fence 和锁内同时写入任务和不可变回执。同一操作重试只读历史结果,接收者后来修改、完成或删除任务也不会触发重建。提交前失败不会留下部分 lane;canonical 展示投递仍 pending 时,Chat 必须恢复后才能报告验证成功。卡片列出部分分配及缺口;quota/stop 是参考,显式强制声明会被拒绝。Agent 发起的结算在 journal 首次写入时绑定同一状态基线并在结算时重读;基线缺失或已变动、或全部 lane 均为缺口的计划,记录为类型化的失败回执,不创建 Todo,重放结果不变。 这完成 F4 的本地分配/重试部分,不等于 R1 协同验收。注册接收者可以被分配任务,但不会被冒充为作者;Agent 发起的结算未经业主确认不能给另一 peer 分配任务。分配不证明接收者采纳、lease、执行、依赖消费或独立验收。普通已授权工作不应普遍增加第二次确认。解决缺口需要明确的新意图;重放不能静默扩展原确认子集。fingerprint 绑定当前本地状态与 canonical revision,不是完整共享 Goal 意图事务。本地 registry 与活动状态锁现在持续保护到类型化提交结束:native token claim 在调用进程退出后仍有效;源字节变化拒绝新分配;失效交接只能重放精确匹配的已提交回执。registry 竞争返回可重试失败,不创建任务;canonical 展示恢复在源锁释放后执行。允许安全重试的失败 Team Plan 提案在 Goal 或管家工作区刷新后仍保留原评审身份;重试重新核验当前源事实,并恢复已有分配,不生成新提案。stale、gated 和不允许安全重试的失败仍不提供此恢复入口,已打开的抽屉也遵循同一判定。Chat 恢复在查找回执前解析当前注册 Goal 与源绑定;Goal 被移除或绑定源不可读时可能阻断恢复。canonical 展示恢复使用当前注册源,本次修复不恢复已删除的源绑定。R2/R3/R4 仍负责执行器资格、接收者采纳/结果返回及共享意图/授权;跨主机 Turn lease 不是计划屏障。 diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 1c06f8d9ae..2bd97bec7d 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -645,6 +645,31 @@ relabeled `same_agent_non_delivery` to escape a contradictory internal ordering. This intentionally removes the old task-class-dependent CLI prerequisite while retaining declared validation, claim/lease checks, identity and terminal fences. +For quota-admitted RESULT on qualified File/SQLite Todo authority, completion +validation retains the original work identity, declared check and candidate. +A durably passed check survives a later RESULT IO failure: retry the unchanged +request to recover that outcome. Changed candidate evidence, actor, lease or +declaration cannot reuse it. If execution may have started without a durable +outcome, completion and replacement `delivery_result` reads fail closed until +the original outcome is reconciled; a new read ID cannot erase that uncertainty. +A known failed check remains retryable. Goal acceptance and terminal accounting +remain independent gates; these qualified lanes do not certify an unqualified +caller/provider combination. Legacy terminal closeout reuses the bound committed +RESULT and original validation, requires the same original quota writeback/spend +receipts, and recovers only the verified lease generation. A preflight abort must +not carry its retired lock token into a new final verification. A held original +fence may finish lease retirement after Todo commit; it cannot authorize a new +lease or bypass changed ownership, declarations, candidate evidence or accounting. + +已准入的 File/SQLite RESULT 会保留原工作身份、声明和候选的验收结果。 +验收已持久成功、随后 RESULT 写入失败时,同一请求可恢复原结果。执行可能已开始但 +结果尚未持久化时,完成和替换 `delivery_result` 读取都会拒绝;须先核对原执行结果, +不能用新的 read ID 消除不确定状态。已知失败的检查仍可重试;Goal 验收和 terminal +结算仍是独立门槛,其他 caller/provider 的完整资格须另行证明。 +Legacy 收尾复用原已提交 RESULT 和验收,仍须原写回、扣额回执全部成立。 +预检关闭后,最终 fence 必须保存新锁 token;Todo 提交后丢响应,只能恢复原已验证 +fence 和 lease 世代,不得重领租约、恢复 claim 或绕过当前 ownership/accounting。 + This keeps the active checklist honest without making LoopX a heavyweight project-management state machine. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 68dda6aa90..f42c286e4f 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -123,12 +123,12 @@ Authority handoff mode between agents. ## host_adapter_settlement_step_kind -Ordered provider steps of one host-adapter settlement plan. The TypeScript owner authors the plan and the Python transport validates the plan it receives against the same sequence, so the two runtimes must carry the same order. Distinct from settlement_step_kind: that vocabulary names the turn settlement phases, whose validation phase is never executed, while every kind here is a command the host adapter runs. +Provider step kinds used by host-adapter settlement plans. TypeScript authors each phase's ordered plan and Python validates that phase's sequence before execution. Ordinary settlement uses guard, lifecycle_completion, durable_writeback, quota_spend and optional terminal_closeout; first-delivery preparation uses delivery_context or direction_context in its phase-specific sequence. Distinct from settlement_step_kind, which names the turn settlement phases including seeded validation. - Tier / 层级: `cross_runtime`; status / 状态: `canonical`. -- python: [`HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE`](../../loopx/control_plane/host_adapter_settlement.py). +- python: [`HOST_ADAPTER_SETTLEMENT_STEP_KINDS`](../../loopx/control_plane/host_adapter_settlement.py). - typescript: [`HOST_ADAPTER_SETTLEMENT_STEP_KINDS`](../../loopx/control_plane/turn_driver/host_todo_completion.ts). -- Values / 值: `guard`, `lifecycle_completion`, `durable_writeback`, `quota_spend`, `terminal_closeout`. +- Values / 值: `guard`, `lifecycle_completion`, `durable_writeback`, `quota_spend`, `terminal_closeout`, `delivery_context`, `direction_context`. ## loop_disposition diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index baf92ac129..0105d21791 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -971,11 +971,317 @@ must satisfy the checkpoint before terminal closeout; it neither re-authors the outcome nor spends a second time. Never invent an unchanged reason to clear a gap. Typed in-flight continuations keep their existing exemption. + + +### Default result freshness and current-Agent checkpoint continuation + +Result freshness now belongs to ordinary new work admissions. Local-registry +CLI `quota should-run`, MCP `should_run` and managed `turn run-once` persist the +original Turn's complete result basis before exposing or dispatching work. +An unbound observation carries no selected work basis. Choosing a Todo through +its returned selection command upgrades that original receipt using the same +typed freshness admission policy as a direct selection. Historical already-bound +receipts retain their original policy; replay cannot silently enroll them. +CLI allocates or restores a Turn when no Turn parameter is supplied; ordinary +`todo complete` restores that Agent/Todo's original identity and read key. +Ambiguous pending Turns fail closed instead of selecting the latest key. +Automatic CLI/MCP identity acquisition serializes the existing selection, +heartbeat append and work-input persistence under one scoped IO fence. Lock +timeout remains a failure; it cannot allocate a second Turn. Explicit Turn +callers retain their existing identity and lease rules; mixed explicit/automatic +calls do not gain a global single-Turn guarantee. MCP completion and vision +recovery restore the admitted identity; the older deterministic host identity +remains a compatibility fallback when there is no default work admission. +Legacy Markdown uses its existing writer locks; File/SQLite use their existing +typed Todo head/CAS owner. PostgreSQL result-only reads use the provider head and +identity, with the final check on the native Todo transaction head. Source-session +remains a lifecycle-only profile under its existing runtime admission restriction. +No capability, provider or actor write authority is added. Required checkpoints +continue the current Agent after the durable result; typed exemptions add no +direction call. +Default entrypoint qualification currently covers local-registry Legacy Markdown, +File and SQLite. PostgreSQL has native result transaction/head/CAS conformance; +the ordinary Python CLI/MCP/managed runtime does not supply the existing +service-owned PostgreSQL factory. A PostgreSQL selector therefore fails closed +without legacy fallback; this slice does not add that product integration. +Packaged frontend and Lark recovery journeys are also unqualified. These are delivery +limits, not an option to silently bypass freshness on a protected admission. +Manual Todo lifecycle operations without any work admission retain their existing +permission and validation contract. They have not captured an Agent work basis +and are not qualified result-freshness execution. Absence of an admission is not +proof of a historical Turn. Once work is protected, manual omission of identity +or read key cannot downgrade its result obligation. + +For ordinary admitted CLI work, neither command needs a first-delivery switch, +Turn parameter or manually copied read key: + +```sh +loopx --format json quota should-run --goal-id example --agent-id agent-a \ + --todo-id todo_page --codex-app +# Work and validate against the returned delivery_result_context. +loopx --format json todo complete --goal-id example --agent-id agent-a \ + --todo-id todo_page --note "Candidate verified against the original work basis." +``` + +Related Todo, transitive dependency, acceptance, source or lease changes reject +the old candidate eligibility before result effects. Unrelated provider revisions +and display-only changes do not require another judgment. Native command and +acceptance validation retain their existing authority and bind the actual candidate +and issued validation receipt to the original read identity. Managed validation +also receives that work basis. A new read cannot relabel cached validation. + +Native Todo display loss does not erase native Todo authority. The Goal's +frontmatter and non-Todo objective/acceptance prose still belong to the Goal +document owner. Losing that whole document blocks full-basis execution; restoring +Todo sections alone does not restore Goal intent. Such projections carry +`goal_source_completeness=todo_projection_only`; the exact older unmarked +recovery codec has the same meaning. Checkpoint admission rejects this state. +Use the existing `loopx-project` recovery route to locate and restore actual +owner source or backup, then retry the original Agent/Turn. Preserve an already +admitted heartbeat receipt; failed basis reads do not dispatch work or spend. +Native Todo read/write and lease operations keep their existing authority. +No automatic reconstruction of missing Goal narrative is claimed. + +The initial default quota JSON packet grew because it carries the work basis +consumed by the Agent. Matched fixed-fixture measurements against the pre-adoption +checkpoint branch were 18,096 to 22,993 characters (small), 34,553 to 37,912 +(crowded), and 21,082 to 22,704 (multi-Agent). These are output costs, not model +token, latency or outcome measurements. Disabled reward-memory recall keeps its +decision/authority flags and a detail command; duplicate disabled-route diagnostics +remain available through `--include-detail agent-todos`. Enabled, failed and actual +recall contexts remain complete. Only the measured quota JSON regression budgets +changed in that initial adoption; fixture populations remained unchanged. +The later representation repair retains those frozen ceilings: default +`quota should-run`, `turn plan` without transaction detail, `diagnose`, and +non-full `heartbeat-prompt` JSON use compact whitespace. Explicit quota details, +full heartbeat and transaction detail retain pretty JSON. All parsed fields, +authoring constraints, evidence, read identities and routed actions remain +complete; consumers must parse JSON rather than depend on indentation. Markdown +remains readable. Brief and ordinary thin share the current-contract worker; +captured TurnEnvelope execution retains its independent saved-observation rules. +These representation changes do not reduce the work-basis obligation or prove +model cost or utility. + +After a stale result, MCP calls `should_run(revalidate_todo_id=TODO)` and revalidates +the retained candidate before retrying ordinary `complete_task`; keys travel +automatically. CLI reads `checkpoint-context --purpose delivery_result` for the +returned original Turn, revalidates, then retries ordinary completion. Managed +`--resume-turn-key TURN --retry-failed-turn` retains the candidate and original +session, rereads and invokes the current Agent to validate it. The retained +candidate and revalidation instruction survive receipt/journal update and cold +dispatch recovery. Exact successful Todo receipts take precedence over later +basis changes, including loss of the result response before refresh/writeback. +For historical result-only admissions, if a managed terminal Todo is rejected after refresh/spend, +those receipts and the outer transaction prefix remain fixed. The original Turn +records bounded Host/validator revalidation progress; retry resumes that progress +and then only the pending Todo closeout. It cannot rewind or repeat settlement. +Host or validation failure keeps the terminal result pending and preserves the +settled receipts. This does not supply the remaining changed-frontier redirection +or independent Goal acceptance contract. + +New supported admissions also persist `checkpoint_continuation=current_agent_v1` +in the original heartbeat receipt. Ordinary completion commits the result before +reading the complete post-result Goal frontier. The current Agent must author a +new Vision patch or an unchanged reason bound to that read. An unchanged reason +requires an existing Vision baseline. Pre-result judgments are not accepted as +this judgment; a new key cannot relabel a cached response. The existing checkpoint +owner retains cadence, material-trigger, in-flight and blocked-retry exemptions. + +MCP `complete_task` returns `direction_pending`, `completed=true` and +`settlement_complete=false` with the actual context and original Turn identity. +The current tool caller retains its original completion arguments, consumes that response and continues +`complete_task` with its returned `read_context_id` and new decision, without +`first_delivery=true`. `review_task_vision` rereads on stale direction. CLI callers +use the equivalent checkpoint-context/refresh commands below for the original +Turn; the CLI transports facts and cannot manufacture an Agent judgment. + +Managed `turn run-once` automatically dispatches `phase=checkpoint_direction` +through the original Host after committing a completion result, or after retaining +an original noncompletion outcome requiring a supplement. Generic adapters must +consume `checkpoint_context` and echo its read ID with the decision; older adapters +that only implement the work-result response remain pending until adapted. +Codex resumes the exact original native session, including a fresh-per-Turn work +session. Operation transport offers no operation tools during direction. DSH +retains the original lineage/iteration session ID. No independent reviewer is +required or invoked. Host failures use the existing retry policy; stop and +permission failures retain the result without a fabricated judgment. + +Cold recovery uses `turn run-once --resume-turn-key TURN --retry-failed-turn`. +Successful result, checkpoint and quota receipts win over a lost response. A +stale direction invalidates only its judgment; implementation and validation stay +cached. Ordinary no-followup completion preserves its original terminal intent, +commits the result with an intermediate continuation, then uses the existing +same-Turn terminal upgrade after checkpoint and spend. The Todo owner persists +`completion_requested_continuation=no_followup` when deferring a stored closeout; +this intent is not a terminal recovery receipt and applies only to its original +completion identity. An explicit MCP `no_follow_up` request is replayed with its +original completion arguments; the facade does not infer that intent from a new +frontier or a replacement Agent. This does not certify Goal +completion or supply the complete changed-frontier scheduling contract. + +Historical receipts without this mode retain their prior contract, including +result-only and already-spent missing-checkpoint supplement recovery. Unknown +modes and downgrade attempts fail closed. The explicit `first_delivery` v2 +protocol remains compatible, including its historical protected-terminal limit. +Rolling back to an older writer while a new-mode Turn is pending is unsupported: +retain the new writer to recover those Turns, or restore a consistent pre-admission +snapshot through the existing owner procedure. Do not delete mode fields or +rewrite receipts to disable protection. Packaged frontend/Lark operation, complete +terminal redirection, mixed-version rollback and live-model utility remain +separate acceptance. These defaults do not complete the maintainer's first stage. + +The stages are independent: prepare and validate the candidate, commit the +allowed result operation, read the resulting state, judge the direction outside +source/provider locks, commit that direction, then finish ordinary settlement. +An independent reviewer +requires its own registered input, result and recovery contract. +Neither Todo completion nor a satisfied direction checkpoint certifies Goal +completion. + +The ordinary quota guard returns the persisted basis before work. To explicitly +reread after a stale rejection, or to enter the separate direction protocol: + +```sh +loopx --format json checkpoint-context --goal-id example --agent-id agent-a \ + --todo-id todo_page --turn-instance-id turn-1 --purpose delivery_result +``` + +Validate against that basis. Explicit protocol callers may pass its `read_context_id` +as `--delivery-read-context`; default callers restore the persisted key. Retain the Goal, Agent, Todo, Turn and original +candidate/validation options. The Todo owner checks historical receipts first, +compares relevant facts against its current authority head, and commits an exact +delta and receipt with revision CAS. Unrelated revisions may retry without +another model decision. A related change requires rechecking the candidate. + +After the allowed result commit, read the direction: + +```sh +loopx --format json checkpoint-context --goal-id example --agent-id agent-a \ + --todo-id todo_page --turn-instance-id turn-1 --purpose first_delivery \ + --decision-scope goal +``` + +Read the returned basis and produce a new Vision or unchanged reason. Submit it +using the existing delivery fields plus `refresh-state --first-delivery +--checkpoint-read-context ID --progress-scope goal`. Both `agent_lane` and `goal` +scopes bind complete membership and dependency closure. Reading more work grants no authority to write another Agent's work. +Final submission protects registry, Goal state, index and the real provider +through append. It rejects `--next-action` and Codex session usage booking as +compound effects; explicit usage observations can accompany the run. Lock order: +index, registry, maintenance, Todo projection, state, then provider. Model calls +and validation commands stay outside this section. + +MCP callers use `complete_task(first_delivery=true)`. The v2 protocol first +returns `result_review_pending` with a result context. Validate, then call again +with `delivery_read_context_id`; it commits the ordinary completion and returns +`direction_pending`. Judge that context and call again with both +`delivery_read_context_id` and `read_context_id`, plus the new Vision/reason. +`review_task_vision(first_delivery=true)` can reread a rejected direction; it +never attaches a new token automatically to an old Vision. Protected +`no_follow_up=true` is rejected before any effects are admitted in that explicit +direction protocol. Its native no-followup rejection applies after historical +receipt recovery. Default result-only protection uses the existing legal terminal +owner and adds no blanket no-followup rejection. Ordinary v0/v1 completion now +checks default result freshness. Historical admissions retain their prior ordering; +new current-Agent admissions use result → direction → refresh/spend → terminal. +An indexed successful direction can settle its original owed quota slot once. +A new terminal upgrade still checks the captured actual Goal source; a typed +Todo-only projection cannot authorize that write. Restore the owner's document +and resume the same Turn, retaining successful result and quota receipts. An +exact already committed terminal receipt is recovered before this new admission +check. This does not certify Goal acceptance or solve changed-frontier continuation. +The remaining changed-frontier stage must provide the legal transition for a changed +frontier after checkpoint or spend; retrying a refused terminal intent is not +that transition. + +Status and quota readbacks expose `first_delivery_progress`: result and +direction commits, quota spend, pending stage and recovery action. Shared and Agent-scoped +status mirror recovery text into the existing next-action field used by the +dashboard and channel projections. These receipt observations are neither new +workflow authority nor a Goal completion signal. + +Malformed or unreadable historical receipts are reported as scoped +`observation_errors`, never as proof that no recovery is pending. If the damaged +receipt cannot be attributed to a Turn, `observation_unavailable` retains the +Goal-scoped warning while leaving unrelated lanes' next actions intact. An +enrolled Turn whose original readback is uncertain stays `operation_unknown`; +recovery writes remain blocked until the original artifacts can be reconciled. +Observation reuses one complete canonical Todo snapshot across the discovered +identities; it does not truncate older pending work. + +Ordinary Todo completion recovers an exact terminal receipt before checking an +unreadable optional enrollment source, without changing its request fingerprint. +Managed recovery carries the original journal's work key after the Todo owner +confirms the same Turn's successful result. A subsequently missing or damaged +result read receipt cannot erase that commit or trigger another validation or +settlement. The optional direction enrollment still requires its own receipt. +For a new completion, the original Turn's verified ordinary writeback can prove +that a damaged shared supplement is optional. Missing or inconsistent history, +an enrolled writeback, or any result enrollment receipt remains +`checkpoint_commit_unknown`; unreadable JSON alone never proves non-enrollment. + +After a lost response, retry the original request. `checkpoint-context` for its +first-delivery identity verifies an indexed direction and artifacts before +returning `committed`. A proved empty append can retry after freshness validation. +JSON/Markdown without a complete consistent index remains +`checkpoint_commit_unknown`: preserve the original identity and artifacts for +operator reconciliation; another Turn cannot bypass that unresolved append. +The files, provider, quota and Git are not one transaction. + +Omitting first-delivery leaves the historical explicit enrollment inactive; it +does not disable default result freshness or a new admission's current-Agent +checkpoint continuation. Historical admissions without these policies retain +their original rules, including supplement recovery. New protected admissions cannot +downgrade through omitted identity/key, false flags or an older Host schema. +There is no per-command switch to disable an admitted result obligation. Before +rolling back a runtime, finish or reconcile pending protected Turns with a +compatible runtime; preserve original receipts and successful artifacts. This +does not add cross-host atomicity, Goal certification or model-quality guarantees. + +### 中文:本阶段边界与恢复 + +普通新执行默认保存结果依据并在 Todo owner 提交时检查新鲜度。 +CLI/MCP 自动携带原 Turn 和原 key;managed 在派发前持久保存依据。 +相关变化先保留候选、由当前 Agent 根据新依据重新验证,再提交原 Turn。 +重读及派发中途重启保留候选;成功结果的精确回执优先于当前依据变化。 +历史没有保护标记的准入继续使用原规则,不能靠省略参数降级新准入。 +新受支持准入默认采用 `checkpoint_continuation=current_agent_v1`;历史没有该 +模式的准入及显式 first_delivery 协议继续按原契约恢复。 +先读取结果依据并验证候选,由 Todo owner 检查原回执、当前依据及 CAS; +结果提交后再读取方向依据,由调用方提交当前 Vision 或 unchanged reason, +最后完成原 Turn 的 refresh/spend。共享机制不改变各 owner 的失败策略, +也不改变 Post-Writeback optional hook 的 isolate 与 quota 结算定义。 + +相关依据变化时,未提交结果必须重新验证候选;结果已经提交时只重新读取和 +判断方向。原 Turn、已提交结果和回执保持原身份,不能把旧判断贴上新 token。 +索引不完整或关键提交未知时保留原 artifacts,先协调原回执,不能换 Turn 绕过。 +明确的 `required=false/satisfied=true/decision=not_required` 沿用既有 checkpoint +豁免;缺少 checkpoint 记录不能被观察器猜成无需判断。 + +旧显式结果加方向协议仍不接纳 protected no-followup;新默认准入由既有 +terminal owner 先保留合法结果和原终结意图,完成当前 Agent 的方向判断与结算后 +沿同 Turn terminal upgrade 关闭。默认 managed 续接原 Host/session,required +checkpoint 会增加一次方向调用;typed 豁免不增加该调用。方向 stale 只重判方向, +不重复实施、验证或扣额。历史 refresh→spend→terminal 顺序保留。 +完整 changed-frontier 改向出口与混版本回退仍待独立验收;独立 reviewer +另行登记完整契约,没有方向专用尝试预算或通用回滚协议。 +status/quota 的回执投影可供既有前端及 Lark 读取,但不代表默认产品旅程已交付, +也不代表 Goal 已完成或模型效果已提升。 + +Code publication remains a separate operation. Qualify ref CAS against an +integration target that is not checked out; this protocol does not wrap direct +Git writes or promise concurrent working-directory isolation. If publication +responds ambiguously, read the integration status and exact current SHA, verify +that combined candidate, then use the existing `integration-branch sync +--candidate-ref SHA --execute` recovery. This can adopt the already published +candidate without moving the branch again. A clean merge is not task acceptance: +run the task verifier on that combined SHA before confirming its result. + ### Read basis for checkpoint-only recovery Missing-checkpoint supplementation now requires an explicit read receipt. This is a default admission change for both legacy and newly committed Turn writebacks; -normal first writebacks and non-Turn vision authoring retain their existing rules. +non-opted-in first writebacks and non-Turn vision authoring retain their existing rules. From the original working directory and with the original registry/runtime/project/ state-file options, read the basis for the exact settlement: diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 8ac1184fa3..36008eae25 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -86,11 +86,11 @@ def register_refresh_state_command( add_subcommand_format: Callable[[argparse.ArgumentParser], None], ) -> None: context_parser = subparsers.add_parser( - "checkpoint-context", help="Recover a missing checkpoint after a committed Turn writeback.", + "checkpoint-context", help="Read the original Turn's result or checkpoint basis.", description=( - "Recovery only: requires the original committed refresh-state writeback. " - "For the first writeback, supply the vision with refresh-state directly. " - "Use this command only when recovery requests a fresh checkpoint context; " + "New supported Turns read direction after result completion, before settlement. " + "Historical supplement recovery requires the original committed writeback. " + "Judge the returned basis and echo its read identity with the new decision; " "reuse the original Turn identity." ), ) @@ -101,6 +101,11 @@ def register_refresh_state_command( binding.add_argument("--todo-id") binding.add_argument("--replan-obligation-id") context_parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) + context_parser.add_argument("--purpose", choices=("supplement_checkpoint", "first_delivery", "delivery_result"), + default="supplement_checkpoint", + help="Read result requirements, post-result direction, or the original missing checkpoint; new supported Turns are protected by default.") + context_parser.add_argument("--decision-scope", choices=("agent_lane", "goal"), default=None, + help="Complete work collection used by the first direction judgment; reading grants no write authority.") context_parser.add_argument("--project") context_parser.add_argument("--state-file") context_parser.add_argument("--dependency-todo-id", action="append", default=[], @@ -109,6 +114,8 @@ def register_refresh_state_command( "refresh-state", help="Append a read-only run from active goal state after state-only updates.", ) + refresh_state_parser.add_argument("--first-delivery", action="store_true", + help="Commit an explicitly enrolled first direction using its fresh checkpoint-context receipt.") add_subcommand_format(refresh_state_parser) refresh_state_parser.add_argument("--goal-outcome-update-json", type=Path, help="Optional changed outcome/Agent review envelope (operation_id, expected_provider_revision, refinement). Apply once before normal writeback; unchanged work needs no update. Independent checks remain separate.") @@ -463,6 +470,7 @@ def handle_refresh_state_command( project=Path(args.project).expanduser() if args.project else None, state_file=Path(args.state_file).expanduser() if args.state_file else None, dependency_todo_ids=args.dependency_todo_id, + purpose=args.purpose, decision_scope=args.decision_scope, goal_ref=goal_ref, ) except Exception as exc: @@ -630,6 +638,7 @@ def handle_refresh_state_command( merge_agent_vision_patch=merge_agent_vision_patch, vision_unchanged_reason=args.vision_unchanged_reason, checkpoint_read_context_id=getattr(args, "checkpoint_read_context", None), + first_delivery=bool(getattr(args, "first_delivery", False)), progress_observation=progress_observation, explore_result=explore_result, usage_measurement=usage_measurement, diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index f2731a0ff0..862b67e2e8 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Mapping from pathlib import Path from typing import Any +from ..cli_runtime import PrintPayload from .quota_presentation import ( _project_quota_cli_payload as _project_quota_cli_payload, @@ -55,6 +56,7 @@ quota_rollout_details, quota_rollout_replan_obligation_id, quota_rollout_todo_id, + quota_result_work_admission_details, reconcile_existing_heartbeat_receipt_for_turn, render_existing_heartbeat_receipt_payload, ) @@ -105,10 +107,6 @@ ) from .quota_scheduler_followup import build_scheduler_followup_payload -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] RolloutEventAppender = Callable[..., dict[str, object]] @@ -464,6 +462,47 @@ def _record_non_guard_quota_event( ) +def _read_back_committed_heartbeat( + payload: dict[str, object], + args: argparse.Namespace, + *, + runtime_root: Path, + turn_instance_id: str, + stall_observation: str, + requested_todo_id: str | None, + goal_ref: dict[str, str] | None, +) -> None: + """Expose selection only after the original heartbeat receipt is read back.""" + receipt = find_heartbeat_receipt( + runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + turn_instance_id=turn_instance_id, + goal_ref=goal_ref, + ) + if receipt: + rollout_event_value = payload.get("rollout_event") + rollout_event: Mapping[str, object] = ( + rollout_event_value if isinstance(rollout_event_value, Mapping) else {} + ) + payload["heartbeat_receipt"] = heartbeat_receipt_view( + receipt, + turn_instance_id=turn_instance_id, + status="committed" if rollout_event.get("appended") else "replayed", + ) + commit_requested_action_selection(payload, requested_todo_id=requested_todo_id) + else: + fail_heartbeat_receipt( + payload, + turn_instance_id=turn_instance_id, + stall_observation=stall_observation, + reason=( + "heartbeat receipt append could not be read back; retry " + "quota should-run with the same --turn-instance-id" + ), + ) + + def handle_quota_command( args: argparse.Namespace, *, @@ -480,6 +519,46 @@ def handle_quota_command( return native_result if args.quota_command == "replan-view": return handle_replan_view(args, print_payload) + # Serialize only automatic identity acquisition. The typed quota owner + # decides which Turn is unfinished; this IO fence keeps that read, the + # existing heartbeat append and work-input persistence in one admission. + from contextlib import nullcontext + import hashlib + from ..file_lock import exclusive_cross_runtime_file_lock + from ..history import load_registry + from ..paths import resolve_runtime_root + from ..runtime import validate_goal_id_path_segment + + try: + fence = nullcontext() + if (args.quota_command == "should-run" and args.agent_id + and not getattr(args, "turn_instance_id", None) + and not getattr(args, "begin_turn", False) + and not getattr(args, "dry_run", False)): + root = resolve_runtime_root(load_registry(registry_path), runtime_root_arg, registry_path=registry_path) + goal_id = validate_goal_id_path_segment(args.goal_id) + agent_key = hashlib.sha256(args.agent_id.encode()).hexdigest() + fence = exclusive_cross_runtime_file_lock(root / "goals" / goal_id / "quota" / + f"default-work-{agent_key}", operation="default-work-admission") + with fence: + return _handle_quota_command(args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, + print_payload=print_payload, append_cli_rollout_event=append_cli_rollout_event, + capture_directory=capture_directory) + except (ValueError, OSError, RuntimeError) as error: + payload = quota_failure_payload(args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, error=error) + print_payload(payload, args.format, _quota_renderer(args)) + return 1 + + +def _handle_quota_command( + args: argparse.Namespace, + *, + registry_path: Path, + runtime_root_arg: str | None, + print_payload: PrintPayload, + append_cli_rollout_event: RolloutEventAppender, + capture_directory: Path | None = None, +) -> int: usage_quota_started = time.time_ns() // 1_000_000 heartbeat_turn_id: str | None = None heartbeat_receipt_existing: dict[str, object] | None = None @@ -634,6 +713,7 @@ def handle_quota_command( runtime_root=runtime_root, turn_instance_id=heartbeat_turn_id, existing=heartbeat_receipt_existing, + registry_path=registry_path, goal_ref=goal_ref, ) else: @@ -833,6 +913,10 @@ def handle_quota_command( ), } ) + if settlement_identity is not None: + rollout_details.update(quota_result_work_admission_details( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=args.goal_id, todo_id=rollout_todo_id, goal_ref=goal_ref)) append_cli_rollout_event( payload, registry_path=registry_path, @@ -860,45 +944,19 @@ def handle_quota_command( *(["goal_ref"] if goal_ref is not None else []), ], ) - receipt = find_heartbeat_receipt( - runtime_root, - goal_id=args.goal_id, - agent_id=args.agent_id, + _read_back_committed_heartbeat( + payload, + args, + runtime_root=runtime_root, turn_instance_id=heartbeat_turn_id, + stall_observation=heartbeat_stall_observation, + requested_todo_id=( + action_selection.requested_todo_id + if action_selection is not None + else None + ), goal_ref=goal_ref, ) - if receipt: - rollout_event_value = payload.get("rollout_event") - rollout_event: Mapping[str, object] = ( - rollout_event_value - if isinstance(rollout_event_value, Mapping) - else {} - ) - payload["heartbeat_receipt"] = heartbeat_receipt_view( - receipt, - turn_instance_id=heartbeat_turn_id, - status="committed" - if rollout_event.get("appended") - else "replayed", - ) - commit_requested_action_selection( - payload, - requested_todo_id=( - action_selection.requested_todo_id - if action_selection is not None - else None - ), - ) - else: - fail_heartbeat_receipt( - payload, - turn_instance_id=heartbeat_turn_id, - stall_observation=heartbeat_stall_observation, - reason=( - "heartbeat receipt append could not be read back; retry " - "quota should-run with the same --turn-instance-id" - ), - ) else: _record_non_guard_quota_event( payload, args, registry_path=registry_path, @@ -908,6 +966,38 @@ def handle_quota_command( spend_turn_instance_id=spend_turn_instance_id, rollout_details=rollout_details, append_cli_rollout_event=append_cli_rollout_event, ) + if args.quota_command == "should-run" and heartbeat_turn_id and payload.get("ok") is True: + from ..control_plane.goals.checkpoint_context_io import TurnResultWork, result_work_required, result_work_context, result_work_context_exists + from ..control_plane.quota.settlement import read_heartbeat_settlement + + try: + work_readback = read_heartbeat_settlement(runtime_root, goal_id=args.goal_id, + agent_id=args.agent_id, todo_id=getattr(args, "todo_id", None), + turn_instance_id=heartbeat_turn_id, registry_path=registry_path, goal_ref=goal_ref, + resolve_original_binding=True) + if (work_readback is not None and work_readback.identity.value is not None + and result_work_required(work_readback)): + payload["work_input_restored"] = result_work_context_exists(runtime_root, work_readback.identity.value) + committed = heartbeat_receipt_existing is not None and TurnResultWork( + registry_path, runtime_root, work_readback.identity.value, goal_ref).result_committed() + if committed: + payload["result_committed"] = True + progress = payload.get("first_delivery_progress") + payload["recommended_action"] = ( + progress["next_action"] if isinstance(progress, dict) + and progress.get("settlement_identity") == work_readback.identity.value.as_dict() + else "Resume this original Turn's pending settlement; its Todo result is already committed." + ) + elif work_readback.writeback_run is None: + payload["delivery_result_context"] = result_work_context(registry_path=registry_path, + runtime_root=runtime_root, identity=work_readback.identity.value, goal_ref=goal_ref) + except (ValueError, OSError, RuntimeError) as error: + admitted_receipt = payload.get("heartbeat_receipt") + payload = quota_failure_payload(args, registry_path=registry_path, + runtime_root_arg=runtime_root_arg, error=error) + payload.update(error_code=getattr(error, "code", "delivery_work_context_unknown"), + error=str(error), + status="delivery_work_context_unavailable", heartbeat_receipt=admitted_receipt) attach_reward_memory_recall_after_should_run( payload, quota_command=args.quota_command, diff --git a/loopx/cli_commands/quota_context.py b/loopx/cli_commands/quota_context.py index e5f7cd2c30..1d55372065 100644 --- a/loopx/cli_commands/quota_context.py +++ b/loopx/cli_commands/quota_context.py @@ -286,6 +286,12 @@ def prepare_quota_command_context( registry_path=registry_path, runtime_root_override=runtime_root_arg, ) + if command == "should-run" and not heartbeat_turn_id and args.agent_id and not args.dry_run: + from ..control_plane.goals.checkpoint_context_io import restore_default_turn_id + + heartbeat_turn_id = restore_default_turn_id(runtime_root=runtime_root, registry_path=registry_path, + goal_id=args.goal_id, agent_id=args.agent_id, todo_id=getattr(args, "todo_id", None), + pending_only=True) or mint_turn_instance_id(prefix="work") if command == "monitor-poll" and args.execute and (args.todo_id or args.target_key): # A selected provider outage precedes unrelated status preparation. # An unmigrated source may still recover its original quota transaction; diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py index 839f4def49..e73dbbc569 100644 --- a/loopx/cli_commands/quota_failure_report.py +++ b/loopx/cli_commands/quota_failure_report.py @@ -80,6 +80,7 @@ def quota_failure_payload( error: Exception, ) -> dict[str, object]: command = args.quota_command + from ..control_plane.goals.checkpoint_context_io import CheckpointReadContextRejected lock_timeout_fields = lock_timeout_error_fields(error) verbose_debug = verbose_debug_fields( error, verbose=bool(getattr(args, "verbose", False)) @@ -234,6 +235,9 @@ def quota_failure_payload( payload["agent_id"] = error.agent_id elif isinstance(error, (LegacyCoordinationWriterFenced, LocalCoordinationAuthorityUnavailable)): payload.update(authority_failure) + elif isinstance(error, CheckpointReadContextRejected): + payload.update(error_code=error.code, reason=str(error), **error.payload, + recommended_action=error.payload["checkpoint_read_context"].get("recommended_action", str(error))) if lock_timeout_fields: payload["recommended_action"] = "inspect the lock holder before retrying" if command == "monitor-poll": diff --git a/loopx/cli_commands/quota_request.py b/loopx/cli_commands/quota_request.py index 127be33127..19fde5794f 100644 --- a/loopx/cli_commands/quota_request.py +++ b/loopx/cli_commands/quota_request.py @@ -165,6 +165,10 @@ def validate_quota_command_request(args: argparse.Namespace) -> None: raise QuotaCommandValidationError( "`loopx quota should-run --todo-id` requires --turn-instance-id or --begin-turn" ) + if command == "should-run" and args.todo_id and not args.agent_id: + raise QuotaCommandValidationError( + "`loopx quota should-run --todo-id` requires --agent-id" + ) if begin_turn and command != "should-run": raise QuotaCommandValidationError( "--begin-turn is only valid with `loopx quota should-run`" diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index d95842b180..f3daa7b08f 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Any +from ..cli_runtime import PrintPayload + from ..contract import check_contract, render_contract_markdown from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable from ..control_plane.runtime.status_projection_cache import ( @@ -39,10 +41,6 @@ from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status from .status_registration import register_status_commands as register_status_commands -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] FormatSelector = Callable[..., str] @@ -754,6 +752,19 @@ def attach_agent_lane_next_actions( guard=guard, ) latest_action = guard.get("latest_run_recommended_action") + delivery_progress = guard.get("first_delivery_progress") + if isinstance(delivery_progress, dict): + item["first_delivery_progress"] = delivery_progress + if ( + isinstance(delivery_progress, dict) + and guard.get("recommended_action") == delivery_progress["next_action"] + ): + item["recommended_action"] = delivery_progress["next_action"] + if isinstance(project_asset, dict): + project_asset["next_action"] = delivery_progress["next_action"] + goal_channel = item.get("goal_channel_projection") + if isinstance(goal_channel, dict): + goal_channel["next_action"] = delivery_progress["next_action"] for target in (item, project_asset): if not isinstance(target, dict): continue @@ -885,6 +896,8 @@ def handle_diagnose_command( ], }, } + # Preserve the full reasoning evidence packet; JSON indentation carries no + # decision information and grows with every diagnosis collection row. print_payload(payload, output_format(args), render_diagnosis_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index d0fed0eeef..74e1f67599 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -3,6 +3,7 @@ import argparse from collections.abc import Callable from pathlib import Path +from ..cli_runtime import PrintPayload from ..agent_registry import ( agent_profile_from_registry, @@ -74,10 +75,6 @@ register_update_command, ) -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] FormatSelector = Callable[..., str] AddFormat = Callable[[argparse.ArgumentParser], None] @@ -491,11 +488,11 @@ def handle_support_control_command( and recurring_thin_surface else payload ) - print_payload( - output_payload, - selected_output_format, - render_heartbeat_prompt_markdown, - ) + if not args.full: + print_payload(output_payload, selected_output_format, + render_heartbeat_prompt_markdown) + else: + print_payload(output_payload, selected_output_format, render_heartbeat_prompt_markdown) return 0 if payload.get("ok") else 1 supervisor_result = handle_supervisor_control_command( diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 1acc087a0d..26283e11c7 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -13,6 +13,7 @@ read_canonical_todo_fields_if_promoted, ) from ..control_plane.effect_runtime import effect_runtime_result +from ..control_plane.goals.checkpoint_context_io import checkpoint_continuation_required from ..control_plane.agents.workspace_guard import capture_delivery_workspace from ..control_plane.todos.contract import ( replan_successor_semantic_binding, @@ -125,7 +126,8 @@ def _completion_settlement_error( # Todo acceptance and Turn settlement are distinct facts. Ordinary # completion can precede accounting (including controller validation). # Only terminal intent requires the full chain before closing out. - if not no_follow_up or settlement_readback.settlement.failure is None: + if (not no_follow_up or settlement_readback.settlement.failure is None + or checkpoint_continuation_required(settlement_readback)): return None return ( "terminal no-follow-up closeout requires matching writeback and quota spend receipts: " @@ -599,6 +601,17 @@ def handle_todo_command( completion_turn_key = None completion_identity_source = None completion_delivery_workspace = None + # An explicit lifecycle identity must reach its own typed owner; + # default Turn restoration applies only to an unscoped request. + if (not getattr(args, "turn_instance_id", None) + and not getattr(args, "completion_identity_key", None) + and args.agent_id and args.todo_id): + from ..control_plane.goals.checkpoint_context_io import restore_default_turn_id + + args.turn_instance_id = restore_default_turn_id( + runtime_root=resolve_runtime_root(load_registry(registry_path), runtime_root_arg), + registry_path=registry_path, goal_id=args.goal_id, agent_id=args.agent_id, + todo_id=args.todo_id, pending_only=False) if getattr(args, "turn_instance_id", None): runtime_root = resolve_runtime_root(load_registry(registry_path), runtime_root_arg) settlement_readback = _read_todo_turn_settlement( @@ -697,7 +710,12 @@ def handle_todo_command( evidence=args.evidence, completion_result_file=Path(args.result_file).expanduser() if args.result_file else None, completion_turn_key=completion_turn_key, + delivery_read_context_id=getattr(args, "delivery_read_context", None), + delivery_settlement_identity=settlement_identity.as_dict() if settlement_identity else None, completion_identity_source=completion_identity_source, + defer_no_followup=(settlement_readback is not None + and checkpoint_continuation_required(settlement_readback) + and settlement_readback.settlement.failure is not None), completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=Path.cwd(), task_lease_idempotency_key=args.task_lease_idempotency_key, @@ -822,7 +840,7 @@ def handle_todo_command( raise RuntimeError("exact settlement readback unexpectedly returned not-found") settlement_result = ( settlement_readback.terminal_settlement - if args.no_follow_up and settlement_identity is not None + if payload.get("completion_continuation") == "no_followup" and settlement_identity is not None else settlement_readback.identity ) payload["settlement_result"] = settlement_result_payload( diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 969994b3b7..c07c49a253 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -480,6 +480,8 @@ def validate_todo_update_options(args: argparse.Namespace) -> None: def validate_todo_complete_options(args: argparse.Namespace) -> None: + if getattr(args, "delivery_read_context", None) and not args.turn_instance_id: + raise ValueError("--delivery-read-context requires the original --turn-instance-id") if not args.todo_id: raise ValueError("todo complete requires --todo-id") if args.result_file and args.role == "user": @@ -573,6 +575,8 @@ def validate_todo_archive_completed_options(args: argparse.Namespace) -> None: def validate_shared_todo_options(args: argparse.Namespace) -> None: if getattr(args, "operation_id", None) and args.todo_command not in {"receipt", "add"}: raise ValueError("--operation-id is supported only by todo receipt and canonical todo add") + if getattr(args, "delivery_read_context", None) and args.todo_command != "complete": + raise ValueError("--delivery-read-context is supported only by todo complete") if args.result_file and args.todo_command != "complete": raise ValueError("--result-file is supported only by todo complete") agent_id_allowed_for_user_authoring = ( diff --git a/loopx/cli_commands/todo_event.py b/loopx/cli_commands/todo_event.py index d59260d698..5f2e5b31be 100644 --- a/loopx/cli_commands/todo_event.py +++ b/loopx/cli_commands/todo_event.py @@ -6,6 +6,7 @@ from ..control_plane.todos.contract import decision_scope_metadata_value from ..file_lock import lock_timeout_error_fields +from ..control_plane.goals.checkpoint_context_io import CheckpointReadContextRejected RolloutEventAppender = Callable[..., dict[str, object]] @@ -57,7 +58,7 @@ def todo_error_payload( elif isinstance(exc, (TaskLeaseError, HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError, ActiveStateAuthorityMutationError, LocalCoordinationAuthorityUnavailable)): payload["error_code"] = exc.code payload.update(exc.payload) - elif isinstance(exc, LocalCoordinationAuthorityRejection): + elif isinstance(exc, (LocalCoordinationAuthorityRejection, CheckpointReadContextRejected)): payload["error_code"] = exc.code payload["code"] = exc.code for key, value in exc.payload.items(): @@ -88,7 +89,7 @@ def append_todo_rollout_event( terminal_closeout = bool( turn_instance_id and args.todo_command == "complete" - and getattr(args, "no_follow_up", False) + and payload.get("completion_continuation") == "no_followup" ) if ( args.todo_command == "receipt" @@ -136,7 +137,7 @@ def append_todo_rollout_event( "changed": bool(payload.get("changed")), "added": bool(payload.get("added")), "already_exists": bool(payload.get("already_exists")), - "no_followup": bool(getattr(args, "no_follow_up", False)), + "no_followup": payload.get("completion_continuation") == "no_followup", "completion_continuation": payload.get("completion_continuation"), "completion_recovery": payload.get("completion_recovery"), "mutation_authority": payload.get("mutation_authority"), diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index f3de10804a..29da00d407 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -111,6 +111,7 @@ def register_todo_command( todo_parser.add_argument("--note", help="Public-safe note to attach to a lifecycle transition.") todo_parser.add_argument("--evidence", help="Public-safe evidence pointer or short result for complete/update.") todo_parser.add_argument("--result-file", help="For todo complete with bound Goal acceptance criteria, bind a bounded local .json, .md or .txt result. A Todo validator alone is insufficient; use --evidence for a local artifact pointer.") + todo_parser.add_argument("--delivery-read-context", help="Original delivery_result read identity for protected Turn completion.") todo_parser.add_argument( "--validation-command", help=( diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 7a4ac7350e..af49c57e9b 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Mapping from pathlib import Path from typing import Any +from ..cli_runtime import PrintPayload from ..control_plane.goals.first_party_host_admission import ( FirstPartyHostGoalAdmission, @@ -33,10 +34,6 @@ resolve_turn_resume_session_binding, ) -PrintPayload = Callable[ - [dict[str, object], str, Callable[[dict[str, object]], str]], - None, -] FormatSelector = Callable[..., str] @@ -391,5 +388,8 @@ def handle_turn_command( if args.turn_command == "run-once" else _render_loopx_turn_plan_markdown ) - print_payload(payload, output_format(args), renderer) + if args.turn_command == "plan" and not args.include_transaction_detail: + print_payload(payload, output_format(args), renderer) + else: + print_payload(payload, output_format(args), renderer) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index daf6227de7..49fc1b6f60 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -24,6 +24,7 @@ read_heartbeat_settlement, ) from ..control_plane.quota.spend_sources import quota_spend_source_for_execution_context +from ..control_plane.effect_program import SettlementFailureKind from ..control_plane.work_items.autonomous_replan_obligation import ( replan_obligation_id_from_packet, ) @@ -232,7 +233,14 @@ def refresh_candidate( # The host workspace is execution context, not state authority. state_project = None result_kind = str(result.get("result_kind") or "") - if result_kind in {"repair_required", "replan_required"}: + fresh_checkpoint = current_checkpoint.enabled() + pending_judgment = fresh_checkpoint and not result.get("checkpoint_read_context_id") + prior_checkpoint = read_heartbeat_settlement(runtime_root, + goal_id=settlement_identity.goal_id, agent_id=settlement_identity.agent_id, + todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, + registry_path=registry_path, goal_ref=goal_ref) if pending_judgment else None + if result_kind in {"repair_required", "replan_required"} and (not fresh_checkpoint or + (pending_judgment and (prior_checkpoint is None or prior_checkpoint.writeback_run is None))): todo_id = str(selected_todo.get("todo_id") or "") if not todo_id and not settlement_identity.replan_obligation_id: raise ValueError( @@ -261,19 +269,20 @@ def refresh_candidate( turn_instance_id=settlement_identity.turn_instance_id, agent_id=args.agent_id, progress_scope="goal", - autonomous_replan_recorded=result_kind == "replan_required", + autonomous_replan_recorded=result_kind == "replan_required" and pending_judgment if fresh_checkpoint else result_kind == "replan_required", progress_observation=( dict(progress_observation) if isinstance(progress_observation, dict) else None ), agent_vision_packet=( dict(result["agent_vision"]) - if isinstance(result.get("agent_vision"), dict) + if isinstance(result.get("agent_vision"), dict) and not pending_judgment else None ), vision_unchanged_reason=( - str(result.get("vision_unchanged_reason") or "") or None + (str(result.get("vision_unchanged_reason") or "") or None) if not pending_judgment else None ), + checkpoint_read_context_id=result.get("checkpoint_read_context_id"), completion_todo_id=completion_todo_id, completion_turn_key=completion_turn_key, dry_run=dry_run, @@ -315,6 +324,15 @@ def writeback( status="appended", details={"command": "turn run-once"}, ) + if (pending_judgment and refresh.get("ok") and + refresh.get("vision_checkpoint", {}).get("decision") == "missing_required"): + try: + decided = current_checkpoint.continue_direction(result, purpose="supplement_checkpoint") + return writeback(decided, completion_todo_id=completion_todo_id, + completion_turn_key=completion_turn_key, effect_ref=effect_ref) + except ValueError as error: + return {"ok": False, "appended": False, "reason": str(error), + "checkpoint_stage": "direction_pending"} return refresh def completion_intent(_result: dict[str, object]) -> dict[str, object]: @@ -346,6 +364,7 @@ def todo_completion( result: dict[str, object], *, effect_ref: str, + terminal: bool = False, ) -> dict[str, object]: todo_id = str(selected_todo.get("todo_id") or "") if not todo_id: @@ -368,6 +387,10 @@ def todo_completion( completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=delivery_workspace_path, **(dict(native_completion_lease_resolver()) if native_completion_lease_resolver else {}), + delivery_read_context_id=(result.get("result_work_binding") or {}).get("read_context_id"), + delivery_settlement_identity=settlement_identity.as_dict(), + defer_no_followup=current_checkpoint.enabled() and not terminal, + no_followup=terminal, ) if completion.get("ok") is not True: return completion @@ -460,13 +483,14 @@ def completion_writeback( completion = {"ok": True, "appended": True, "completion": prior} if not completion.get("ok"): return completion - todo_id = str(selected_todo.get("todo_id") or "") - refresh = writeback( - result, - completion_todo_id=todo_id, - completion_turn_key=settlement_identity.turn_instance_id, - effect_ref=effect_ref, - ) + try: + result = current_checkpoint.continue_direction(result) + todo_id = str(selected_todo.get("todo_id") or "") + refresh = writeback(result, completion_todo_id=todo_id, + completion_turn_key=settlement_identity.turn_instance_id, effect_ref=effect_ref) + except ValueError as error: + return {"ok": False, "appended": False, "reason": str(error), + "checkpoint_stage": "direction_pending", "completion": completion["completion"]} return { "ok": bool(refresh.get("ok")), "appended": bool(completion.get("appended")) @@ -481,7 +505,7 @@ def terminal_closeout( effect_ref: str, ) -> dict[str, object]: require_effect_ref(effect_ref, SettlementStepKind.TERMINAL_CLOSEOUT) - return todo_completion(result, effect_ref=effect_ref) + return todo_completion(result, effect_ref=effect_ref, terminal=True) def current_status() -> dict[str, object]: return collect_turn_status_payload( @@ -640,6 +664,8 @@ def writeback_resolver(effect_ref: str) -> dict[str, object]: "kind": "unknown", "reason": "refresh-state receipt has no durable run", } + if current_checkpoint.enabled() and run.get("vision_checkpoint", {}).get("satisfied") is not True: + return {"kind": "absent"} if event is None: append_settlement_event( run, @@ -725,6 +751,13 @@ def terminal_closeout_resolver(effect_ref: str) -> dict[str, object]: raise RuntimeError(EXACT_SETTLEMENT_READBACK_NOT_FOUND) event = readback.completion_event completion = terminal_completion_readback() + # The Todo fence proves this original Turn's terminal upgrade + # absent; an ordinary result event is not a closeout receipt. + # Receipt classification remains with the TS settlement owner. + terminal_failure = readback.terminal_closeout.failure + if (completion is None and terminal_failure is not None and + terminal_failure.kind is SettlementFailureKind.RECEIPT_MISSING): + return {"kind": "absent"} if event is None and completion is None: return {"kind": "absent"} if ( @@ -887,12 +920,14 @@ def resolve_built_in_session_binding( turn_envelope, goal_admission=goal_admission, session_scope=session_scope, + exact_session=True, ) if goal_admission is not None else codex_cli_session_binding( runtime_root, turn_envelope, session_scope=session_scope, + exact_session=True, ) ) @@ -928,6 +963,54 @@ def settlement_admission(): return request_settlement_lifetime(request_locator, settlement_identity, registry_path=registry_path, runtime_root=runtime_root, workspace=project) + def run_direction(context: dict[str, Any]) -> dict[str, Any]: + from ..control_plane.turn_driver.executor import build_loopx_turn_host_request, _run_host, _run_host_runner + from ..control_plane.turn_driver.journal_store import turn_journal_path + from ..usage_goal import observe_goal_execution + from ..control_plane.collaboration.goal_instance_scope import collaboration_goal_scope + + if goal_admission is not None: + goal_admission.require_current() + with collaboration_goal_scope(registry_path, goal_id=args.goal_id, + agents=(args.agent_id,), caller_goal_ref=goal_ref, require_active=True): + pass + request = build_loopx_turn_host_request(payload) + request.update(phase="checkpoint_direction", checkpoint_context=context) + request["session"] = {**request["session"], "action": "resume"} + if args.host == "codex-cli": + binding = session_binding_resolver(envelope) + journal = json.loads(turn_journal_path(runtime_root, goal_id=args.goal_id, + turn_key=request["turn_key"]).read_text(encoding="utf-8")) + if not binding or binding != journal.get("checkpoint_host_binding"): + raise ValueError("Original current-Agent session binding is unavailable; preserve the result and reconcile") + session = dict(request["session"]) + session["action"] = "resume" + session["context_policy"] = {**session.get("context_policy", {}), "mode": "resume"} + request["session"] = session + with observe_goal_execution(runtime_root, args.goal_id, host=args.host): + observation = (_run_host_runner(request, runner=host_runner) if host_runner is not None else + _run_host(request, argv=raw_argv, project=project, timeout_seconds=args.timeout_seconds)) + if observation.get("ok") is not True: + from ..control_plane.turn_driver.host_failure import BuiltInHostError + raise BuiltInHostError(str(observation.get("reason") or "Current Agent direction continuation failed"), + failure_kind=str(observation.get("failure_kind") or "unknown")) + decision = dict(observation["value"]) + if decision.get("stop_reason"): + return {"result_kind": "user_action_required"} + if decision.get("result_kind") in {"wait", "user_action_required", "iteration_failed"}: + return decision + if decision.get("agent_vision_json"): + decision["agent_vision"] = json.loads(decision["agent_vision_json"]) + return {"read_context_id": decision.get("read_context_id"), + "agent_vision": decision.get("agent_vision"), + "vision_unchanged_reason": decision.get("vision_unchanged_reason")} + + from ..control_plane.turn_driver.checkpoint_continuation import CurrentAgentCheckpoint + _state_project, checkpoint_state = resolve_todo_state_path(registry_path=registry_path, + goal_id=args.goal_id, project=None, state_file=None) + current_checkpoint = CurrentAgentCheckpoint(registry_path, runtime_root, checkpoint_state, + settlement_identity, run_direction) + def on_managed_start_admitted() -> None: if settlement_identity.request_subject is not None: require_current_request_operation(request_locator, settlement_identity, @@ -938,6 +1021,9 @@ def on_managed_start_admitted() -> None: if isinstance(replan_packet, Mapping) else None ) + from ..control_plane.goals.checkpoint_context_io import default_result_freshness_policy + + policy = default_result_freshness_policy(runtime_root, args.goal_id, settlement_identity.todo_id, goal_ref) ensure_turn_heartbeat_settlement_receipt( runtime_root, settlement_identity, @@ -945,6 +1031,8 @@ def on_managed_start_admitted() -> None: semantic_replan_obligation_id=replan_obligation_id, semantic_replan_capability_guard=capability_guard, goal_ref=goal_ref, + result_freshness=policy, + checkpoint_continuation="current_agent_v1" if policy is not None else None, ) managed_cadence = managed_cadence_start( @@ -957,6 +1045,8 @@ def on_managed_start_admitted() -> None: ) execution_started = bool(args.execute) + from ..control_plane.goals.checkpoint_context_io import TurnResultWork + result = run_loopx_turn_once( payload, decision_capture_payload=decision, @@ -974,6 +1064,7 @@ def on_managed_start_admitted() -> None: settlement_admission=settlement_admission, writeback=writeback if args.execute else None, completion_writeback=completion_writeback if args.execute else None, + post_result_checkpoint=current_checkpoint.enabled if args.execute else None, completion_intent=completion_intent if args.execute else None, terminal_closeout=terminal_closeout if args.execute else None, spend=spend if args.execute else None, @@ -989,6 +1080,7 @@ def on_managed_start_admitted() -> None: admit_start=managed_cadence.admit if args.execute else None, confirm_start=managed_cadence.confirm if args.execute else None, goal_admission=goal_admission, + result_work=TurnResultWork(registry_path, runtime_root, settlement_identity, goal_ref), ) if args.execute: result.update(drain_committed_turn_post_writeback_hooks( diff --git a/loopx/cli_commands/turn_todo_writeback.py b/loopx/cli_commands/turn_todo_writeback.py index 1e0052d96a..6e0705ab85 100644 --- a/loopx/cli_commands/turn_todo_writeback.py +++ b/loopx/cli_commands/turn_todo_writeback.py @@ -74,6 +74,10 @@ def write_turn_validated_completion( expected_todo_text_sha256: str | None = None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, + delivery_read_context_id: str | None = None, + no_followup: bool = False, + defer_no_followup: bool = False, + delivery_settlement_identity: Mapping[str, Any] | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, ) -> dict[str, Any]: @@ -87,6 +91,10 @@ def write_turn_validated_completion( role="agent", completion_turn_key=completion_turn_key, completion_identity_source="turn_settlement", + delivery_read_context_id=delivery_read_context_id, + delivery_settlement_identity=delivery_settlement_identity, + no_followup=no_followup, + defer_no_followup=defer_no_followup, expected_todo_text_sha256=expected_todo_text_sha256, require_captured_todo_text=True, completion_delivery_workspace=completion_delivery_workspace, diff --git a/loopx/contract.py b/loopx/contract.py index f3f01f1fd2..911dcd6b52 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -74,7 +74,7 @@ class LeakRule: blocking: bool = True def is_candidate(self, folded_text: str) -> bool: - return any(literal in folded_text for literal in self.required_literals) + return any(map(folded_text.__contains__, self.required_literals)) # Python's Unicode re.IGNORECASE matches ASCII I/i against both dotted and diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 79c81a5ee8..cafeee4c48 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -170,6 +170,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "no_followup", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "completion_receipt_id", "replan_obligation_id", diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 8fb413c7c8..fefae735bc 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -57,6 +57,7 @@ def _freeze(value: Any) -> Any: 'no_followup', 'successor_todo_ids', 'completion_continuation', + 'completion_requested_continuation', 'completion_recovery', 'completion_receipt_id', 'replan_obligation_id', diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index e8fde13ac4..26c081bd6c 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -46,6 +46,7 @@ "no_followup", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "completion_receipt_id", "replan_obligation_id", diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 8d400d3c9a..d46f3840db 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,5 +1,6 @@ import {requirePromotionRegisteredAgents} from "./shadow_registry_source.ts"; import {verifyReentrySettlement} from "./todo_terminal_reentry.ts"; +import {withTerminalDeliverySources, terminalDeliveryBasisCheck} from "./todo_delivery_context.ts"; import {readPromotionReceipt, commitPromotionAndReadBack} from './promotion_receipt.ts'; import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOperation, REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA} from './reviewed_promotion_plan.ts'; import {registryAuthoritySourceCheck} from "./authority_source.ts"; @@ -1330,17 +1331,19 @@ export async function terminalLifecycleLocalCoordinationTodo( requireAuthorityStoreId(todoId, "linked successor Todo id")); const successorIntents = input.successor_intents.map((intent, index) => requireJsonObject(intent, `successor_intents[${index}]`)); - return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + return await withTerminalDeliverySources(root, goalId, input, async () => { const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; const researchEvidence = new ResearchTerminalEvidenceHost(root, goalId, requireJsonObject(input.registry_source, "registry source")); try { - return {...await executeCoordinationTodoTerminalLifecycle(store, { + const execute = () => executeCoordinationTodoTerminalLifecycle(store, { ...(input.expected_todo_text_sha256 === undefined ? {} : { expected_todo_text_sha256: input.expected_todo_text_sha256 === null ? null : requireAuthorityStoreId(input.expected_todo_text_sha256, "expected Todo text digest"), }), + ...(input.delivery_context == null || requireJsonObject(input.delivery_context, "delivery context").read_context_id == null ? {} : {delivery_read_context_id: + requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").read_context_id, "delivery read context id")}), validation_source_provider_revision: input.validation_source_provider_revision == null ? null : requireAuthorityStoreId(input.validation_source_provider_revision, "validation source provider revision"), validation_declaration_sha256: input.validation_declaration_sha256 == null @@ -1371,6 +1374,8 @@ export async function terminalLifecycleLocalCoordinationTodo( ...(input.claim_operation_id == null ? {} : {claim_operation_id: requireAuthorityStoreId(input.claim_operation_id, "claim_operation_id")}), allow_user_gate_auto_acquire: input.allow_user_gate_auto_acquire as boolean, requested_no_followup: input.requested_no_followup as boolean, + ...(input.defer_no_followup === undefined ? {} : { + defer_no_followup: requireBoolean(input.defer_no_followup, "defer_no_followup")}), requested_completion_identity_source: input.requested_completion_identity_source === null || input.requested_completion_identity_source === undefined @@ -1400,7 +1405,13 @@ export async function terminalLifecycleLocalCoordinationTodo( dry_run: input.dry_run as boolean, now: claimObservedAt(input.observed_at), }, async () => await authoritySourcesCurrent() && researchEvidence.current(), - researchEvidence.qualify, request => verifyReentrySettlement(root, request)), ...providerEvidence}; + researchEvidence.qualify, request => verifyReentrySettlement(root, request), + terminalDeliveryBasisCheck(root, input, store)); + let result = await execute(); + // Only retry an explicit rejected CAS; ambiguous effects retain identity. + for (let attempt = 1; input.delivery_context != null && attempt < 3 && + result.conflict_kind === "provider_revision_mismatch"; attempt++) result = await execute(); + return {...result, ...providerEvidence}; } finally {await researchEvidence.close();} }, {businessWrite: "todo_terminal"}); } catch (error) { diff --git a/loopx/control_plane/coordination/todo_delivery_context.ts b/loopx/control_plane/coordination/todo_delivery_context.ts new file mode 100644 index 0000000000..dcd6c642ea --- /dev/null +++ b/loopx/control_plane/coordination/todo_delivery_context.ts @@ -0,0 +1,177 @@ +/** Source locks and same-head basis check for Turn result commits. + * Validation runs between invocations. Provider CAS, not these locks, owns the + * atomic Todo delta and receipt. No checkpoint provider transaction is nested. */ +import {createHash} from "node:crypto"; +import {readFileSync} from "node:fs"; +import {join, resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {withFileMutationLock} from "../effect_runtime_io.ts"; +import type {AuthorityStore, AuthorityStoreHead} from "./authority_store.ts"; +import {authorityStoreSourceAuthority} from "./authority_store.ts"; +import {canonicalAuthoritySha256} from "./authority_store_codec.ts"; +import {indexCoordinationProjection} from "./coordination_projection.ts"; +import {withCanonicalWriter} from "./local_authority_write.ts"; +import {legacyCoordinationTodoLockPath} from "./legacy_writer_lock_paths.ts"; +import {checkpointProviderFacts} from "../goals/checkpoint_authority.ts"; +import {evaluateCheckpointReadContext, evaluateResultWorkBinding, requireCompleteCheckpointGoalSource} from "../goals/checkpoint_read_context.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {inspectCheckpointReplay} from "../goals/checkpoint_commit.ts"; +import {resultFreshnessRequired, heartbeatReceiptFactFromEvent, selectEffectiveHeartbeatReceipt} + from "../quota/heartbeat_receipt_identity.ts"; +import {QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlementFromSnapshot, + readQuotaSettlementSnapshot} from "../quota/settlement_readback.ts"; + +const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +function bytes(path: string): Buffer { + try { return readFileSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return Buffer.alloc(0); + } +} + +/** Index -> registry -> maintenance -> Todo projection -> state -> provider CAS. */ +export async function withTerminalDeliverySources( + root: string, goalId: string, input: JsonObject, write: () => Promise, +): Promise { + if (input.delivery_context == null) return withCanonicalWriter(root, goalId, input.dry_run === true, write); + const context = requireJsonObject(input.delivery_context, "delivery context"); + const registry = requireJsonObject(input.registry_source, "registry source"); + const state = resolve(requireNonEmptyString(context.state_file, "delivery state file")); + return withFileMutationLock(join(root, "goals", goalId, "runs", "index.jsonl"), () => + withFileMutationLock(requireNonEmptyString(registry.path, "registry path"), () => + withCanonicalWriter(root, goalId, false, () => + withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => + withFileMutationLock(state, write, 30_000), 30_000)), 30_000), 30_000); +} + +/** Called only after durable receipt recovery, against the head used for CAS. */ +export function terminalDeliveryBasisCheck(root: string, input: JsonObject, store: AuthorityStore): + ((head: AuthorityStoreHead) => Promise) | undefined { + if (input.delivery_context == null) { + if (input.command !== "complete") return undefined; + return async head => { + if (indexCoordinationProjection(head.head, String(input.goal_id)).todos.get(String(input.todo_id))?.role === "user") return {ok: true}; + const snapshot = await readQuotaSettlementSnapshot(root, String(input.goal_id)); + const registrySource = requireJsonObject(input.registry_source, "registry source"); + const document = requireJsonObject(JSON.parse(readFileSync(String(registrySource.path), "utf8")), "registry"); + const registry = jsonObject(document.payload) ?? document; + const goal = Array.isArray(registry.goals) ? registry.goals.map(jsonObject).find(row => row?.id === input.goal_id) : null; + const goalRef = registry.profile_id === "source_session_v1" + ? {goal_id: input.goal_id, goal_instance_id: goal?.goal_instance_id} : null; + const relevant = snapshot.events.filter(event => event.event_kind === "quota_should_run" && + jsonObject(event.details)?.todo_id === input.todo_id && + canonicalAuthoritySha256(event.goal_ref ?? null) === canonicalAuthoritySha256(goalRef)); + const original = relevant.filter(event => event.agent_id === input.actor_agent_id && + [event.run_id, jsonObject(event.details)?.settlement_effect_id].includes(input.requested_completion_turn_key)); + if (original.length && !original.some(resultFreshnessRequired)) return {ok: true}; + if (relevant.some(resultFreshnessRequired)) return {ok: false, + error_code: "delivery_work_context_required", reread_required: false, + error: "This Todo has a protected Turn admission. Restore its original Turn and work validation before completing.", + next_action: "Resume the admitted Turn; do not omit its identity or create a replacement submission."}; + return {ok: true}; + }; + } + const context = requireJsonObject(input.delivery_context, "delivery context"); + const binding = requireJsonObject(context.identity, "delivery identity"); + const identity = settlementIdentity({goal_id: String(binding.goal_id), agent_id: String(binding.agent_id), + todo_id: String(binding.todo_id), turn_instance_id: String(binding.turn_instance_id), + replan_obligation_id: binding.replan_obligation_id == null ? null : String(binding.replan_obligation_id)}); + const rejected = (code: string, error: string): JsonObject => ({ok: false, error_code: code, + error, reread_required: true, next_action: "Read delivery_result context for the original Turn; recheck the candidate and validation before retrying."}); + return async head => { + if (canonicalAuthoritySha256(binding) !== canonicalAuthoritySha256(settlementIdentityPayload(identity)) || + identity.goal_id !== input.goal_id || identity.todo_id !== input.todo_id || identity.agent_id !== input.actor_agent_id || + ![identity.effect_id, identity.turn_instance_id].includes(String(input.requested_completion_turn_key))) { + return rejected("delivery_identity_mismatch", "Delivery basis belongs to another Goal, Agent, Todo or Turn."); + } + const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", + `${createHash("sha256").update(identity.effect_id + ":delivery_result").digest("hex")}.json`); + if (context.read_context_id == null) { + // A malformed shared supplement is optional only when the original + // writeback proves the ordinary path. A first-delivery read cannot be + // admitted after that writeback; absent history is not such proof. + const unknown = () => rejected("checkpoint_commit_unknown", + "Cannot establish optional enrollment from the original Turn; preserve its receipts and reconcile before retrying."); + try { + try { readFileSync(receiptPath); return unknown(); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const snapshot = await readQuotaSettlementSnapshot(root, identity.goal_id); + const readback = readQuotaSettlementFromSnapshot({ + ...binding, schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, runtime_root: root, + infer_turn_instance_id: false, allow_unbound_binding: false, + }, snapshot); + const prior = jsonObject(readback.writeback_run); + const writeback = jsonObject(jsonObject(readback.writeback)?.result); + const purpose = jsonObject(jsonObject(prior?.vision_checkpoint)?.read_context)?.purpose; + if (prior === null || writeback?.failure !== null || + (purpose != null && purpose !== "supplement_checkpoint")) return unknown(); + inspectCheckpointReplay({runtime_root: root, goal_id: identity.goal_id, prior}); + return {ok: true}; + } catch { return unknown(); } + } + if (context.capture_error != null) return rejected("delivery_source_unavailable", String(context.capture_error)); + let receipt: JsonObject | null = null; + try { receipt = jsonObject(JSON.parse(readFileSync(receiptPath, "utf8"))); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const snapshot = await readQuotaSettlementSnapshot(root, identity.goal_id); + const goalRef = jsonObject(requireJsonObject(context.facts, "delivery source facts").source)?.goal_ref ?? null; + const entries = snapshot.events.filter(event => event.event_kind === "quota_should_run" && + event.agent_id === identity.agent_id && event.run_id === identity.turn_instance_id && + canonicalAuthoritySha256(event.goal_ref ?? null) === canonicalAuthoritySha256(goalRef)) + .map(event => ({fact: heartbeatReceiptFactFromEvent(event), value: event})); + const required = resultFreshnessRequired(selectEffectiveHeartbeatReceipt(identity.goal_id, identity.agent_id, entries)); + if (required && receipt?.result_only !== true) return rejected("delivery_work_context_required", + "Restore the default protected Turn's work basis before submitting."); + const selected = indexCoordinationProjection(head.head, identity.goal_id).todos.get(identity.todo_id!); + const resultCommitted = required && selected?.status === "done" && + [identity.effect_id, identity.turn_instance_id].includes(String(selected.completion_turn_key)); + // Successful result recovery ends its freshness check. A new terminal + // upgrade still needs the actual Goal source; its own receipt is recovered + // before this callback and does not need a new source check. + if (resultCommitted && !(input.requested_no_followup === true && input.defer_no_followup !== true)) { + return {ok: true}; + } + if (required && !resultCommitted && (input.validation_receipt != null || input.goal_acceptance_validation_receipts != null)) { + if (input.result_validation_binding == null) return rejected("delivery_validation_binding_required", + "Native validation must remain bound to its candidate and original work basis."); + const validation = evaluateResultWorkBinding({phase: "check_validation", identity: binding, + receipt, terminal_input: input, binding: input.result_validation_binding}); + if (validation.ok !== true) return validation; + } + if (input.requested_no_followup === true && !required) { + return {...rejected("delivery_no_followup_unsupported", + "Protected no-follow-up completion is not admitted in this protocol stage; retain the original identity and receipts."), + reread_required: false, next_action: "This protocol stage supports ordinary result delivery; do not retry an unsupported terminal intent or bypass an unresolved Turn."}; + } + const facts = requireJsonObject(context.facts, "delivery source facts"); + const authority = authorityStoreSourceAuthority(store); + if (authority !== "file_v0" && authority !== "sqlite_v0" && !(required && authority === "postgresql_v0")) { + return rejected("delivery_provider_unsupported", "Delivery freshness requires File or SQLite authority."); + } + const source = requireJsonObject(facts.source, "delivery source"); + const state = resolve(requireNonEmptyString(context.state_file, "delivery state file")); + if (resolve(String(source.state_file)) !== state || + digest(bytes(state)) !== context.state_sha256 || + digest(bytes(join(root, "goals", identity.goal_id, "runs", "index.jsonl"))) !== context.index_sha256) { + return rejected("delivery_source_capture_changed", "Source changed while capturing the completion request; retry the same read identity."); + } + if (resultCommitted) { + try { requireCompleteCheckpointGoalSource(facts); } + catch (error) { + if (!(error instanceof EffectRuntimeRequestError) || error.code !== "checkpoint_goal_source_incomplete") throw error; + return {...rejected(error.code, error.message), reread_required: false, + next_action: "Restore the Goal owner's actual document; resume this original Turn and retain its successful result and quota receipts."}; + } + return {ok: true}; + } + const storeId = await store.storeIdentity(); + if (storeId.status !== "available") return rejected("delivery_store_unavailable", "Cannot establish the current authority store identity."); + const current = checkpointProviderFacts(identity.goal_id, facts, head, storeId.store_identity, authority, true); + return evaluateCheckpointReadContext({phase: "check", purpose: "delivery_result", identity: binding, + read_context_id: context.read_context_id, receipt, + facts: current}); + }; +} diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 764d222a4a..0cd5ec46e1 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -24,6 +24,7 @@ import type { JsonObject } from "../effect_program.ts"; import type { AuthorityStore, AuthorityStoreCommit, + AuthorityStoreHead, } from "./authority_store.ts"; import { AuthorityStoreProtocolError, @@ -100,6 +101,8 @@ interface CoordinationTodoTerminalLifecycleBaseInput { /** Captured task digest. Null refuses fresh completion without source proof; * omission preserves unscoped callers. Historical receipts recover first. */ readonly expected_todo_text_sha256?: string | null; + /** Immutable read identity, included in the operation payload commitment. */ + readonly delivery_read_context_id?: string; readonly review_basis?: {readonly provider_revision: string; readonly registry_sha256: string}; /** Presence selects source-bound validation; null means no effect issued yet. */ readonly validation_source_provider_revision?: string | null; @@ -119,6 +122,7 @@ interface CoordinationTodoTerminalLifecycleBaseInput { readonly lease_expected_version: number | null; readonly allow_user_gate_auto_acquire: boolean; readonly requested_no_followup: boolean; + readonly defer_no_followup?: boolean; readonly requested_completion_identity_source: CompletionIdentitySource | null; readonly linked_successor_todo_ids: readonly string[]; readonly successor_intents: readonly JsonObject[]; @@ -377,6 +381,12 @@ function normalizeTerminalInput( (typeof raw.expected_todo_text_sha256 !== "string" || !BARE_SHA256_PATTERN.test(raw.expected_todo_text_sha256))) { throw new AuthorityStoreProtocolError("expected Todo text commitment must be a SHA-256 digest"); } + if (raw.delivery_read_context_id !== undefined) { + requireAuthorityStoreId(raw.delivery_read_context_id, "delivery read context id"); + if (raw.operation_identity.kind !== "completion_turn" || raw.user_update !== undefined || raw.review_basis !== undefined) { + throw new AuthorityStoreProtocolError("delivery basis requires an ordinary Turn completion"); + } + } if (raw.review_basis !== undefined) { const basis = canonicalAuthorityObject(raw.review_basis, "terminal review basis"); if (Object.keys(basis).some(key => !["provider_revision", "registry_sha256"].includes(key)) || @@ -431,6 +441,8 @@ function normalizeTerminalInput( "allow_user_gate_auto_acquire", ), requested_no_followup: requireBoolean(raw.requested_no_followup, "requested_no_followup"), + ...(raw.defer_no_followup === undefined ? {} : { + defer_no_followup: requireBoolean(raw.defer_no_followup, "defer_no_followup")}), requested_completion_identity_source: raw.requested_completion_identity_source === null ? null @@ -506,6 +518,10 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri validation_receipt: input.validation_receipt, goal_acceptance_source_binding: input.goal_acceptance_source_binding ?? null, goal_acceptance_validation_receipts: input.goal_acceptance_validation_receipts ?? null} : {}), + ...(input.delivery_read_context_id === undefined ? {} : { + delivery_read_context_id: input.delivery_read_context_id, + note: input.note, evidence: input.evidence, reason: input.reason, + }), // Older receipts deliberately retain their original fingerprint. A reviewed // command binds both its approved snapshot and its complete prose intent. ...(input.review_basis === undefined ? {} : {review_basis: input.review_basis, @@ -529,6 +545,7 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri lease_expected_version: input.lease_expected_version, allow_user_gate_auto_acquire: input.allow_user_gate_auto_acquire, requested_no_followup: input.requested_no_followup, + ...(input.defer_no_followup === true ? {defer_no_followup: true} : {}), requested_completion_turn_key: input.requested_completion_turn_key, requested_completion_identity_source: input.requested_completion_identity_source, linked_successor_todo_ids: input.linked_successor_todo_ids, @@ -843,6 +860,9 @@ async function commitTerminalResult( todo_id: input.todo_id, command: input.command, request_sha256: requestSha, + ...(input.delivery_read_context_id === undefined ? {} : { + delivery_read_context_id: input.delivery_read_context_id, + }), result, }]; return terminalReceipt(input, requestSha).commit(store, commit); @@ -1013,7 +1033,7 @@ function terminalTarget( ...(input.evidence === null ? {} : {evidence: input.evidence}), ...(input.reason === null ? {} : {reason: input.reason}), ...(input.decision_outcome === null ? {} : {decision_outcome: input.decision_outcome}), - ...(input.requested_no_followup ? {no_followup: true} : {}), + ...(input.defer_no_followup === true ? {no_followup: false} : input.requested_no_followup ? {no_followup: true} : {}), ...(successorIds.length === 0 ? {} : {successor_todo_ids: successorIds}), ...(acceptedResult === null ? {} : {completion_result: acceptedResult}), }; @@ -1200,6 +1220,7 @@ export async function executeCoordinationTodoTerminalLifecycle( authoritySourcesCurrent: AuthoritySourceCheck = uncheckedAuthoritySource, terminalEvidenceGuard: TodoTerminalEvidenceGuard | null = null, verifyLifecycleSettlement?: (input: CoordinationTodoTerminalLifecycleInput) => Promise, + deliveryBasisCheck?: (head: AuthorityStoreHead) => Promise, ): Promise { let normalized: CoordinationTodoTerminalLifecycleInput; try { @@ -1210,6 +1231,21 @@ export async function executeCoordinationTodoTerminalLifecycle( error instanceof Error ? error.message : "invalid Todo terminal lifecycle request", ); } + if (normalized.operation_identity.kind === "completion_turn" && + normalized.delivery_read_context_id === undefined && deliveryBasisCheck !== undefined) { + const operationId = completionTurnOperationId(normalized, normalized.requested_no_followup && normalized.defer_no_followup !== true); + const historical = await store.readReceipt(operationId); + const original = historical.status === "found" && historical.receipts.length === 1 + ? historical.receipts[0] : null; + if (original?.schema_version === COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA && + original.operation_id === operationId && original.goal_id === normalized.goal_id && + original.todo_id === normalized.todo_id && original.command === normalized.command && + typeof original.delivery_read_context_id === "string") { + // Recover only a committed request's own key. The complete request hash + // below must still match; this cannot authorize a new or changed intent. + normalized = {...normalized, delivery_read_context_id: original.delivery_read_context_id}; + } + } const requestSha = terminalRequestSha(normalized); let input: ResolvedCoordinationTodoTerminalLifecycleInput; let head: LoadedAuthority; @@ -1224,13 +1260,25 @@ export async function executeCoordinationTodoTerminalLifecycle( head = observation.authority; } else { input = {...normalized, operation_id: normalized.operation_identity.kind === "completion_turn" || isLifecycleCloseout(normalized) - ? completionTurnOperationId(normalized, normalized.requested_no_followup) + ? completionTurnOperationId(normalized, normalized.requested_no_followup && normalized.defer_no_followup !== true) : normalized.operation_identity.operation_id}; // Named operation recovery reports history, not present execution authority. const receipt = terminalReceipt(input, requestSha); const replay = await receipt.read(store); + if (replay?.reason_code === "coordination_operation_identity_mismatch" && + normalized.operation_identity.kind === "completion_turn" && + normalized.requested_no_followup && normalized.defer_no_followup !== true) { + // Lifecycle reentry may already own the closeout phase. Recover an exact + // deferred RESULT request as history, never reinterpret the other route's + // closeout receipt or authorize another terminal mutation. The parent + // fingerprint still binds its actor, lease, evidence and validation. + const parent = {...input, operation_id: completionTurnOperationId(input, false), + defer_no_followup: true}; + const original = await terminalReceipt(parent, terminalRequestSha(parent)).read(store); + if (original?.status === "replayed") return original; + } if (replay !== null) return replay; - if (normalized.operation_identity.kind === "completion_turn" && normalized.requested_no_followup) { + if (normalized.operation_identity.kind === "completion_turn" && normalized.requested_no_followup && normalized.defer_no_followup !== true) { // Older adapters used the ordinary id for an already accepted closeout. // Recover that exact historical request before reading current authority. // An ordinary receipt has a different hash and is checked below as the @@ -1258,6 +1306,17 @@ export async function executeCoordinationTodoTerminalLifecycle( try { input = {...input, ...await claimExecutionProof(store, head.head, input, input.claim_operation_id, input.requested_no_followup), claim_operation_id: undefined}; } catch (error) { return terminalFailure("claim_execution_unavailable", error instanceof Error ? error.message : "Original task claim unavailable"); } } + // Lifecycle reentry can only alter a previously accepted continuation. Its + // original quota settlement is verified below; delivery-result admission + // governs new work and cannot require this route to become a replacement Turn. + if (!isLifecycleCloseout(input) && + (input.delivery_read_context_id !== undefined || deliveryBasisCheck !== undefined)) { + if (deliveryBasisCheck === undefined) return terminalFailure("delivery_basis_check_required", + "Protected completion requires its source and provider basis check.", {}, "decision_rejection"); + const basis = await deliveryBasisCheck(head); + if (basis.ok !== true) return terminalFailure(String(basis.error_code), String(basis.error), + {delivery_read_context: basis}, "decision_rejection"); + } let update: NormalizedCoordinationTodoUpdateInput | undefined; if (input.user_update !== undefined) { try { @@ -1284,7 +1343,7 @@ export async function executeCoordinationTodoTerminalLifecycle( const reviewedRevision = input.review_basis?.provider_revision ?? update?.expected_provider_revision; const validationRevision = input.user_update?.validation_source_provider_revision ?? input.validation_source_provider_revision; if ((reviewedRevision !== undefined && reviewedRevision !== head.provider_revision) || - (validationRevision != null && validationRevision !== head.provider_revision)) { + (input.delivery_read_context_id === undefined && validationRevision != null && validationRevision !== head.provider_revision)) { return terminalFailure("provider_revision_mismatch", "Todo changed during completion review or validation; reread and retry"); } if ((update !== undefined || input.validation_source_provider_revision !== undefined) && @@ -1509,7 +1568,8 @@ export async function executeCoordinationTodoTerminalLifecycle( : canonicalAuthorityObject(evidenceGuard.evidence, "capability completion evidence"); } const acceptanceBinding = acceptanceRequirements === null ? null - : acceptanceSourceBinding(input, acceptanceRequirements, head.provider_revision); + : acceptanceSourceBinding(input, acceptanceRequirements, + input.delivery_read_context_id !== undefined && validationRevision != null ? validationRevision : head.provider_revision); let acceptanceEvidence: JsonObject | null = null; if (acceptanceRequirements !== null && acceptanceBinding !== null && input.goal_acceptance_validation_receipts != null) { @@ -1548,7 +1608,7 @@ export async function executeCoordinationTodoTerminalLifecycle( if (input.command === "complete") { const validationRequired = todo.completion_validation_required === true; const validationSha256 = todo.completion_validation_sha256; - if (normalized.operation_identity.kind === "completion_turn" && input.requested_no_followup) { + if (normalized.operation_identity.kind === "completion_turn" && input.requested_no_followup && input.defer_no_followup !== true) { let fence; try { fence = evaluateTodoCompletionFence({schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, @@ -1566,7 +1626,22 @@ export async function executeCoordinationTodoTerminalLifecycle( lease_idempotency_key: normalized.lease_idempotency_key, lease_expected_version: normalized.lease_expected_version}), requested_no_followup: false, operation_id: completionTurnOperationId(input, false)}; - const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); + let original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); + // The result phase may have durably deferred the original closeout. + // Recover only an exact parent fingerprint, including actor, evidence, + // validation and lease; the pending marker alone grants no authority. + if (todo.completion_requested_continuation === "no_followup" && + (original === null || original.reason_code === "coordination_operation_identity_mismatch")) { + for (const requestedNoFollowup of [false, true]) { + const deferred = {...originalInput, requested_no_followup: requestedNoFollowup, + defer_no_followup: true}; + const candidate = await terminalReceipt(deferred, terminalRequestSha(deferred)).read(store); + if (candidate?.status === "replayed") { + original = candidate; + break; + } + } + } if (original === null) return terminalFailure("terminal_completion_receipt_required", "Same-Turn closeout requires the original accepted ordinary completion receipt.", {}, "decision_rejection"); if (original.status !== "replayed") return original; @@ -1635,6 +1710,7 @@ export async function executeCoordinationTodoTerminalLifecycle( ? todo : {...todo, ...input.validation_declaration}, requested_no_followup: input.requested_no_followup, requested_completion_turn_key: input.requested_completion_turn_key, + defer_no_followup: input.defer_no_followup, requested_completion_identity_source: input.requested_completion_identity_source, requested_has_successor: input.successor_intents.length > 0 || input.linked_successor_todo_ids.length > 0, diff --git a/loopx/control_plane/coordination/todo_terminal_reentry.ts b/loopx/control_plane/coordination/todo_terminal_reentry.ts index 877c590967..b488cc3860 100644 --- a/loopx/control_plane/coordination/todo_terminal_reentry.ts +++ b/loopx/control_plane/coordination/todo_terminal_reentry.ts @@ -19,21 +19,29 @@ export function requiresReentrySettlement(input: CoordinationTodoTerminalLifecyc /** Resolve historical quota identity from its durable receipt, never by parsing * an opaque completion key or treating missing history as unscoped work. */ export async function verifyReentrySettlement(runtimeRoot: string, - input: CoordinationTodoTerminalLifecycleInput): Promise { + input: Pick): Promise { const {readQuotaSettlementSnapshot, readQuotaSettlementFromSnapshot, QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA} = await import("../quota/settlement_readback.ts"); const snapshot = await readQuotaSettlementSnapshot(runtimeRoot, input.goal_id); const event = snapshot.events.find(event => { const details = jsonObject(event.details); const identity = jsonObject(details?.settlement_identity); - return event.agent_id === input.actor_agent_id && - (details?.settlement_effect_id === input.requested_completion_turn_key || + // Managed Turns persist the opaque Turn key; interactive completions + // persist the full effect id. Resolve both from the original scoped event, + // then let quota readback prove the same writeback and spend receipts. + return event.goal_id === input.goal_id && event.agent_id === input.actor_agent_id && + (details?.todo_id === input.todo_id || event.todo_id === input.todo_id || + identity?.todo_id === input.todo_id) && + (event.run_id === input.requested_completion_turn_key || + details?.settlement_effect_id === input.requested_completion_turn_key || identity?.effect_id === input.requested_completion_turn_key); }); const turn = event?.run_id; if (typeof turn !== "string" || input.actor_agent_id === null || - settlementIdentity({goal_id: input.goal_id, agent_id: input.actor_agent_id, - todo_id: input.todo_id, turn_instance_id: turn}).effect_id !== input.requested_completion_turn_key) { + (turn !== input.requested_completion_turn_key && + settlementIdentity({goal_id: input.goal_id, agent_id: input.actor_agent_id, + todo_id: input.todo_id, turn_instance_id: turn}).effect_id !== input.requested_completion_turn_key)) { throw new AuthorityStoreProtocolError("Lifecycle closeout requires the original quota identity and settled receipts"); } const readback = readQuotaSettlementFromSnapshot({schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 873fd6f0be..fd112eec14 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -23,6 +23,7 @@ from ..file_lock import process_is_alive from .runtime.file_reads import iter_binary_file_reads +from .runtime.private_permissions import protect_private_path, require_private_path from .runtime.node_probe import ( HOST_PERMISSION_RECOMMENDATION, MINIMUM_NODE_VERSION as MINIMUM_NODE_VERSION, @@ -63,6 +64,7 @@ "quota.turn_envelope.evaluate", "turn.controller", "goal.checkpoint_read_context.resolve", + "goal.checkpoint_read_context.work_binding", "goal.checkpoint_read_context.commit", "goal.checkpoint_read_context.inspect_replay", "performance_diagnosis.inspect", @@ -845,9 +847,13 @@ def _request_with_info( diagnostic_code="invalid_request", ) directory = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="loopx-effect-"))) + protect_private_path(directory, directory=True) response_sink = directory / "response.json" descriptor = os.open(response_sink, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - os.close(descriptor) + try: + protect_private_path(response_sink) + finally: + os.close(descriptor) request["response_sink"] = str(response_sink) encoded = (json.dumps(request, separators=(",", ":")) + "\n").encode() if len(encoded) > MAX_REQUEST_BYTES: @@ -860,6 +866,7 @@ def _request_with_info( params_path = directory / "params.json" descriptor = os.open(params_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) with os.fdopen(descriptor, "wb") as file: + protect_private_path(params_path) file.write(params_bytes) request.pop("params") request["params_ref"] = { @@ -1049,8 +1056,10 @@ def _read_local_snapshot_response( or size > MAX_LOCAL_SNAPSHOT_BYTES or not isinstance(digest, str) or BARE_SHA256_PATTERN.fullmatch(digest) is None): raise ValueError("invalid local snapshot reference") + require_private_path(sink.parent, directory=True) descriptor = os.open(sink, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(descriptor, "rb") as file: + require_private_path(sink, descriptor=file.fileno()) metadata = os.fstat(file.fileno()) if (not os.path.isfile(sink) or metadata.st_size != size or (hasattr(os, "getuid") and @@ -1209,6 +1218,8 @@ def _start_runtime(*, fingerprint: str, info_path: Path) -> dict[str, Any]: environment["LOOPX_EFFECT_RUNTIME_TOKEN"] = token # Fixed daemon context, never a model-supplied executable argument. environment["LOOPX_EFFECT_RUNTIME_PYTHON"] = sys.executable + # Windows snapshot ACL facts use this parent-selected interpreter only. + environment["LOOPX_EFFECT_RUNTIME_PYTHON_BIN"] = sys.executable # Capture stderr so a rejected startup can publish a typed # startup diagnostic instead of a bare exit status. The capture # is an unlinked temporary file, so it cannot deadlock the child on a diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 236066a588..d48fdb3532 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -366,6 +366,7 @@ export function createEffectRuntimeHandlers( ["goal.checkpoint_read_context.resolve", lazyHandler(() => import("./goals/checkpoint_authority.ts"), ({resolveCheckpointReadContext}) => resolveCheckpointReadContext)], ["goal.checkpoint_read_context.commit", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({commitCheckpoint}) => commitCheckpoint)], ["goal.checkpoint_read_context.inspect_replay", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointReplay}) => inspectCheckpointReplay)], + ["goal.checkpoint_read_context.inspect_attempt", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointAttempt}) => inspectCheckpointAttempt)], ["goal.vision_wait.coverage", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectVisionWaitCoverage}) => projectVisionWaitCoverage)], ["goal.vision_wait.project", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectGoalVisionWaitState}) => projectGoalVisionWaitState)], ["goal.shared_goal_alignment.project", lazyHandler(() => import("./goals/shared_goal_alignment.ts"), ({projectSharedGoalAlignment}) => projectSharedGoalAlignment)], @@ -398,6 +399,7 @@ export function createEffectRuntimeHandlers( ["quota.spend.commit", lazyHandler(() => import("./quota/spend_commit.ts"), ({evaluateQuotaSpendCommit}) => evaluateQuotaSpendCommit)], ["quota.void.commit", lazyHandler(() => import("./quota/void_commit.ts"), ({evaluateQuotaVoidCommit}) => evaluateQuotaVoidCommit)], ["quota.settlement.read", lazyHandler(() => import("./quota/settlement_readback.ts"), ({readQuotaSettlement}) => readQuotaSettlement)], + ["quota.settlement.default_result_work", lazyHandler(() => import("./quota/settlement_readback.ts"), ({selectDefaultResultWorkTurn}) => selectDefaultResultWorkTurn)], [ "quota.prior_host_turn_closeout.preflight", lazyHandler(() => import("./quota/unsettled_host_turn_recovery.ts"), ({preflightPriorHostTurnCloseout}) => preflightPriorHostTurnCloseout), @@ -446,6 +448,8 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.handoff_mode_set", lazyHandler(() => import("./coordination/handoff_mode_runtime.ts"), ({setLocalHandoffMode}) => setLocalHandoffMode)], ["coordination.local_authority.handoff_mode_migrate", lazyHandler(() => import("./coordination/handoff_mode_runtime.ts"), ({migrateLocalHandoffMode}) => migrateLocalHandoffMode)], ["coordination.local_authority.todo_terminal", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({terminalLifecycleLocalCoordinationTodo}) => terminalLifecycleLocalCoordinationTodo)], + ["turn.first_delivery.evaluate", lazyHandler(() => import("./turn_driver/first_delivery.ts"), ({evaluateFirstDelivery}) => evaluateFirstDelivery)], + ["goal.checkpoint_read_context.work_binding", lazyHandler(() => import("./goals/checkpoint_read_context.ts"), ({executeResultWorkBinding}) => executeResultWorkBinding)], ["coordination.local_authority.todo_archive", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({archiveLocalCoordinationTodos}) => archiveLocalCoordinationTodos)], ["coordination.local_authority.todo_archive_ack", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({acknowledgeLocalCoordinationTodoArchive}) => acknowledgeLocalCoordinationTodoArchive)], ["coordination.local_authority.todo_read", lazyHandler(() => import("./coordination/local_authority_read.ts"), ({readLocalCoordinationTodo}) => readLocalCoordinationTodo)], diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index e8a0de86e8..903d29b502 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -47,6 +47,7 @@ const LOCAL_SNAPSHOT_METHODS = new Set([ "quota.turn_envelope.evaluate", "turn.controller", "goal.checkpoint_read_context.resolve", + "goal.checkpoint_read_context.work_binding", "goal.checkpoint_read_context.commit", "goal.checkpoint_read_context.inspect_replay", "performance_diagnosis.inspect", diff --git a/loopx/control_plane/effect_runtime_snapshot.ts b/loopx/control_plane/effect_runtime_snapshot.ts index b3bd40438a..6bf287a57b 100644 --- a/loopx/control_plane/effect_runtime_snapshot.ts +++ b/loopx/control_plane/effect_runtime_snapshot.ts @@ -1,6 +1,8 @@ /** Private same-UID files for complete local facts that exceed the RPC wire. * The digest covers the bytes used by the typed owner; paths are never evidence. */ import {createHash} from "node:crypto"; +import {execFile} from "node:child_process"; +import {fileURLToPath} from "node:url"; import {constants} from "node:fs"; import {lstat, open, type FileHandle} from "node:fs/promises"; import {basename, dirname, isAbsolute} from "node:path"; @@ -19,11 +21,23 @@ async function privateFile(path: string, flags: number): Promise { (parent.uid !== process.getuid() || (parent.mode & 0o077) !== 0))) { throw new Error("invalid private snapshot directory"); } - if ((await lstat(path)).isSymbolicLink()) throw new Error("private snapshot is a symlink"); + const entry = await lstat(path); + if (entry.isSymbolicLink()) throw new Error("private snapshot is a symlink"); + if (process.platform === "win32") { + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN; + if (!python || !isAbsolute(python)) throw new Error("private snapshot ACL helper unavailable"); + // Fixed package-owned Win32 IO, not a caller-supplied executable or decision owner. + const helper = fileURLToPath(new URL("./runtime/private_permissions.py", import.meta.url)); + await new Promise((resolve, reject) => { + execFile(python, [helper, "--verify-snapshot", path], + {timeout: 5000, windowsHide: true, maxBuffer: 4096}, + error => error ? reject(error) : resolve()); + }); + } const file = await open(path, flags | (constants.O_NOFOLLOW ?? 0)); try { const stat = await file.stat(); - if (!stat.isFile() || (process.getuid && + if (!stat.isFile() || stat.dev !== entry.dev || stat.ino !== entry.ino || (process.getuid && (stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0))) { throw new Error("invalid private snapshot file"); } diff --git a/loopx/control_plane/goals/checkpoint_authority.ts b/loopx/control_plane/goals/checkpoint_authority.ts index 3b93c8be9d..2f39c9d1eb 100644 --- a/loopx/control_plane/goals/checkpoint_authority.ts +++ b/loopx/control_plane/goals/checkpoint_authority.ts @@ -1,41 +1,68 @@ /** Same-head checkpoint facts and the two shipped local provider fences. * Not an AuthorityStore extension contract or a checkpoint authority migration. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {authorityStoreSourceAuthority} from "../coordination/authority_store.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; import {FileAuthorityStore} from "../coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../coordination/sqlite_authority_store.ts"; import {openRuntimeAuthorityStore, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence} from "../coordination/legacy_writer_fence.ts"; -import {indexCoordinationProjectionTodos, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {parseIsoTimestamp} from "../runtime_timestamp.ts"; import {readGoalAcceptance} from "./acceptance_contract.ts"; import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; +import type {AuthorityStoreHead} from "../coordination/authority_store.ts"; + +/** The same provider facts feed checkpoint append and Todo result CAS checks. */ +export function checkpointProviderFacts( + goalId: string, facts: JsonObject, head: AuthorityStoreHead, storeIdentity: string, + sourceAuthority: string, protectedDelivery: boolean, +): JsonObject { + const source = requireJsonObject(facts.source, "checkpoint source"); + const projection = indexCoordinationProjection(head.head, goalId); + validateCoordinationTodoReadModel(head.head, goalId); + const acceptance = readGoalAcceptance(head.head, goalId); + const todoId = jsonObject(facts.checkpoint_identity)?.todo_id; + const lease = protectedDelivery && typeof todoId === "string" ? projection.leases.get(todoId) : null; + const expiry = typeof lease?.expires_at === "string" ? parseIsoTimestamp(lease.expires_at) : null; + return {...facts, + ...(protectedDelivery ? {execution_lease: lease ? {...lease, + active: lease.status === "active" && expiry !== null && expiry > new Date()} : null} : {}), + todos: projection.todo_ids.map(id => projection.todos.get(id)!), + acceptance: {revision: acceptance?.revision ?? null, contract_digest: acceptance?.digest ?? null, + contract: acceptance?.document ?? null}, + provider_revision: head.provider_revision, + source: {...source, authority: sourceAuthority, store_identity: storeIdentity}, + }; +} export async function withCheckpointAuthority( root: string, goalId: string, facts: JsonObject, save: (facts: JsonObject) => JsonObject, + requireCanonical = false, + resultOnly = false, + completeTodoSource = false, ): Promise { const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "failed") throw new Error(fence.reason); const source = requireJsonObject(facts.source, "checkpoint source"); if (fence.status === "missing") { + if (requireCanonical) throw new Error("first delivery freshness requires File or SQLite canonical authority"); return save({...facts, source: {...source, authority: "legacy_markdown", store_identity: null}}); } const store = await openRuntimeAuthorityStore(root, goalId, {}); if (!(store instanceof FileAuthorityStore) && !(store instanceof SqliteAuthorityStore)) { + if (resultOnly && authorityStoreSourceAuthority(store) === "postgresql_v0") { + const [head, identity] = await Promise.all([store.loadAuthority(), store.storeIdentity()]); + if (head.status !== "loaded" || identity.status !== "available") { + throw new Error("Cannot read the original result provider head and identity"); + } + return save(checkpointProviderFacts(goalId, facts, head, identity.store_identity, "postgresql_v0", true)); + } throw new Error("checkpoint supplement requires a supported local provider fence"); } return await store.withCheckpointHead((head, identity) => { - const projection = indexCoordinationProjectionTodos(head.head, goalId); - validateCoordinationTodoReadModel(head.head, goalId); - const acceptance = readGoalAcceptance(head.head, goalId); - return save({...facts, - todos: projection.todo_ids.map(id => projection.todos.get(id)!), - acceptance: {revision: acceptance?.revision ?? null, contract_digest: acceptance?.digest ?? null, - contract: acceptance?.document ?? null}, - provider_revision: head.provider_revision, - source: {...source, authority: authorityStoreSourceAuthority(store), store_identity: identity}, - }); + return save(checkpointProviderFacts(goalId, facts, head, identity, authorityStoreSourceAuthority(store), requireCanonical || resultOnly || completeTodoSource)); }); } @@ -48,7 +75,11 @@ export async function resolveCheckpointReadContext(value: unknown): Promise current); + const current = await withCheckpointAuthority(root, goalId, facts, current => current, + request.purpose === "first_delivery" && request.checkpoint_continuation !== true || (request.purpose === "delivery_result" && + request.result_only !== true && jsonObject(request.receipt)?.result_only !== true), + request.purpose === "delivery_result" && (request.result_only === true || jsonObject(request.receipt)?.result_only === true), + request.purpose === "first_delivery" || request.full_frontier === true || jsonObject(request.receipt)?.full_frontier === true); // Reduce the captured snapshot after releasing the read fence, as before. // Only commitCheckpoint holds the provider fence through its final check/save. return evaluateCheckpointReadContext({...request, facts: current}); diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 0802eb25bf..0921600b77 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -1,7 +1,7 @@ /** Missing-checkpoint commit. Index/source claims survive the requesting CLI; * the real provider fence lasts through the synchronous durable append. */ import {createHash} from "node:crypto"; -import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, writeFileSync} from "node:fs"; +import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, writeFileSync} from "node:fs"; import {dirname, join, resolve} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; @@ -24,17 +24,31 @@ import { quotaAccountingOwnerLocks, requireCurrentQuotaAccountingOwner, } from "../quota/source_admission.ts"; -import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; +import {checkpointBasisSnapshot, evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; import {withCheckpointAuthority} from "./checkpoint_authority.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {checkpointContinuationRequired} from "../quota/heartbeat_receipt_identity.ts"; const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +function readReceiptValue(path: string): unknown { + try { return JSON.parse(readFileSync(path, "utf8")); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return null; + } +} function unknown(message: string): never { throw new EffectRuntimeRequestError(`${message}; read back the original Turn before retrying`, "checkpoint_commit_unknown"); } function indexBytes(path: string): Buffer { - const bytes = readFileSync(path); + let bytes: Buffer; + try { bytes = readFileSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return Buffer.alloc(0); + } // The ordinary history reader tolerates damaged rows. A commit cannot infer // absence from that reader or append behind a torn (even JSON-valid) tail. if (bytes.length && bytes[bytes.length - 1] !== 10) unknown("checkpoint index has an incomplete tail"); @@ -106,6 +120,89 @@ function writeSynced(path: string, text: string): void { finally { closeSync(fd); } } +function writeReceipt(path: string, receipt: JsonObject): void { + const temporary = `${path}.${process.pid}.tmp`; + writeSynced(temporary, JSON.stringify(receipt) + "\n"); + renameSync(temporary, path); +} + +/** Caller holds the index lock. Unindexed artifacts never prove a committed + * direction. A completely absent attempt may be retried; torn writes stay held. */ +export function inspectCheckpointAttempt(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint attempt inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const identity = requireJsonObject(request.identity, "settlement identity"); + const runsDir = join(root, "goals", goalPathSegment(identity.goal_id), "runs"); + const rows = indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) + .filter(line => line.trim()).map(line => requireJsonObject(JSON.parse(line), "run index row")); + const matching = rows.filter(row => jsonObject(row.settlement_identity)?.effect_id === identity.effect_id && + jsonObject(row.vision_checkpoint)?.satisfied === true && jsonObject(row.vision_checkpoint)?.read_context != null); + const path = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts", + `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id")).digest("hex")}.json`); + let receipt: JsonObject | null = null; + let receiptError: unknown; + try { receipt = requireJsonObject(JSON.parse(readFileSync(path, "utf8")), "read receipt"); } + catch (error) { receiptError = error; } + // An exact indexed decision and its matching artifacts are stronger than a + // disposable read receipt. Recover the original decision without reauthoring. + if (matching.length) { + if (matching.length !== 1 || canonicalAuthoritySha256(matching[0].settlement_identity) !== canonicalAuthoritySha256(identity)) { + unknown("checkpoint attempt conflicts with its index identity"); + } + if (receipt !== null) { + if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) unknown("checkpoint receipt identity differs"); + const attempt = jsonObject(receipt.commit_attempt); + if (attempt && canonicalAuthoritySha256(matching[0]) !== canonicalAuthoritySha256(attempt.index_record)) { + unknown("checkpoint attempt conflicts with its index"); + } + } + return {...committedArtifacts(matching[0], runsDir), status: "committed"}; + } + if (request.check_other_attempts === true) { + const contexts = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts"); + let names: string[] = []; + try { names = readdirSync(contexts); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const indexed = new Set(indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) + .filter(line => line.trim()).map(line => jsonObject(JSON.parse(line).settlement_identity)?.effect_id)); + const resultReadName = `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id") + ":delivery_result").digest("hex")}.json`; + for (const name of names.filter(name => name.endsWith(".json") && BARE_SHA256_PATTERN.test(name.slice(0, -5)))) { + // This disjoint path can hold only the original work read, never a direction append. + // Losing it cannot erase the successful result or create direction uncertainty. + if (name === resultReadName) continue; + let other: JsonObject; + try { other = requireJsonObject(JSON.parse(readFileSync(join(contexts, name), "utf8")), "read receipt"); } + catch { unknown("checkpoint read receipt is unavailable; preserve original Turn artifacts before retrying"); } + const owner = jsonObject(other.identity); + if (other.purpose === "first_delivery" && other.commit_attempt != null && owner?.effect_id !== identity.effect_id && + owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id && !indexed.has(owner?.effect_id)) { + unknown("another original Turn has an unresolved direction append for this Todo"); + } + } + } + if (receipt === null) { + if ((receiptError as NodeJS.ErrnoException)?.code === "ENOENT") return {ok: true, status: "absent"}; + unknown("checkpoint read receipt is unavailable"); + } + if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) unknown("checkpoint receipt identity differs"); + const attempt = jsonObject(receipt.commit_attempt); + if (attempt == null) return {ok: true, status: "not_started"}; + for (const [field, extension] of [["json_path", ".json"], ["markdown_path", ".md"]]) { + const artifact = runPath(attempt[field], runsDir, extension); + try { + const stat = lstatSync(artifact); + // The caller reserves empty paths before persisting the attempt marker. + // Both empty regular files are an absent effect; any bytes remain unknown. + if (!stat.isFile() || stat.size !== 0) unknown("checkpoint append has unindexed artifacts"); + } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + // No content and no row: there was no visible effect. Clear only the marker, + // preserving the original read identity; a subsequent basis check still runs. + const {commit_attempt: _attempt, ...unstarted} = receipt; + return {ok: true, status: "absent", read_context_id: receipt.read_context_id, receipt: unstarted}; +} + export async function commitCheckpoint(value: unknown): Promise { const request = requireJsonObject(value, "checkpoint commit"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); @@ -120,7 +217,9 @@ export async function commitCheckpoint(value: unknown): Promise { const runsDir = join(root, "goals", identity.goal_id, "runs"); const indexPath = join(runsDir, "index.jsonl"); const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); - const targets = [indexPath, shadowMaintenanceLockPath(root, identity.goal_id), + const firstDelivery = request.purpose === "first_delivery"; + const registryPath = firstDelivery ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; + const targets = [indexPath, ...(registryPath ? [registryPath] : []), shadowMaintenanceLockPath(root, identity.goal_id), legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); const owner = parseQuotaAccountingOwner({ goalRefValue: request.goal_ref, @@ -148,7 +247,7 @@ export async function commitCheckpoint(value: unknown): Promise { } function replay(readback: JsonObject): JsonObject | null { const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); - if (recovery.decision !== "replay") return null; + if (recovery.decision !== "replay" && recovery.decision !== "repair_receipt") return null; const prior = requireJsonObject(readback.writeback_run, "committed checkpoint"); if (jsonObject(prior.vision_checkpoint)?.satisfied !== true || jsonObject(prior.refresh_recovery)?.vision_request_digest !== recovery.vision_request_digest) { @@ -240,7 +339,7 @@ export async function commitCheckpoint(value: unknown): Promise { const repeated = replay(readback); if (repeated) return repeated; const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); - if (recovery.decision !== "supplement_checkpoint") { + if (recovery.decision !== (firstDelivery ? "append" : "supplement_checkpoint")) { throw new EffectRuntimeRequestError(String(recovery.reason ?? "checkpoint supplement rejected"), "checkpoint_commit_rejected"); } await requireShadowPrimaryWriteAllowed(root, identity.goal_id); @@ -256,10 +355,15 @@ export async function commitCheckpoint(value: unknown): Promise { if (digest(indexBytes(indexPath)) !== expectedIndex || digest(readFileSync(statePath)) !== expectedState) { unknown("checkpoint sources changed during lock handoff"); } - let receipt: unknown = null; - try { receipt = JSON.parse(readFileSync(receiptPath, "utf8")); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + // Format-neutral byte guard under the adopted source lock, not a JSON + // decoder. Keep it synchronous with the final head read and durable append. + if (registryPath && digest(readFileSync(registryPath)) !== request.registry_sha256) { + unknown("checkpoint registry changed during lock handoff"); + } + const receipt = readReceiptValue(receiptPath); const context = evaluateCheckpointReadContext({phase: "check", identity: binding, + checkpoint_continuation: checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), + purpose: request.purpose, decision_scope: request.decision_scope, read_context_id: retry.checkpoint_read_context_id, receipt, facts: current}); if (context.ok !== true) return context; const record = requireJsonObject(request.record, "checkpoint record"); @@ -275,12 +379,21 @@ export async function commitCheckpoint(value: unknown): Promise { const jsonPath = runPath(row.json_path, runsDir, ".json"); const markdownPath = runPath(row.markdown_path, runsDir, ".md"); try { + if (firstDelivery) { + // Persist uncertainty before the first run artifact. A replacement + // read cannot discard this attempt after a crash or a lost response. + writeReceipt(receiptPath, {...requireJsonObject(receipt, "first delivery receipt"), + commit_attempt: {vision_request_digest: recovery.vision_request_digest, + mutation_digest: recovery.mutation_digest, json_path: jsonPath, markdown_path: markdownPath, + index_record: row}}); + } writeSynced(jsonPath, JSON.stringify(record, null, 2) + "\n"); writeSynced(markdownPath, requireNonEmptyString(request.markdown, "checkpoint Markdown")); appendJsonLineSync(indexPath, row); } catch { unknown("checkpoint append outcome is uncertain"); } return {ok: true, replayed: false, context, json_path: jsonPath, markdown_path: markdownPath}; - }); + }, firstDelivery && !checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), false, + firstDelivery || checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt))); } finally { // The effect owns the end of the handed-off critical section. Releasing the // markers here also handles a caller that timed out but is still alive. diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 0ffc13bbaa..cd0cbd70ef 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -12,7 +12,7 @@ from ...file_lock import cross_runtime_lock_witness, exclusive_cross_runtime_file_lock from ...history import load_index, load_registry from ...paths import resolve_runtime_root -from ...registry import atomic_write_json +from ..runtime.document_io import atomic_write_private_json as atomic_write_json from ...runtime import validate_goal_id_path_segment from ..coordination.legacy_writer_fence import legacy_coordination_todo_lock_path from ..coordination.shadow_management import shadow_maintenance_lock_target, require_shadow_primary_write_allowed @@ -37,7 +37,8 @@ def _checkpoint_effect(method: str, request: dict[str, Any]) -> Any: # The complete Goal prose and archived Todo basis can exceed the 2 MiB # RPC wire. Only this locked local checkpoint path opts into the exact, # digest-bound same-UID snapshot transport; default effects stay bounded. - return effect_runtime_result(method, request, large_local_snapshot=True) + return effect_runtime_result(method, request, + large_local_snapshot=method != "goal.checkpoint_read_context.inspect_attempt") except EffectRuntimeRejected as error: raise CheckpointReadContextRejected({"ok": False, "error": str(error), "error_code": error.diagnostic_code, "reread_required": False}) from error @@ -54,11 +55,507 @@ def _resolve(runtime_root: Path, **request: Any) -> dict[str, Any]: return result -def _receipt_path(root: Path, identity: SettlementIdentity) -> Path: - digest = hashlib.sha256(identity.effect_id.encode()).hexdigest() +def _receipt_path(root: Path, identity: SettlementIdentity, purpose: str = "supplement_checkpoint") -> Path: + key = identity.effect_id + (":delivery_result" if purpose == "delivery_result" else "") + digest = hashlib.sha256(key.encode()).hexdigest() return root / "goals" / identity.goal_id / "checkpoint-contexts" / f"{digest}.json" +def _read_context_receipt(path: Path) -> dict[str, Any]: + receipt = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(receipt, dict): + raise ValueError("checkpoint receipt must be an object") + return receipt + + +def default_result_freshness_policy(root: Path, goal_id: str, todo_id: str | None, + goal_ref: Mapping[str, Any] | None = None) -> str | None: + from ..coordination.local_authority import read_canonical_todos_if_promoted + + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=goal_id) + return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "admission", "todo_id": todo_id, "goal_ref": goal_ref, + "source_authority": (canonical or {}).get("source_authority", "legacy_markdown"), + })["policy"] + + +def result_work_required(readback: Any) -> bool: + return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "policy", "heartbeat_receipt": readback.heartbeat_receipt, + })["required"] + + +def checkpoint_continuation_required(readback: Any) -> bool: + return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "direction_policy", "heartbeat_receipt": readback.heartbeat_receipt, + })["required"] + + +def result_work_context_exists(root: Path, identity: SettlementIdentity) -> bool: + return _receipt_path(root, identity, "delivery_result").exists() + + +def restore_default_turn_id(*, runtime_root: Path, registry_path: Path, goal_id: str, + agent_id: str, todo_id: str | None, pending_only: bool) -> str | None: + from .first_party_host_admission import capture_first_party_host_goal_ref + from ..quota.settlement import QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA + todo = None + if not pending_only and todo_id: + from ...todos import list_goal_todos + + todo = list_goal_todos(registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, + runtime_root_arg=str(runtime_root)).get("todo") + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=goal_id) + with quota_accounting_admission(runtime_root=runtime_root, registry_path=registry_path, + goal_id=goal_id, goal_ref=goal_ref, operation="default-result-work-read", lock_legacy_index=False) as admission: + request = { + "schema_version": QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, "runtime_root": str(runtime_root.resolve()), + "goal_id": goal_id, "agent_id": agent_id, "todo_id": todo_id, "turn_instance_id": None, + "infer_turn_instance_id": False, "allow_unbound_binding": False, "pending_only": pending_only, "todo": todo, + **({"goal_ref": goal_ref, "source_admission": admission} if goal_ref is not None else {}), + } + try: + result = effect_runtime_result("quota.settlement.default_result_work", request) + except EffectRuntimeRejected as error: + raise CheckpointReadContextRejected({"ok": False, "error": str(error), + "error_code": error.diagnostic_code, "reread_required": False}) from error + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result["turn_instance_id"] + + +def result_work_context(*, registry_path: Path, runtime_root: Path, + identity: SettlementIdentity, reread: bool = False, + goal_ref: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Restore original work input; only explicit revalidation may replace it.""" + path = _receipt_path(runtime_root, identity, "delivery_result") + if not reread and path.exists(): + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "restore", "identity": identity.as_dict(), "receipt": _read_context_receipt(path), + "goal_ref": goal_ref, + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result + read_checkpoint_context(registry_path=registry_path, runtime_root_override=str(runtime_root), + goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, + turn_instance_id=identity.turn_instance_id, purpose="delivery_result", goal_ref=goal_ref) + return result_work_context(registry_path=registry_path, runtime_root=runtime_root, identity=identity, goal_ref=goal_ref) + + +def check_result_work_context(*, registry_path: Path, runtime_root: Path, + identity: SettlementIdentity, context: Mapping[str, Any], + goal_ref: Mapping[str, Any] | None = None) -> dict[str, Any]: + from ...state_refresh import resolve_goal_state + + _, _, state_file = resolve_goal_state(registry=load_registry(registry_path), goal_id=identity.goal_id, + project_override=None, state_file_override=None) + with exclusive_cross_runtime_file_lock(runtime_root / "goals" / identity.goal_id / "runs/index.jsonl", + operation="check-result-work"): + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): + return _resolve(runtime_root, phase="check", purpose="delivery_result", identity=identity.as_dict(), + read_context_id=context.get("read_context_id"), + receipt=_read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref, first_delivery=True)) + + +def bind_result_validation(*, runtime_root: Path, identity: SettlementIdentity, candidate: Mapping[str, Any], + validation: Mapping[str, Any], context: Mapping[str, Any], binding=None) -> dict[str, Any]: + receipt = _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")) + if receipt.get("read_context_id") != context.get("read_context_id"): + raise CheckpointReadContextRejected({"ok": False, "error_code": "delivery_validation_binding_mismatch", + "error": "Candidate validation belongs to a replaced work basis; revalidate the retained candidate."}) + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "bind_validation" if binding is None else "check_validation", "identity": identity.as_dict(), + "receipt": receipt, "candidate": dict(candidate), "validation": dict(validation), "binding": binding, + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result + + +def bind_terminal_result_validation(*, runtime_root: Path, request: Mapping[str, Any], + context: Mapping[str, Any]) -> dict[str, Any]: + identity = SettlementIdentity.from_runtime_payload(context["identity"]) + receipt = _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")) + if receipt.get("read_context_id") != context.get("read_context_id"): + raise CheckpointReadContextRejected({"ok": False, "error_code": "delivery_validation_binding_mismatch", + "error": "The work basis changed during validation; retain the candidate and revalidate."}) + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "bind_validation", "identity": identity.as_dict(), "receipt": receipt, + "terminal_input": dict(request), + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + return result["binding"] + + +def terminal_validation_effect_io(*, runtime_root: Path, request: Mapping[str, Any], + context: Mapping[str, Any], execution_result: Mapping[str, Any], + updates: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Persist only the existing typed owner's admitted validation effect state.""" + identity = SettlementIdentity.from_runtime_payload(context["identity"]) + path = _receipt_path(runtime_root, identity, "delivery_result") + with exclusive_cross_runtime_file_lock(path, operation="terminal-validation-effect"): + receipt = _read_context_receipt(path) + if receipt.get("read_context_id") != context.get("read_context_id"): + raise CheckpointReadContextRejected({"ok": False, "error_code": "delivery_validation_binding_mismatch", + "error": "Validation belongs to a replaced work basis; retain the original candidate."}) + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "begin_terminal_validation" if updates is None else "finish_terminal_validation", + "identity": identity.as_dict(), "receipt": receipt, + "terminal_input": dict(request), "execution_result": dict(execution_result), + **({"updates": dict(updates)} if updates is not None else {}), + }) + if result.get("ok") is not True: + raise CheckpointReadContextRejected(result) + if "receipt" in result: + atomic_write_json(path, result["receipt"]) + return result + + +class TurnResultWork: + """Managed Turn IO for the existing typed work-basis owner.""" + + def __init__(self, registry_path: Path, runtime_root: Path, identity: SettlementIdentity, + goal_ref: Mapping[str, Any] | None = None): + self.registry_path, self.runtime_root, self.identity = registry_path, runtime_root, identity + self.goal_ref = goal_ref + + def result_committed(self) -> bool: + from ...state_refresh import resolve_goal_state + from ..todos.durable_completion import read_persisted_todo_record_with_source + from ..todos.completion_fence import evaluate_todo_completion_fence + + _, _, state = resolve_goal_state(registry=load_registry(self.registry_path), goal_id=self.identity.goal_id, + project_override=None, state_file_override=None) + todo, _, source = read_persisted_todo_record_with_source(state, todo_id=self.identity.todo_id, + registry_path=self.registry_path, goal_id=self.identity.goal_id, runtime_root=self.runtime_root) + # Managed journals use the Turn id; the CLI/MCP settlement adapter uses + # its fully scoped effect id. Both name this identity, never another Turn. + completion_key = (self.identity.effect_id if todo.get("completion_turn_key") == self.identity.effect_id + else self.identity.turn_instance_id) + fence = evaluate_todo_completion_fence(todo=todo, projection_source=source, + completion_turn_key=completion_key, no_followup=False, + goal_id=self.identity.goal_id, todo_id=self.identity.todo_id, + completion_identity_source="turn_settlement") + return fence["outcome"] == "replay" and fence["status"] == "done" + + def read(self, *, reread: bool = False) -> dict[str, Any] | None: + readback = read_heartbeat_settlement(self.runtime_root, goal_id=self.identity.goal_id, + agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + turn_instance_id=self.identity.turn_instance_id, registry_path=self.registry_path, goal_ref=self.goal_ref) + if not result_work_required(readback): + return None + return result_work_context(registry_path=self.registry_path, runtime_root=self.runtime_root, + identity=self.identity, reread=reread, goal_ref=self.goal_ref) + + def check(self, context: Mapping[str, Any]) -> None: + read_heartbeat_settlement(self.runtime_root, goal_id=self.identity.goal_id, + agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + turn_instance_id=self.identity.turn_instance_id, registry_path=self.registry_path, goal_ref=self.goal_ref) + # A writeback may precede a terminal Todo commit. Only the typed owner + # can establish that the original result is already committed. + check_result_work_context(registry_path=self.registry_path, runtime_root=self.runtime_root, + identity=self.identity, context=context, goal_ref=self.goal_ref) + + def bind(self, context: Mapping[str, Any], candidate: Mapping[str, Any], + validation: Mapping[str, Any], binding=None) -> dict[str, Any]: + return bind_result_validation(runtime_root=self.runtime_root, identity=self.identity, + candidate=candidate, validation=validation, context=context, binding=binding) + + +def capture_legacy_result_work(**request: Any) -> dict[str, Any] | None: + # Optional/damaged history cannot precede exact successful result recovery. + # A new effect still reaches the locked typed rejection below. + try: + return _capture_legacy_result_work(**request) + except (ValueError, OSError) as error: + return {"error": {"ok": False, "error_code": "delivery_work_context_unknown", "error": str(error)}} + + +def _capture_legacy_result_work(*, runtime_root: Path, registry_path: Path, goal_id: str, todo_id: str, agent_id: str | None, + completion_turn_key: str | None, identity_payload: Mapping[str, Any] | None, + read_context_id: str | None) -> dict[str, Any] | None: + """Read IO before validation; enforce after historical replay under writer locks.""" + from ...rollout_event_log import rollout_event_log_path + from .first_party_host_admission import capture_first_party_host_goal_ref + + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=goal_id) + + path = rollout_event_log_path(runtime_root, goal_id) + content = path.read_bytes() if path.exists() else b"" + if content and not content.endswith(b"\n"): + raise ValueError("result work admission log has an incomplete tail") + events = [json.loads(line) for line in content.splitlines() if line.strip()] + admission = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "legacy_admission", "events": events, "todo_id": todo_id, "agent_id": agent_id, + "completion_turn_key": completion_turn_key, "identity": identity_payload, + "goal_ref": goal_ref, + }) + if admission.get("required") is False: + return None + if admission.get("ok") is False: + return {"error": admission} + identity = SettlementIdentity.from_runtime_payload(identity_payload) + try: + context = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "restore", "identity": identity.as_dict(), + "receipt": _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), + "goal_ref": goal_ref, + }) + if context.get("ok") is not True: + return {"error": context} + if read_context_id and context["read_context_id"] != read_context_id: + return {"error": {"ok": False, "error_code": "checkpoint_read_context_unknown_or_replaced", + "error": "The candidate refers to a replaced work read; revalidate on the original Turn."}} + return context + except (ValueError, OSError) as error: + return {"error": {"ok": False, "error_code": "delivery_work_context_unknown", "error": str(error)}} + + +@contextmanager +def legacy_result_source_locks(root: Path, registry_path: Path, goal_id: str, *, protected: bool) -> Iterator[None]: + with ExitStack() as stack: + if protected: + for target in (root / "goals" / goal_id / "runs/index.jsonl", registry_path, + shadow_maintenance_lock_target(root, goal_id)): + stack.enter_context(exclusive_cross_runtime_file_lock(target, operation="result-work-commit")) + yield + + +def legacy_result_commit_policy(transaction: Mapping[str, Any], *, runtime_root: Path, + goal_id: str, todo_id: str, agent_id: str | None, + todo: Mapping[str, Any], candidate: Mapping[str, Any]) -> dict[str, Any]: + """Project the locked typed completion's IO requirements; no admission decision here.""" + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "legacy_commit_policy", "transaction": dict(transaction), + "runtime_root": str(runtime_root), "goal_id": goal_id, "todo_id": todo_id, "agent_id": agent_id, + "todo": dict(todo), "candidate": dict(candidate), + }) + if result.get("ok") is False: + raise CheckpointReadContextRejected(result) + return result + + +def check_locked_legacy_result_work(*, runtime_root: Path, registry_path: Path, state_file: Path, + context: Mapping[str, Any] | None, candidate: Mapping[str, Any], + validation: Mapping[str, Any], commit_policy: Mapping[str, Any]) -> None: + if context is None or commit_policy.get("required") is False: + return + if context.get("error"): + raise CheckpointReadContextRejected(dict(context["error"])) + identity = SettlementIdentity.from_runtime_payload(context["settlement_identity"]) + from .first_party_host_admission import capture_first_party_host_goal_ref + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=identity.goal_id) + bind_result_validation(runtime_root=runtime_root, identity=identity, + context=context, candidate=candidate, validation={"ok": True, "native_validation": validation}) + _resolve(runtime_root, phase="check", purpose="delivery_result", identity=identity.as_dict(), + read_context_id=context["read_context_id"], + receipt=_read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref, first_delivery=True)) + + +def first_delivery_context_enrolled(root: Path, identity: SettlementIdentity, *, result_only: bool = False) -> bool: + """Read the persisted protocol selection; enrollment survives caller restart.""" + for purpose in ("first_delivery", "delivery_result"): + # Default result protection has its own persisted admission policy. + # Its work receipt cannot enroll the optional direction protocol. + if result_only and purpose == "delivery_result": + continue + try: + receipt = _read_context_receipt(_receipt_path(root, identity, purpose)) + except FileNotFoundError: + continue + if receipt.get("purpose") == purpose: + if purpose == "delivery_result" and receipt.get("result_only") is True: + continue + return True + return False + + +def _inspect_attempt(root: Path, identity: SettlementIdentity) -> dict[str, Any]: + # Called with the original index and source locks held. The TS inspector is + # read-only; only this locked IO adapter can clear a proved absent marker. + result = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(root.resolve()), "identity": identity.as_dict(), "check_other_attempts": True}) + receipt = result.pop("receipt", None) + if receipt is not None: + atomic_write_json(_receipt_path(root, identity), receipt) + return result + + +def first_delivery_progress( + root: Path, readback: Any, *, canonical_todos: list[dict[str, Any]] | None = None, + observation_unknown: bool = False, registry_path: Path | None = None, +) -> dict[str, Any] | None: + """Observe protocol receipts for quota consumers; this grants no write authority. + + A snapshot may lag a concurrent writer. Ambiguity is displayed as unknown; + only the locked replay path can resolve it or admit another operation. + """ + identity = readback.identity.value + if identity is None: + return None + committed = False + selected: dict[str, Any] = {} + unknown = observation_unknown + try: + if not first_delivery_context_enrolled(root, identity) and not checkpoint_continuation_required(readback): + return None + except (ValueError, OSError): + unknown = True + try: + if canonical_todos is None: + canonical_todos = _delivery_observation_todos(root, identity.goal_id, registry_path) + selected = next((todo for todo in canonical_todos if todo["todo_id"] == identity.todo_id), {}) + committed = selected.get("status") == "done" and selected.get("completion_turn_key") in {identity.effect_id, identity.turn_instance_id} + direction_path = _receipt_path(root, identity) + direction = _read_context_receipt(direction_path) if direction_path.exists() else {} + except (ValueError, OSError): + direction = {} + unknown = True + return effect_runtime_result("turn.first_delivery.evaluate", { + "phase": "project", "result_committed": committed, + "checkpoint_continuation": checkpoint_continuation_required(readback), + "completion_event": readback.completion_event, + # Observation consumes stage markers, not the complete Agent input or + # an append's projected index record. Keep the ordinary RPC bounded. + "writeback_run": {"vision_checkpoint": (readback.writeback_run or {}).get("vision_checkpoint")}, + "direction_receipt": { + "direction_stopped": direction.get("direction_stopped"), + "direction_host_failure": direction.get("direction_host_failure"), + "commit_attempt": {} if direction.get("commit_attempt") is not None else None, + }, + "unknown": unknown, + "quota_spent": readback.spend_run is not None, + "settlement_complete": readback.settlement.failure is None, + "terminal_settlement_complete": readback.terminal_settlement.failure is None, + "identity": identity.as_dict(), + "completion_todo": {key: selected.get(key) for key in ( + "status", "no_followup", "completion_continuation", + "completion_requested_continuation", "completion_turn_key")}, + }) + + +def _delivery_observation_todos(root: Path, goal_id: str, registry_path: Path | None) -> list[dict[str, Any]]: + from ..coordination.local_authority import read_canonical_todos_if_promoted + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=goal_id) + if canonical is not None: + return canonical.get("todos", []) + if registry_path is None: + return [] + from ...todos import resolve_todo_state_path + _, state = resolve_todo_state_path(registry_path=registry_path, goal_id=goal_id, project=None, state_file=None) + active, archived, _ = parse_todo_source(state.read_text(encoding="utf-8")) + return [*active["user"], *active["agent"], *archived] + + +def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | None = None, + *, registry_path: Path | None = None) -> dict[str, Any] | None: + """Isolate historical observation faults; admission still uses strict reads. + + An unreadable digest cannot be assigned to an Agent. Keep that Goal-scoped + uncertainty visible without replacing another lane's normal next action. + """ + identities = {} + errors = [] + for path in (root / "goals" / goal_id / "checkpoint-contexts").glob("*.json"): + try: + receipt = _read_context_receipt(path) + except (ValueError, OSError): + errors.append({"scope": "goal", "code": "checkpoint_receipt_unavailable", "receipt": path.stem}) + continue + binding = receipt.get("identity", {}) + if receipt.get("purpose") not in ("first_delivery", "delivery_result"): + continue + observed_agent = binding.get("agent_id") if isinstance(binding, dict) else None + if isinstance(observed_agent, str) and observed_agent and agent_id is not None and observed_agent != agent_id: + continue + try: + identity = SettlementIdentity.from_runtime_payload(binding) + if (receipt.get("schema_version") != "checkpoint_read_context_v2" or identity.goal_id != goal_id + or path != _receipt_path(root, identity, receipt["purpose"])): + raise ValueError("checkpoint receipt binding mismatch") + except (ValueError, RuntimeError): + errors.append({"scope": "goal", "code": "checkpoint_receipt_invalid", "receipt": path.stem}) + continue + identities[identity.effect_id] = identity.as_dict() + # Share the complete current authority snapshot across observed identities. + # Do not cap the historical scan or silently lose older pending work. + canonical_todos = None + if identities: + try: + canonical_todos = _delivery_observation_todos(root, goal_id, registry_path) + except (ValueError, OSError): + errors.append({"scope": "goal", "code": "checkpoint_authority_unavailable"}) + for binding in reversed(list(identities.values())): + try: + identity = SettlementIdentity.from_runtime_payload(binding) + observation_unknown = any(error.get("receipt") in { + _receipt_path(root, identity, purpose).stem for purpose in ("first_delivery", "delivery_result") + } for error in errors) + readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=binding["agent_id"], + todo_id=binding.get("todo_id"), replan_obligation_id=binding.get("replan_obligation_id"), + turn_instance_id=binding["turn_instance_id"]) + if readback is None or canonical_todos is None: + raise ValueError("enrolled checkpoint readback unavailable") + progress = first_delivery_progress(root, readback, canonical_todos=canonical_todos, + observation_unknown=observation_unknown) + except (ValueError, OSError): + progress = effect_runtime_result("turn.first_delivery.evaluate", {"phase": "project", "unknown": True}) + if progress is not None and progress["stage"] != "settled": + return {**progress, "settlement_identity": binding, **({"observation_errors": errors} if errors else {})} + if errors: + return {**effect_runtime_result("turn.first_delivery.evaluate", { + "phase": "project", "observation_unavailable": True}), + "goal_id": goal_id, "agent_id": agent_id, "observation_errors": errors} + return None + + +def delivery_result_context_input( + *, runtime_root: Path, registry_path: Path, state_file: Path, + identity: SettlementIdentity, read_context_id: str | None, +) -> dict[str, Any] | None: + """Capture IO for the native Todo owner; never refresh the Agent's token. + + The native owner recovers a historical receipt before checking capture + errors. Every validation continuation recaptures sources under short locks. + """ + result: dict[str, Any] = {"identity": identity.as_dict(), + "read_context_id": read_context_id or "missing", "state_file": str(state_file.resolve())} + from .first_party_host_admission import capture_first_party_host_goal_ref + goal_ref = capture_first_party_host_goal_ref(registry_path=registry_path, goal_id=identity.goal_id) + if not read_context_id: + try: + readback = read_heartbeat_settlement(runtime_root, goal_id=identity.goal_id, + agent_id=identity.agent_id, todo_id=identity.todo_id, + turn_instance_id=identity.turn_instance_id, registry_path=registry_path, goal_ref=goal_ref) + if readback is not None and result_work_required(readback): + receipt = _read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")) + read_context_id = receipt.get("read_context_id") + result["read_context_id"] = read_context_id or "missing" + elif not first_delivery_context_enrolled(runtime_root, identity): + return None + except (ValueError, OSError) as error: + # Preserve the ordinary request fingerprint for receipt-first + # recovery. Only the native owner can prove an unreadable shared + # supplement optional; this is not evidence of non-enrollment. + return {**result, "read_context_id": None, "capture_error": str(error)} + index = runtime_root / "goals" / identity.goal_id / "runs" / "index.jsonl" + try: + with exclusive_cross_runtime_file_lock(index, operation="delivery-result-capture"): + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): + result.update( + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref, first_delivery=True), + state_sha256=hashlib.sha256(state_file.read_bytes()).hexdigest(), + index_sha256=hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), + ) + except (ValueError, OSError) as error: + result["capture_error"] = str(error) + return result + + def require_complete_checkpoint_index(index: Path) -> None: """Framing check before replay too; typed settlement validates the rows.""" try: @@ -73,7 +570,7 @@ def require_complete_checkpoint_index(index: Path) -> None: @contextmanager -def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: +def _source_guard(root: Path, goal_id: str, state_file: Path, registry_path: Path | None = None) -> Iterator[None]: """Caller holds runs/index first. Match promotion's M -> Todo -> state order. M prevents source cutover; it does not exclude canonical provider commits. @@ -81,6 +578,8 @@ def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: Do not run projection sync or a new state mutation inside this guard. """ with ExitStack() as locks: + if registry_path is not None: + locks.enter_context(exclusive_cross_runtime_file_lock(registry_path, operation="checkpoint-first-delivery")) for target in ( shadow_maintenance_lock_target(root, goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=goal_id), @@ -94,9 +593,22 @@ def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: def _local_source_facts( root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, goal_ref: Mapping[str, Any] | None = None, + *, first_delivery: bool = False, ) -> dict[str, Any]: - text = state_file.read_text(encoding="utf-8") + try: + text = state_file.read_text(encoding="utf-8") + except FileNotFoundError as error: + raise CheckpointReadContextRejected({ + "ok": False, "error_code": "checkpoint_goal_source_unavailable", + "reread_required": False, + "error": "The authoritative Goal document is missing; native Todo records cannot restore its objective or acceptance prose.", + "recommended_action": "Use loopx-project to locate and restore the Goal owner's actual document or backup. Retry quota should-run for the original Agent/Turn; preserve its admitted receipt and do not execute or spend before the Goal source is restored.", + }) from error metadata, body = split_state_frontmatter(text) + from ..todos.projection_document import is_legacy_todo_projection_skeleton + + if set(metadata) == {"goal_id"} and is_legacy_todo_projection_skeleton(text, metadata["goal_id"]): + metadata["goal_source_completeness"] = "todo_projection_only" lines = body.splitlines() regions = find_todo_source_regions(lines) owned = {i for region in regions for i in range(region.start, region.end)} @@ -115,12 +627,24 @@ def _local_source_facts( ] newest = [run for _, run in sorted(enumerate(runs), key=lambda pair: (str(pair[1].get("generated_at") or ""), pair[0]), reverse=True)] - return { + facts = { "todos": todos, "frontmatter": metadata, "goal_prose": prose, "acceptance": None, "agent_vision": latest_agent_vision_from_runs(newest, goal_id=identity.goal_id, agent_id=identity.agent_id), "source": {"state_file": str(state_file.resolve()), "runtime_root": str(root.resolve()), "authority": "legacy_markdown"}, } + if first_delivery: + from ...state_refresh import resolve_goal_state, registered_agents_for_goal + + registry = load_registry(registry_path) + goal, _, current_path = resolve_goal_state(registry=registry, goal_id=identity.goal_id, + project_override=None, state_file_override=None) + if current_path.resolve() != state_file.resolve() or identity.agent_id not in registered_agents_for_goal(goal): + raise ValueError("first delivery source or Agent registration changed; reread the original Turn") + facts["source"].update(registry_path=str(registry_path.resolve()), goal_ref=goal_ref, + registry_goal=goal) + facts["checkpoint_identity"] = identity.as_dict() + return facts def read_checkpoint_context( @@ -129,10 +653,13 @@ def read_checkpoint_context( replan_obligation_id: str | None = None, project: Path | None = None, state_file: Path | None = None, dependency_todo_ids: list[str] | None = None, goal_ref: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, ) -> dict[str, Any]: # Local import avoids a cycle with refresh-state's persistence adapter. from ...state_refresh import resolve_goal_state, registered_agents_for_goal + if purpose == "first_delivery" and goal_ref is not None: + raise ValueError("First delivery freshness currently supports the local-registry Goal profile; source-session GoalRef admission is not qualified.") goal_id = validate_goal_id_path_segment(goal_id) registry = load_registry(registry_path) root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) @@ -155,21 +682,51 @@ def read_checkpoint_context( registry_path=registry_path, goal_ref=goal_ref, source_admission=source_admission, borrow_source_admission=source_admission is not None) - if readback is None or readback.identity.value is None or readback.writeback_run is None: + first_delivery = purpose != "supplement_checkpoint" + if readback is None or readback.identity.value is None or ( + not first_delivery and readback.writeback_run is None + ): raise ValueError("checkpoint-context requires the original committed Turn writeback") identity = readback.identity.value - with _source_guard(root, goal_id, path): - result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run, - writeback_committed=readback.writeback.failure is None, - quota_spend_committed=readback.spend_run is not None or readback.spend_event is not None, - semantic_replan_guard=readback.semantic_replan_guard, - read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], - facts=_local_source_facts(root, registry_path, path, identity, goal_ref)) - receipt = result.pop("receipt") - atomic_write_json(_receipt_path(root, identity), receipt) + continuation = checkpoint_continuation_required(readback) + if continuation and purpose != "delivery_result" and decision_scope is None: + decision_scope = "goal" + elif decision_scope is None: + decision_scope = "agent_lane" + with _source_guard(root, goal_id, path, registry_path if first_delivery else None): + if purpose == "first_delivery": + attempt = _inspect_attempt(root, identity) + if attempt.get("status") == "committed": + return {**attempt, "settlement_identity": identity.as_dict(), "purpose": purpose, + "instructions": "Direction already committed. Replay the original refresh or resume this Turn's remaining settlement; do not request another direction."} + with ExitStack() as read_locks: + if purpose == "delivery_result": + read_locks.enter_context(exclusive_cross_runtime_file_lock( + _receipt_path(root, identity, purpose), operation="result-work-read")) + previous_receipt = None + if first_delivery or continuation: + try: + previous_receipt = json.loads(_receipt_path(root, identity, purpose).read_text(encoding="utf-8")) + except FileNotFoundError: + pass + result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run, + writeback_committed=readback.writeback.failure is None, + quota_spend_committed=readback.spend_run is not None or readback.spend_event is not None, + semantic_replan_guard=readback.semantic_replan_guard, + purpose=purpose, decision_scope=decision_scope, + receipt=previous_receipt, + result_only=purpose == "delivery_result" and result_work_required(readback), + checkpoint_continuation=continuation, + full_frontier=continuation and purpose != "delivery_result", + admitted_turn=readback.heartbeat_receipt is not None, + read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], + facts=_local_source_facts(root, registry_path, path, identity, goal_ref, first_delivery=first_delivery)) + receipt = result.pop("receipt") + atomic_write_json(_receipt_path(root, identity, purpose), receipt) return {**result, "read_context_id": receipt["read_context_id"], "settlement_identity": identity.as_dict(), - "instructions": "Read this basis and judge the direction again. Echo read_context_id as " - "--checkpoint-read-context in the checkpoint-only refresh for this exact Turn. " + "purpose": purpose, + "instructions": ("Read this basis and validate the candidate. Echo read_context_id as --delivery-read-context in todo complete. " + if purpose == "delivery_result" else "Read this basis and judge the direction again. Echo read_context_id as --checkpoint-read-context in the direction refresh for this exact Turn. ") + "A new checkpoint-context read replaces this receipt; do not run parallel confirmations " "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend."} @@ -179,21 +736,34 @@ def checkpoint_commit_guard( *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, read_context_id: str | None, goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, ) -> Iterator[dict[str, Any]]: """Capture/preview under source locks. The native save repeats the check under the real provider fence; this preliminary check is not the commit.""" - with _source_guard(runtime_root, identity.goal_id, state_file): + first_delivery = purpose == "first_delivery" + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path if first_delivery else None): + readback = read_heartbeat_settlement(runtime_root, goal_id=identity.goal_id, + agent_id=identity.agent_id, todo_id=identity.todo_id, turn_instance_id=identity.turn_instance_id, + replan_obligation_id=identity.replan_obligation_id, registry_path=registry_path, goal_ref=goal_ref, + source_admission=source_admission, + borrow_source_admission=source_admission is not None) + if first_delivery: + _inspect_attempt(runtime_root, identity) try: receipt = json.loads(_receipt_path(runtime_root, identity).read_text(encoding="utf-8")) except FileNotFoundError: receipt = None result = _resolve(runtime_root, phase="check", identity=identity.as_dict(), read_context_id=read_context_id, + checkpoint_continuation=readback is not None and checkpoint_continuation_required(readback), + purpose=purpose, decision_scope=decision_scope, receipt=receipt, facts=_local_source_facts( runtime_root, registry_path, state_file, identity, goal_ref, + first_delivery=first_delivery, )) yield result @@ -203,23 +773,30 @@ def commit_checkpoint_run( refresh_retry: dict[str, Any], record: dict[str, Any], index_record: dict[str, Any], markdown: str, goal_ref: Mapping[str, Any] | None = None, source_admission: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, ) -> dict[str, Any]: """Handoff the held locks and parsed bytes to one native save operation.""" root = runtime_root.resolve() index = root / "goals" / identity.goal_id / "runs" / "index.jsonl" - targets = (index, shadow_maintenance_lock_target(root, identity.goal_id), + targets = (index, *((registry_path,) if purpose == "first_delivery" else ()), + shadow_maintenance_lock_target(root, identity.goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity.goal_id), state_file) result = _checkpoint_effect("goal.checkpoint_read_context.commit", { "runtime_root": str(root), "state_file": str(state_file.resolve()), "identity": identity.as_dict(), + "purpose": purpose, "decision_scope": decision_scope, + **({"registry_path": str(registry_path.resolve()), + "registry_sha256": hashlib.sha256(registry_path.read_bytes()).hexdigest()} + if purpose == "first_delivery" else {}), "locks": [cross_runtime_lock_witness(target) for target in targets], "state_sha256": hashlib.sha256(state_file.read_bytes()).hexdigest(), - "index_sha256": hashlib.sha256(index.read_bytes()).hexdigest(), + "index_sha256": hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), "facts": _local_source_facts( root, registry_path, state_file, identity, goal_ref, + first_delivery=purpose == "first_delivery", ), "refresh_retry": refresh_retry, "record": record, "index_record": index_record, "markdown": markdown, **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index 7455b5fd87..e6f7d1a419 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -6,8 +6,25 @@ import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_d import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {isUnqualifiedBlockedReplanWriteback} from "../quota/settlement_phase.ts"; +import {claimAllowsAgent} from "../todos/agent_scope.ts"; +import {resultFreshnessRequired, checkpointContinuationRequired} from "../quota/heartbeat_receipt_identity.ts"; +import {visionAuthoringContract} from "./vision_checkpoint.ts"; +import {hostRetryPolicyCheck} from "../turn_driver/turn_journal.ts"; +import {originalTurnRequestsNoFollowup} from "../todos/completion_state.ts"; +import {terminalValidationRecovery} from "../todos/completion_validation_plan.ts"; const RECEIPT_SCHEMA = "checkpoint_read_context_v1"; +const FIRST_RECEIPT_SCHEMA = "checkpoint_read_context_v2"; +export type CheckpointReadPurpose = "supplement_checkpoint" | "first_delivery" | "delivery_result"; +export type CheckpointDecisionScope = "agent_lane" | "goal"; + +function purpose(request: JsonObject): CheckpointReadPurpose { + const value = request.purpose ?? "supplement_checkpoint"; + if (value !== "supplement_checkpoint" && value !== "first_delivery" && value !== "delivery_result") { + throw new EffectRuntimeRequestError("unsupported checkpoint read purpose"); + } + return value; +} // Exact presentation fields only. Unknown future fields remain part of the basis. const DISPLAY_FIELDS = new Set(["index", "source_section", "schema_version"]); const todoFacts = (todo: JsonObject): JsonObject => Object.fromEntries( @@ -31,9 +48,34 @@ function dependencies(todo: JsonObject): string[] { return [...new Set(result)].sort(); } -function snapshot(request: JsonObject): JsonObject { +export function requireCompleteCheckpointGoalSource(facts: JsonObject): JsonObject { + const metadata = requireJsonObject(facts.frontmatter, "frontmatter"); + if (metadata.goal_source_completeness !== undefined) { + if (metadata.goal_source_completeness !== "todo_projection_only") { + throw new EffectRuntimeRequestError("unsupported Goal source completeness marker"); + } + throw new EffectRuntimeRequestError( + "Restore the Goal owner's actual document via loopx-project; a Todo-only projection cannot supply the Goal basis. Retry the original Turn without work or spend.", + "checkpoint_goal_source_incomplete", + ); + } + return metadata; +} + +export function checkpointBasisSnapshot(request: JsonObject): JsonObject { const identity = requireJsonObject(request.identity, "identity"); const facts = requireJsonObject(request.facts, "facts"); + const metadata = requireCompleteCheckpointGoalSource(facts); + // Re-entry retains the original result, not permission for a new direction. + // Consume the live registry facts under the existing source guard, including + // cached receipt checks, before a direction Host or writeback can run. + const compute = jsonObject(jsonObject(jsonObject(facts.source)?.registry_goal)?.quota)?.compute; + if (purpose(request) === "first_delivery" && typeof compute === "number" && compute <= 0) { + throw new EffectRuntimeRequestError( + "Automatic Goal turns are paused by compute quota. Preserve the original result and receipts; resume the Goal explicitly before continuing this Turn.", + "checkpoint_goal_paused", + ); + } if (!Array.isArray(facts.todos)) throw new EffectRuntimeRequestError("todos must be complete source records"); const records = facts.todos.map(value => todoFacts(requireJsonObject(value, "todo"))); const byId = new Map(); @@ -47,8 +89,23 @@ function snapshot(request: JsonObject): JsonObject { const todoId = identity.todo_id; const task = typeof todoId === "string" ? byId.get(todoId) : null; if (typeof todoId === "string" && !task) throw new EffectRuntimeRequestError("checkpoint Todo is absent; restore its authoritative record before rereading"); + let frontier: JsonObject[] | null = null; + if (purpose(request) === "first_delivery" || request.full_frontier === true) { + const scope = request.decision_scope; + if (scope !== "agent_lane" && scope !== "goal") { + throw new EffectRuntimeRequestError("first delivery requires agent_lane or goal decision scope"); + } + const agent = requireNonEmptyString(identity.agent_id, "agent_id"); + // Recompute the complete membership. The caller cannot omit new work. + frontier = records.filter(todo => scope === "goal" || todo.role === "user" || + claimAllowsAgent({claim: typeof todo.claimed_by === "string" ? todo.claimed_by : null, + excluded: ids(todo.excluded_agents, "excluded_agents")}, agent)); + frontier = frontier.map(todo => ({todo, key: String(todo.todo_id ?? canonicalAuthoritySha256(todo))})) + .sort((a, b) => a.key.localeCompare(b.key)).map(({todo}) => todo); + } const selected = new Map(); - const pending = [...ids(request.dependency_todo_ids, "dependency_todo_ids"), ...(task ? dependencies(task) : [])]; + const pending = [...ids(request.dependency_todo_ids, "dependency_todo_ids"), + ...(task ? dependencies(task) : []), ...(frontier ?? []).flatMap(dependencies)]; while (pending.length) { const id = pending.shift()!; if (selected.has(id) || id === todoId) continue; @@ -57,7 +114,6 @@ function snapshot(request: JsonObject): JsonObject { selected.set(id, todo); pending.push(...dependencies(todo)); } - const metadata = requireJsonObject(facts.frontmatter, "frontmatter"); const goal = { frontmatter: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "updated_at")), prose: facts.goal_prose, @@ -71,57 +127,329 @@ function snapshot(request: JsonObject): JsonObject { agent_vision: facts.agent_vision, source: facts.source, }; + if (frontier !== null) { + basis.frontier = {scope: request.decision_scope, todos: frontier}; + } + if (purpose(request) !== "supplement_checkpoint") { + basis.execution_lease = facts.execution_lease ?? null; + } return {basis, provider_revision: facts.provider_revision ?? null, versions: Object.fromEntries(Object.entries(basis).map(([key, value]) => [key, canonicalAuthoritySha256(value)]))}; } +function checkpointSnapshotOrRejection(request: JsonObject): JsonObject { + try { + return checkpointBasisSnapshot(request); + } catch (error) { + // This is a known source-state rejection, not malformed terminal input. + // Return it through the caller's existing zero-effect rejection path. + if (!(error instanceof EffectRuntimeRequestError) || + !["checkpoint_goal_source_incomplete", "checkpoint_goal_paused"].includes(error.code)) throw error; + return {ok: false, error_code: error.code, reread_required: false, + error: error.message, recommended_action: error.message}; + } +} + const rejected = (code: string, changed: string[] = []): JsonObject => ({ ok: false, error_code: code, reread_required: true, changed_components: changed, error: `${code}: run checkpoint-context for the same Goal/Agent/Todo or obligation/Turn, ` + - "read the returned state and judge again; submit its --checkpoint-read-context with only the vision decision. " + - "Do not repeat implementation, state mutations, or quota spend.", + "using the original purpose. Recheck the candidate for delivery_result, or judge the direction for first_delivery/supplement_checkpoint. " + + "Use the returned identity only with that new decision. Retain successful result and quota receipts.", }); export function evaluateCheckpointReadContext(value: unknown): JsonObject { const request = requireJsonObject(value, "checkpoint read context"); const identity = requireJsonObject(request.identity, "identity"); const token = request.read_context_id; + const use = purpose(request); if (request.phase === "read") { - const prior = requireJsonObject(request.prior, "committed writeback"); - const checkpoint = jsonObject(prior.vision_checkpoint); - const missingQualification = request.writeback_committed === true && request.quota_spend_committed === false && - typeof identity.replan_obligation_id === "string" && - jsonObject(request.semantic_replan_guard)?.selected_obligation_id === identity.replan_obligation_id && - isUnqualifiedBlockedReplanWriteback(prior, settlementIdentity({ - goal_id: requireNonEmptyString(identity.goal_id, "goal_id"), - agent_id: requireNonEmptyString(identity.agent_id, "agent_id"), - turn_instance_id: requireNonEmptyString(identity.turn_instance_id, "turn_instance_id"), - replan_obligation_id: identity.replan_obligation_id, - })); - if ((checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) && !missingQualification) { - return rejected("checkpoint_context_not_missing"); + if (use === "delivery_result" && + jsonObject(jsonObject(request.receipt)?.terminal_validation_effect)?.status === "pending") { + return {...rejected("delivery_validation_outcome_unknown"), reread_required: false, + error: "Original validation may have executed; reconcile its outcome before replacing the work read."}; + } + if (use !== "supplement_checkpoint") { + if (jsonObject(request.receipt)?.commit_attempt != null) { + return {...rejected("checkpoint_commit_unknown"), reread_required: false, + error: "A first delivery append may have started; inspect the original Turn and artifacts before another judgment."}; + } + if ((request.prior != null && !(use === "delivery_result" && request.result_only === true)) || request.admitted_turn !== true) { + return rejected("checkpoint_first_delivery_not_admitted"); + } + } else { + const prior = requireJsonObject(request.prior, "committed writeback"); + const checkpoint = jsonObject(prior.vision_checkpoint); + const missingQualification = request.writeback_committed === true && request.quota_spend_committed === false && + typeof identity.replan_obligation_id === "string" && + jsonObject(request.semantic_replan_guard)?.selected_obligation_id === identity.replan_obligation_id && + isUnqualifiedBlockedReplanWriteback(prior, settlementIdentity({ + goal_id: requireNonEmptyString(identity.goal_id, "goal_id"), + agent_id: requireNonEmptyString(identity.agent_id, "agent_id"), + turn_instance_id: requireNonEmptyString(identity.turn_instance_id, "turn_instance_id"), + replan_obligation_id: identity.replan_obligation_id, + })); + if ((checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) && !missingQualification) { + return rejected("checkpoint_context_not_missing"); + } + } + const projected = checkpointSnapshotOrRejection(request); + if (projected.ok === false) return projected; + const task = jsonObject(requireJsonObject(projected.basis, "basis").todo); + if (request.checkpoint_continuation === true && use !== "delivery_result" && + (request.full_frontier !== true || request.decision_scope !== "goal")) { + return rejected("checkpoint_direction_context_invalid"); + } + if (use === "first_delivery" && request.checkpoint_continuation === true && + !(task?.status === "done" && [identity.effect_id, identity.turn_instance_id].includes(task.completion_turn_key))) { + return {...rejected("checkpoint_result_not_committed"), reread_required: false}; + } + if (use === "delivery_result" && task?.status === "done" && + [identity.effect_id, identity.turn_instance_id].includes(task.completion_turn_key)) { + return {...rejected("delivery_result_already_committed"), reread_required: false, + original_read_context_id: jsonObject(request.receipt)?.read_context_id ?? null, + error: "This Turn's result is already committed. Keep its original read identity for replay and read first_delivery for the pending direction."}; } - const projected = snapshot(request); - return {ok: true, ...projected, receipt: { - schema_version: RECEIPT_SCHEMA, read_context_id: requireNonEmptyString(token, "read_context_id"), + const prior = jsonObject(request.prior); + const originalOutcome = prior === null ? null : Object.fromEntries([ + "classification", "delivery_outcome", "delivery_batch_scale", "recommended_action", "vision_checkpoint", + "autonomous_replan_ack", "settlement_identity", "progress_observation"].filter(key => prior[key] !== undefined) + .map(key => [key, prior[key]])); + return {ok: true, ...projected, ...(originalOutcome ? {original_outcome: originalOutcome} : {}), + ...(use !== "delivery_result" ? {vision_authoring: visionAuthoringContract()} : {}), receipt: { + schema_version: use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA, + ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: request.decision_scope} : {}), + read_context_id: requireNonEmptyString(token, "read_context_id"), + ...(request.full_frontier === true ? {full_frontier: true, decision_scope: request.decision_scope} : {}), identity, dependency_todo_ids: ids(request.dependency_todo_ids, "dependency_todo_ids"), versions: projected.versions, provider_revision: projected.provider_revision, + ...(use === "delivery_result" ? {work_basis: projected.basis} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" ? {decision_basis: projected.basis} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" ? {vision_authoring: visionAuthoringContract()} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" && originalOutcome ? {original_outcome: originalOutcome} : {}), + ...(request.checkpoint_continuation === true && use !== "delivery_result" && + canonicalAuthoritySha256(jsonObject(request.receipt)?.identity ?? null) === canonicalAuthoritySha256(identity) ? + Object.fromEntries(["direction_host_attempt_count", "direction_host_failure", "direction_stopped"] + .filter(key => jsonObject(request.receipt)?.[key] !== undefined) + .map(key => [key, jsonObject(request.receipt)?.[key]])) : {}), + ...(use === "delivery_result" && request.result_only === true ? {result_only: true} : {}), }}; } if (request.phase !== "check") throw new EffectRuntimeRequestError("unknown checkpoint read context phase"); if (typeof token !== "string" || !token.trim()) return rejected("checkpoint_read_context_required"); const receipt = jsonObject(request.receipt); - if (receipt?.schema_version !== RECEIPT_SCHEMA || receipt.read_context_id !== token) { + if (request.checkpoint_continuation === true && use !== "delivery_result" && + (receipt?.full_frontier !== true || receipt.decision_scope !== "goal")) { + return rejected("checkpoint_direction_context_invalid"); + } + if (receipt?.schema_version !== (use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA) || + receipt.read_context_id !== token || + (use !== "supplement_checkpoint" && receipt.purpose !== use)) { return rejected("checkpoint_read_context_unknown_or_replaced"); } if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) { return rejected("checkpoint_read_context_identity_mismatch"); } - const projected = snapshot({...request, dependency_todo_ids: receipt.dependency_todo_ids}); + if (use === "first_delivery" && request.decision_scope != null && + request.decision_scope !== receipt.decision_scope) { + return rejected("checkpoint_read_context_scope_mismatch"); + } + if (use === "first_delivery" && receipt.commit_attempt != null) { + return {...rejected("checkpoint_commit_unknown"), reread_required: false, + error: "The original direction append is uncertain; read back its original Turn before retrying."}; + } + const projected = checkpointSnapshotOrRejection({...request, dependency_todo_ids: receipt.dependency_todo_ids, + ...(receipt.full_frontier === true ? {full_frontier: true, decision_scope: receipt.decision_scope} : {}), + ...(use === "first_delivery" ? {decision_scope: receipt.decision_scope} : {})}); + if (projected.ok === false) return projected; + const committedTask = jsonObject(requireJsonObject(projected.basis, "basis").todo); + if (use === "delivery_result" && receipt.result_only === true && committedTask?.status === "done" && + [identity.effect_id, identity.turn_instance_id].includes(committedTask.completion_turn_key)) { + return {ok: true, read_context_id: token, result_committed: true}; + } const expected = requireJsonObject(receipt.versions, "receipt versions"); const current = requireJsonObject(projected.versions, "current versions"); const changed = Object.keys(current).filter(key => current[key] !== expected[key]); if (changed.length) return rejected("checkpoint_read_context_stale", changed); - return {ok: true, read_context_id: token, versions: current}; + return {ok: true, read_context_id: token, versions: current, + ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: receipt.decision_scope} : {})}; +} + +/** Work input and validation share the existing read-receipt identity owner. */ +export function evaluateResultWorkBinding(value: unknown): JsonObject { + const request = requireJsonObject(value, "result work binding"); + if (request.phase === "direction_terminal_intent") { + const todo = requireJsonObject(request.todo, "completion Todo"); + const identity = requireJsonObject(request.identity, "completion identity"); + return {no_followup: originalTurnRequestsNoFollowup(todo, identity)}; + } + if (request.phase === "replan_stage") { + const enabled = checkpointContinuationRequired(jsonObject(request.heartbeat_receipt)); + const pending = jsonObject(jsonObject(request.prior)?.vision_checkpoint)?.replan_stage === "awaiting_direction"; + const deferred = enabled && request.has_read_context !== true && (request.requested === true || pending); + return {deferred, qualify: deferred ? false : request.requested === true || (enabled && pending)}; + } + if (request.phase === "direction_dispatch") { + const receipt = requireJsonObject(request.receipt, "direction receipt"); + const failure = receipt.direction_host_failure; + if (receipt.direction_stopped === true) return rejected("checkpoint_direction_stopped"); + if (failure !== undefined) { + const policy = hostRetryPolicyCheck({host_failure: failure, + host_attempt_count: receipt.direction_host_attempt_count}); + if (policy?.outcome === "failed") return rejected(String(policy.reason)); + } + const count = receipt.direction_host_attempt_count ?? 0; + if (!Number.isInteger(count) || Number(count) < 0) return rejected("checkpoint_direction_attempt_invalid"); + return {ok: true, attempt: Number(count) + 1}; + } + if (request.phase === "direction_response") { + const receipt = requireJsonObject(request.receipt, "direction receipt"); + const decision = requireJsonObject(request.decision, "direction decision"); + const purpose = receipt.purpose ?? "supplement_checkpoint"; + if (!["first_delivery", "supplement_checkpoint"].includes(String(purpose)) || + receipt.schema_version !== (purpose === "first_delivery" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA) || + receipt.full_frontier !== true || receipt.decision_scope !== "goal") return rejected("checkpoint_direction_context_invalid"); + if (receipt.read_context_id !== decision.read_context_id || + canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(request.identity)) { + return rejected("checkpoint_direction_binding_mismatch"); + } + if (receipt.decision_basis == null || canonicalAuthoritySha256(receipt.versions) !== + canonicalAuthoritySha256(Object.fromEntries(Object.entries(requireJsonObject(receipt.decision_basis, "direction basis")) + .map(([key, value]) => [key, canonicalAuthoritySha256(value)])))) { + return rejected("checkpoint_direction_basis_corrupt"); + } + const vision = jsonObject(decision.agent_vision); + const unchanged = typeof decision.vision_unchanged_reason === "string" ? decision.vision_unchanged_reason.trim() : ""; + if ((vision !== null) === (unchanged.length > 0)) return rejected("checkpoint_direction_decision_required"); + if (vision === null && jsonObject(requireJsonObject(receipt.decision_basis, "direction basis").agent_vision) === null) { + return rejected("checkpoint_direction_baseline_required"); + } + return {ok: true, binding: {read_context_id: receipt.read_context_id, + identity_sha256: canonicalAuthoritySha256(request.identity), decision_sha256: canonicalAuthoritySha256(decision)}}; + } + if (request.phase === "direction_policy") return { + required: checkpointContinuationRequired(jsonObject(request.heartbeat_receipt)), + }; + if (request.phase === "legacy_commit_policy") { + const transaction = requireJsonObject(request.transaction, "locked completion transaction"); + if (transaction.schema_version !== "loopx_todo_completion_transaction_result_v0") { + throw new EffectRuntimeRequestError("Locked completion transaction schema mismatch"); + } + const fence = requireJsonObject(transaction.fence, "locked completion fence"); + const state = jsonObject(transaction.completion_state); + const upgrade = transaction.decision === "commit" && fence.status === "done" && + ["same_turn_terminal_upgrade", "lifecycle_reentry_terminal_upgrade"].includes(String(fence.reason)) && + state?.continuation === "no_followup" && typeof transaction.completion_identity_key === "string" && + transaction.completion_identity_key.length > 0; + if (upgrade) { + const todo = requireJsonObject(request.todo, "original committed Todo"); + const candidate = requireJsonObject(request.candidate, "Legacy closeout candidate"); + for (const field of ["evidence", "note", "reason"] as const) { + if (candidate[field] != null && candidate[field] !== todo[field]) { + return rejected("delivery_validation_binding_mismatch"); + } + } + } + return {required: !upgrade, recover_terminal_lease: upgrade}; + } + if (request.phase === "legacy_admission") { + const events = Array.isArray(request.events) ? request.events.map(event => requireJsonObject(event, "admission event")) : []; + const scopedEvents = events.filter(event => event.event_kind === "quota_should_run" && + canonicalAuthoritySha256(event.goal_ref ?? null) === canonicalAuthoritySha256(request.goal_ref ?? null) && + jsonObject(event.details)?.todo_id === request.todo_id); + const original = scopedEvents.filter(event => event.agent_id === request.agent_id && + [event.run_id, jsonObject(event.details)?.settlement_effect_id].includes(request.completion_turn_key)); + if (original.length && !original.some(resultFreshnessRequired)) return {required: false}; + const protectedEvents = scopedEvents.filter(resultFreshnessRequired); + if (!protectedEvents.length) return {required: false}; + const match = protectedEvents.filter(event => event.agent_id === request.agent_id && + [event.run_id, jsonObject(event.details)?.settlement_effect_id].includes(request.completion_turn_key)); + if (match.length !== 1 || request.identity == null || + jsonObject(request.identity)?.turn_instance_id !== match[0].run_id || + jsonObject(request.identity)?.agent_id !== match[0].agent_id || + jsonObject(request.identity)?.todo_id !== request.todo_id) return rejected("delivery_work_context_required"); + return {required: true}; + } + if (request.phase === "policy") return {required: resultFreshnessRequired(jsonObject(request.heartbeat_receipt))}; + if (request.phase === "admission") { + if (request.todo_id == null) return {policy: null}; + if (!["legacy_markdown", "file_v0", "sqlite_v0", "postgresql_v0"].includes(String(request.source_authority))) { + throw new EffectRuntimeRequestError("Result freshness requires a supported Todo authority", "delivery_provider_unsupported"); + } + return {policy: "turn_result_freshness_required_v1"}; + } + const identity = requireJsonObject(request.identity, "result work identity"); + const receipt = requireJsonObject(request.receipt, "result work receipt"); + if (receipt.schema_version !== FIRST_RECEIPT_SCHEMA || receipt.purpose !== "delivery_result" || + canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) { + return rejected("delivery_work_identity_mismatch"); + } + const basis = requireJsonObject(receipt.work_basis, "persisted work basis"); + if (Object.hasOwn(request, "goal_ref") && + canonicalAuthoritySha256(jsonObject(basis.source)?.goal_ref ?? null) !== canonicalAuthoritySha256(request.goal_ref)) { + return rejected("delivery_work_goal_instance_mismatch"); + } + const versions = requireJsonObject(receipt.versions, "persisted work versions"); + if (Object.keys(basis).some(key => canonicalAuthoritySha256(basis[key]) !== versions[key]) || + Object.keys(versions).some(key => !Object.hasOwn(basis, key))) { + return rejected("delivery_work_basis_corrupt"); + } + const readId = requireNonEmptyString(receipt.read_context_id, "persisted result read identity"); + if (request.phase === "begin_terminal_validation" || request.phase === "finish_terminal_validation") { + return terminalValidationRecovery(request, receipt); + } + if (request.phase === "restore") return {ok: true, basis, + read_context_id: readId, settlement_identity: identity}; + const terminal = jsonObject(request.terminal_input); + const candidate = terminal ? terminalResultCandidate(terminal) : requireJsonObject(request.candidate, "result candidate"); + const candidateDigest = canonicalAuthoritySha256(candidate); + if (request.phase === "bind_validation") { + const validation = terminal ? terminalResultValidation(terminal) : requireJsonObject(request.validation, "result validation"); + if (validation.ok !== true) return rejected("delivery_validation_not_passed"); + return {ok: true, binding: {schema_version: "result_validation_binding_v1", identity, + read_context_id: receipt.read_context_id, candidate_sha256: candidateDigest, + validation_sha256: canonicalAuthoritySha256(validation)}}; + } + if (request.phase !== "check_validation") throw new EffectRuntimeRequestError("unknown result work binding phase"); + const binding = requireJsonObject(request.binding, "result validation binding"); + if (binding.schema_version !== "result_validation_binding_v1" || + canonicalAuthoritySha256(binding.identity) !== canonicalAuthoritySha256(identity) || + binding.read_context_id !== receipt.read_context_id || binding.candidate_sha256 !== candidateDigest || + binding.validation_sha256 !== canonicalAuthoritySha256(terminal ? terminalResultValidation(terminal) : request.validation)) { + return rejected("delivery_validation_binding_mismatch"); + } + return {ok: true, read_context_id: receipt.read_context_id}; +} + +/** Bind the actual result bytes/workspace and issued native validation contract. */ +export function terminalResultCandidate(input: JsonObject): JsonObject { + return {result: input.completion_result ?? null, + evidence: input.evidence ?? null, note: input.note ?? null}; +} +export function terminalResultValidation(input: JsonObject): JsonObject { + return {ok: true, declaration: input.validation_declaration ?? null, + declaration_sha256: input.validation_declaration_sha256 ?? null, + receipt: input.validation_receipt ?? null, + acceptance_source: input.goal_acceptance_source_binding ?? null, + acceptance_receipts: input.goal_acceptance_validation_receipts ?? null}; +} + +/** Runtime admission for Legacy closeout shares the original quota receipt owner. */ +export async function executeResultWorkBinding(value: unknown): Promise { + const request = requireJsonObject(value, "result work request"); + const result = evaluateResultWorkBinding(request); + if (request.phase === "legacy_commit_policy" && result.recover_terminal_lease === true) { + const {verifyReentrySettlement} = await import("../coordination/todo_terminal_reentry.ts"); + const {localTodoCompletionIdentity} = await import("../todos/completion_fence.ts"); + const goal = requireNonEmptyString(request.goal_id, "goal_id"); + const todo = requireNonEmptyString(request.todo_id, "todo_id"); + const key = requireNonEmptyString(requireJsonObject(request.transaction, "transaction").completion_identity_key, "completion identity"); + if (key !== localTodoCompletionIdentity(goal, todo)) { + await verifyReentrySettlement(requireNonEmptyString(request.runtime_root, "runtime_root"), { + goal_id: goal, todo_id: todo, actor_agent_id: requireNonEmptyString(request.agent_id, "agent_id"), + requested_completion_turn_key: key, + }); + } + } + return result; } diff --git a/loopx/control_plane/heartbeat/agent.py b/loopx/control_plane/heartbeat/agent.py index db5361e29c..caa7ca40df 100644 --- a/loopx/control_plane/heartbeat/agent.py +++ b/loopx/control_plane/heartbeat/agent.py @@ -156,9 +156,11 @@ def render_peer_agent_scope_instruction( ) if compact: return ( - f"Agent identity and scope: agent_id `{identity}`; model: peer_v1; " - f"scope: {scope_text}. {peer_rule} Claim: `{claim_command}`. " - "Do not write scope into todo metadata." + f"Equal peer `{identity}` (peer_v1); scope: {scope_text}. " + "Obey quota claim/lease, workspace/repository and Todo continuation. " + "Quota-claim open in-scope work; peer-owned->another/no-work. " + "PR review=user_action+runnable successor; gate exact merge/release/launch only. " + "No peer authority from coordination. Scope=prompt, not Todo metadata." ) return f"""Agent identity and scope: diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index 40bb2aea0b..9fb17c8d5b 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -517,7 +517,9 @@ def build_heartbeat_prompt( task_body = task_body_renderer( goal_id=goal_id, active_state=active_state_text, - cli_preflight=cli_preflight, + # Thin remains an installed-CLI worker, as before. Bootstrap/full + # generator surfaces retain preflight; brief carries it in its body. + cli_preflight="" if thin else cli_preflight, pr_review_pre_quota_command=( "" if traex_visible_goal else commands["pr_review_pre_quota_command"] or "" ), diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index 26aad2c6a7..67e8130aec 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -24,12 +24,14 @@ "loopx_host_todo_completion_reduction_v0" ) _RUNTIME_METHOD = "turn.host_todo_completion.evaluate" -HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE = ( +HOST_ADAPTER_SETTLEMENT_STEP_KINDS = ( "guard", "lifecycle_completion", "durable_writeback", "quota_spend", "terminal_closeout", + "delivery_context", + "direction_context", ) _MISSING = object() @@ -62,7 +64,11 @@ class HostTodoSettlementRequest: vision_path: str | None = None vision_unchanged_reason: str | None = None checkpoint_read_context_id: str | None = None + first_delivery: bool = False + delivery_read_context_id: str | None = None goal_ref: Mapping[str, str] | None = None + turn_instance_id: str | None = None + current_agent_checkpoint: bool = False class HostCliRunner(Protocol): @@ -105,13 +111,21 @@ def _request_payload( payload["provider_outcomes"] = provider_outcomes if request.goal_ref is not None: payload["goal_ref"] = dict(request.goal_ref) - if request.vision_path or request.vision_unchanged_reason or phase in {"vision_refresh", "vision_context"}: + if request.turn_instance_id is not None: + payload["turn_instance_id"] = request.turn_instance_id + if request.vision_path or request.vision_unchanged_reason or request.checkpoint_read_context_id or phase in {"vision_refresh", "vision_context"} or request.first_delivery: payload.update( schema_version="loopx_host_todo_completion_transaction_v1", vision_path=request.vision_path, vision_unchanged_reason=request.vision_unchanged_reason, checkpoint_read_context_id=request.checkpoint_read_context_id, ) + if request.first_delivery or request.current_agent_checkpoint: + payload.update(schema_version="loopx_host_todo_completion_transaction_v2", first_delivery=True, + delivery_read_context_id=request.delivery_read_context_id, + current_agent_checkpoint=request.current_agent_checkpoint) + elif request.delivery_read_context_id: + payload["delivery_read_context_id"] = request.delivery_read_context_id return payload @@ -276,10 +290,12 @@ def _decode_provider_steps(value: Any) -> tuple[_ProviderStep, ...]: value.get("provider_id") != "loopx_cli" or value.get("kind") != "ordered_cli_sequence" or not isinstance(raw_steps, list) - or len(raw_steps) not in {4, 5} + or len(raw_steps) not in {2, 3, 4, 5} ): raise RuntimeError("TypeScript host provider plan shape mismatch") - expected = HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE[: len(raw_steps)] + expected = (("guard", "delivery_context") if len(raw_steps) == 2 else + ("guard", "lifecycle_completion", "direction_context") if len(raw_steps) == 3 else + HOST_ADAPTER_SETTLEMENT_STEP_KINDS[: len(raw_steps)]) steps: list[_ProviderStep] = [] for index, raw in enumerate(raw_steps): if not isinstance(raw, Mapping) or raw.get("step_kind") != expected[index]: @@ -404,6 +420,15 @@ def settle_host_todo_completion( ), phase="finalize", ) + if finalized.get("decision") == "execute": + token = finalized.get("resume_checkpoint_read_context_id") + if (not request.current_agent_checkpoint or request.checkpoint_read_context_id is not None + or not isinstance(token, str) or not token + or finalized.get("identity") != prepared["identity"]): + raise RuntimeError("Committed direction recovery binding is invalid") + request = replace(request, checkpoint_read_context_id=token) + outcomes = _run_provider_plan(_decode_provider_steps(finalized["provider_effect"]), run_cli=run_cli) + finalized = _runtime_reduction(_request_payload(request, phase="finalize", provider_outcomes=outcomes), phase="finalize") result = finalized.get("result") if ( finalized.get("decision") not in {"complete", "blocked", "provider_result"} diff --git a/loopx/control_plane/quota/goal_boundary.py b/loopx/control_plane/quota/goal_boundary.py index 4e8f18adab..a02c5518f3 100644 --- a/loopx/control_plane/quota/goal_boundary.py +++ b/loopx/control_plane/quota/goal_boundary.py @@ -439,23 +439,24 @@ def goal_boundary( if repository_identity and repository_identity.startswith("git:"): boundary["task_repository"] = repository_identity project_asset_source = item if item is not None else goal - if isinstance(project_asset_source, dict) and project_asset_source.get( - "project_asset" - ): - project_asset = project_asset_source.get("project_asset") - if isinstance(project_asset, dict): - if project_asset.get("stop_condition"): - boundary["stop_condition"] = project_asset.get("stop_condition") - if isinstance(project_asset.get("execution_profile"), dict): - boundary["execution_profile"] = ( - quota_execution_profile_boundary_summary( - project_asset["execution_profile"] - ) - ) - if isinstance(project_asset.get("orchestration"), dict): - boundary["orchestration"] = compact_orchestration_policy( - project_asset["orchestration"] + project_asset = ( + project_asset_source.get("project_asset") + if isinstance(project_asset_source, dict) + else None + ) + if isinstance(project_asset, dict): + if project_asset.get("stop_condition"): + boundary["stop_condition"] = project_asset.get("stop_condition") + if isinstance(project_asset.get("execution_profile"), dict): + boundary["execution_profile"] = ( + quota_execution_profile_boundary_summary( + project_asset["execution_profile"] ) + ) + if isinstance(project_asset.get("orchestration"), dict): + boundary["orchestration"] = compact_orchestration_policy( + project_asset["orchestration"] + ) # Model preferences belong to the current registry, not a stale asset snapshot. if spawn_policy is not None and "orchestration" in boundary: model_config = compact_orchestration_policy(spawn_policy).get("model_config") diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index 2c3d7b36d4..7a04e77250 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -205,6 +205,8 @@ def ensure_turn_heartbeat_settlement_receipt( semantic_replan_obligation_id: str | None, semantic_replan_capability_guard: Mapping[str, object] | None = None, goal_ref: Mapping[str, object] | None = None, + result_freshness: str | None = None, + checkpoint_continuation: str | None = None, ) -> dict[str, object]: """Idempotently bind a Turn-created quota guard to its settlement identity. @@ -297,6 +299,10 @@ def ensure_turn_heartbeat_settlement_receipt( "stall_observation": "not_applicable", "source": "loopx_turn_run_once", } + if result_freshness is not None: + details["result_freshness"] = result_freshness + if checkpoint_continuation is not None: + details["checkpoint_continuation"] = checkpoint_continuation if semantic_replan_guard_scoped: details["semantic_replan_obligation_id"] = ( normalized_semantic_replan_obligation_id or "" @@ -611,6 +617,8 @@ def heartbeat_receipt_view( "event_id": event.get("event_id"), "recorded_at": event.get("recorded_at"), } + if "result_freshness" in details: + receipt["result_freshness"] = details["result_freshness"] if details.get("closeout_required") is True: receipt["closeout_required"] = True todo_id = str(details.get("todo_id") or "").strip() diff --git a/loopx/control_plane/quota/heartbeat_receipt_identity.ts b/loopx/control_plane/quota/heartbeat_receipt_identity.ts index 4eaa75d083..3cd0b6448a 100644 --- a/loopx/control_plane/quota/heartbeat_receipt_identity.ts +++ b/loopx/control_plane/quota/heartbeat_receipt_identity.ts @@ -29,6 +29,28 @@ export function heartbeatReceiptDetails(event: JsonObject | null): JsonObject { return jsonObject(event?.details) ?? {}; } +/** Persisted admission, not a caller's completion opt-in. */ +export function resultFreshnessRequired(event: JsonObject | null): boolean { + const policy = heartbeatReceiptDetails(event).result_freshness; + if (policy === undefined) return false; // Historical admission is not rewritten. + if (policy !== "turn_result_freshness_required_v1") { + throw new EffectRuntimeRequestError("Result freshness admission is incomplete or unsupported", + "result_freshness_admission_unknown"); + } + return true; +} + +/** Direction cadence stays with the checkpoint owner; this selects transport. */ +export function checkpointContinuationRequired(event: JsonObject | null): boolean { + const mode = heartbeatReceiptDetails(event).checkpoint_continuation; + if (mode === undefined) return false; + if (mode !== "current_agent_v1") { + throw new EffectRuntimeRequestError("Checkpoint continuation admission is incomplete or unsupported", + "checkpoint_continuation_admission_unknown"); + } + return true; +} + export function optionalHeartbeatString(value: unknown): string | null { if (value === null || value === undefined) return null; return String(value).trim() || null; @@ -80,6 +102,8 @@ export interface HeartbeatReceiptFact extends JsonObject { request_subject?: ExecutionRequestSubject | null; settlement_effect_id: string | null; closeout_required: boolean; + result_freshness?: unknown; + checkpoint_continuation?: unknown; } export function heartbeatReceiptFactFromEvent( @@ -87,6 +111,8 @@ export function heartbeatReceiptFactFromEvent( ): HeartbeatReceiptFact { const eventDetails = heartbeatReceiptDetails(event); return { + result_freshness: eventDetails.result_freshness, + checkpoint_continuation: eventDetails.checkpoint_continuation, event_id: optionalHeartbeatString(event.event_id), run_id: optionalHeartbeatString(event.run_id), todo_id: optionalHeartbeatString(eventDetails.todo_id), @@ -176,6 +202,18 @@ export function selectEffectiveHeartbeatReceipt( const binding = heartbeatReceiptBinding(goalId, agentId, entry.fact); if (binding) identities.set(binding.identity_key, entry.value); } + const protectedIndex = entries.findIndex(entry => resultFreshnessRequired({details: entry.fact})); + const continuationIndex = entries.findIndex(entry => checkpointContinuationRequired({details: entry.fact})); + if (continuationIndex >= 0 && entries.slice(continuationIndex + 1).some(entry => + heartbeatReceiptBinding(goalId, agentId, entry.fact) !== null && + !checkpointContinuationRequired({details: entry.fact}))) { + throw receiptIdentityConflict("Checkpoint continuation admission cannot be downgraded"); + } + if (protectedIndex >= 0 && entries.slice(protectedIndex + 1).some(entry => + heartbeatReceiptBinding(goalId, agentId, entry.fact) !== null && + !resultFreshnessRequired({details: entry.fact}))) { + throw receiptIdentityConflict("A protected Turn admission cannot be replaced by an unprotected receipt"); + } if (identities.size > 1) { throw receiptIdentityConflict( "heartbeat receipt has conflicting settlement identities for the same goal, agent, and turn", diff --git a/loopx/control_plane/quota/refresh_recovery.ts b/loopx/control_plane/quota/refresh_recovery.ts index 39f50a7018..76c3ae6a6b 100644 --- a/loopx/control_plane/quota/refresh_recovery.ts +++ b/loopx/control_plane/quota/refresh_recovery.ts @@ -8,6 +8,7 @@ import { normalizeDeliveryWorkspaceSnapshot } from "../agents/delivery_workspace import { decodeExternalDelivery, type ExternalDeliveryRequest } from "./refresh_external_delivery.ts"; export interface RefreshRetryRequest { + first_delivery_request?: JsonObject | null; checkpoint_read_context_id?: string | null; external_delivery?: ExternalDeliveryRequest | null; vision: JsonObject | null; @@ -38,6 +39,8 @@ export function decodeRefreshRetry(value: unknown): RefreshRetryRequest | null { return value; }; return { + first_delivery_request: input.first_delivery_request == null ? null + : requireJsonObject(input.first_delivery_request, "first_delivery_request"), checkpoint_read_context_id: input.checkpoint_read_context_id == null ? null : nullableString("checkpoint_read_context_id"), external_delivery: decodeExternalDelivery(input.external_delivery), vision: input.vision === null ? null : requireJsonObject(input.vision, "refresh_retry.vision"), @@ -83,17 +86,24 @@ export function refreshRecovery( ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), })).digest("hex") : null; const mutationDigest = createHash("sha256").update(canonical(request.mutation)).digest("hex"); + const firstDigest = request.first_delivery_request == null ? null + : createHash("sha256").update(canonical(request.first_delivery_request)).digest("hex"); const changesMutation = Object.values(request.mutation).some((value) => value !== null && value !== false && (!Array.isArray(value) || value.length > 0)); const result = (decision: Decision, reason: string): JsonObject => ({ schema_version: "refresh_recovery_v0", decision, reason, vision_request_digest: digest, mutation_digest: mutationDigest, + ...(firstDigest ? {first_delivery_request_digest: firstDigest} : {}), ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), original_generated_at: jsonObject(prior?.refresh_recovery)?.original_generated_at ?? prior?.generated_at ?? null, }); if (!prior) return result("append", "first_writeback"); + const priorFirstDigest = jsonObject(prior.refresh_recovery)?.first_delivery_request_digest ?? null; + if ((firstDigest !== null || priorFirstDigest !== null) && firstDigest !== priorFirstDigest) { + return result("reject", "committed_first_delivery_request_conflict"); + } const checkpoint = jsonObject(prior.vision_checkpoint); const priorDigest = jsonObject(prior.refresh_recovery)?.vision_request_digest; // Repeated CLI annotations are not mutations. Semantic delivery changes are. diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index 4b438c521a..648fdb22f2 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -183,6 +183,14 @@ def attach_settlement_progress( if not isinstance(progress, dict) or progress.get("schema_version") != "quota_settlement_progress_v0": raise RuntimeError("TypeScript quota settlement progress missing or invalid") payload["settlement_progress"] = dict(progress) + if runtime_root is not None: + from ..goals.checkpoint_context_io import first_delivery_progress + + delivery = first_delivery_progress(runtime_root, readback, registry_path=registry_path) + if delivery is not None: + payload["first_delivery_progress"] = delivery + if delivery["stage"] in {"direction_pending", "direction_stopped", "direction_failed", "operation_unknown"}: + payload["recommended_action"] = delivery["next_action"] payload.pop("settlement_owed", None) identity = readback.identity.value if payload.get("ok") is not True or progress.get("next_step") != "quota_spend" or identity is None: @@ -221,6 +229,10 @@ def render_settlement_progress_markdown(payload: dict[str, Any]) -> list[str]: if not isinstance(progress, dict): return [] lines = [f"- settlement: `{progress.get('state')}`"] + delivery = payload.get("first_delivery_progress") + if isinstance(delivery, dict): + lines.extend([f"- first delivery: `{delivery['stage']}`; result committed: {delivery['result_committed']}", + f"- next: {delivery['next_action']}"]) if progress.get("closeout_kind") == "typed_blocked_writeback_no_spend": lines.append("- closeout: typed blocked writeback; no quota slot spent") elif progress.get("closeout_kind") == "capability_duty_retired_no_spend": diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index bf3e2cd458..ea9fa08664 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -39,6 +39,7 @@ def reconcile_existing_heartbeat_receipt( runtime_root: Path, turn_instance_id: str, existing: dict[str, object], + registry_path: Path | None = None, goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], str, bool, str]: """Bind or qualify same-Turn work without replacing its settlement identity.""" @@ -131,6 +132,12 @@ def reconcile_existing_heartbeat_receipt( todo_id=rollout_todo_id, replan_obligation_id=rollout_replan_obligation_id, ) + if registry_path is not None and not (existing_todo_id or existing_replan_obligation_id): + # A choice after an unbound observation is a new admission; + # historical already-bound receipts keep their original policy. + rollout_details.update(quota_result_work_admission_details( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=args.goal_id, todo_id=rollout_todo_id, goal_ref=goal_ref)) receipt, upgraded = upgrade_identityless_heartbeat_receipt( runtime_root, goal_id=args.goal_id, @@ -166,6 +173,7 @@ def reconcile_existing_heartbeat_receipt_for_turn( runtime_root: Path, turn_instance_id: str, existing: dict[str, object], + registry_path: Path | None = None, goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], str, bool, str, bool]: """Reconcile an existing receipt and report whether the turn is receipt-ready.""" @@ -176,11 +184,23 @@ def reconcile_existing_heartbeat_receipt_for_turn( runtime_root=runtime_root, turn_instance_id=turn_instance_id, existing=existing, + registry_path=registry_path, goal_ref=goal_ref, ) return receipt, status, appended, stall_observation, True +def quota_result_work_admission_details(*, registry_path: Path, runtime_root: Path, + goal_id: str, todo_id: str | None, + goal_ref: Mapping[str, object] | None = None) -> dict[str, object]: + """Compose new admission metadata using the existing typed checkpoint owner.""" + from ..goals.checkpoint_context_io import default_result_freshness_policy + + policy = default_result_freshness_policy(runtime_root, goal_id, todo_id, goal_ref) + return ({"result_freshness": policy, "checkpoint_continuation": "current_agent_v1"} + if policy is not None else {}) + + def render_existing_heartbeat_receipt_payload( payload: dict[str, object], *, diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index 4bae8b67b2..2d56b15694 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -58,6 +58,7 @@ import { optionalHeartbeatString as optionalString, selectEffectiveHeartbeatReceipt, heartbeatWorkRequalification, + resultFreshnessRequired, type HeartbeatReceiptFact, } from "./heartbeat_receipt_identity.ts"; @@ -683,6 +684,10 @@ function terminalResult( identity: SettlementIdentity, event: JsonObject | null, ): SettlementResult { + if (event?.status === "terminal_no_followup" && details(event).no_followup !== true) { + return settlementFailed({kind: "effect_outcome_unknown", step_kind: "terminal_closeout", + reason: "Terminal event and its no-follow-up evidence contradict; reconcile the original Turn."}); + } if (!event || details(event).no_followup !== true) { return settlementFailed({ kind: "receipt_missing", @@ -1445,3 +1450,32 @@ export async function readQuotaSettlement(value: unknown): Promise { }, ); } + +/** Default work resumes its original unfinished settlement, not the latest key. */ +export async function selectDefaultResultWorkTurn(value: unknown): Promise { + const raw = requireJsonObject(value, "default result work selection"); + const request = decodeRequest(raw); + return await withQuotaAccountingOwner(request.owner, async () => { + const snapshot = await readQuotaSettlementSnapshot(request.runtime_root, request.goal_id); + const turns = new Map(); + const todo = jsonObject(raw.todo); + for (const event of snapshot.events) { + const fields = details(event); + if (event.event_kind !== "quota_should_run" || event.agent_id !== request.agent_id || + !quotaOwnerOwnsProjection(request.owner, event.goal_ref) || !resultFreshnessRequired(event) || + typeof fields.todo_id !== "string" || (request.todo_id !== null && fields.todo_id !== request.todo_id)) continue; + if (todo?.status === "done" && ![event.run_id, fields.settlement_effect_id].includes(todo.completion_turn_key)) continue; + const turn = String(event.run_id); + if (raw.pending_only === true) { + const readback = readQuotaSettlementFromRequest({...request, todo_id: fields.todo_id, turn_instance_id: turn}, snapshot); + if (readback.replay_phase === "settled") continue; + } else if (todo?.status !== "done" && snapshot.events.some(done => done.event_kind === "todo_complete" && + done.agent_id === request.agent_id && done.run_id === event.run_id && done.todo_id === fields.todo_id && + quotaOwnerOwnsProjection(request.owner, done.goal_ref))) continue; + turns.set(turn, event); + } + if (turns.size > 1) throw new EffectRuntimeRequestError( + "Several unfinished Turns match this work; resume an exact original Turn", "delivery_work_turn_ambiguous"); + return {ok: true, turn_instance_id: [...turns.keys()][0] ?? null}; + }); +} diff --git a/loopx/control_plane/runtime/document_io.py b/loopx/control_plane/runtime/document_io.py index 66f94cc959..b1e7defb65 100644 --- a/loopx/control_plane/runtime/document_io.py +++ b/loopx/control_plane/runtime/document_io.py @@ -7,15 +7,23 @@ from __future__ import annotations import os +import json +from typing import Any + +from .private_permissions import protect_private_path import stat import tempfile from pathlib import Path -def atomic_write_state_text(path: Path, text: str, *, create_only: bool = False) -> None: +def atomic_write_state_text( + path: Path, text: str, *, create_only: bool = False, private: bool = False, +) -> None: """Persist complete UTF-8 state while the caller holds its sibling lock.""" path.parent.mkdir(parents=True, exist_ok=True) + if private: + protect_private_path(path.parent, directory=True) mode = stat.S_IMODE(path.stat().st_mode) if not create_only and path.exists() else 0o600 descriptor, temporary = tempfile.mkstemp( prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent) @@ -23,7 +31,10 @@ def atomic_write_state_text(path: Path, text: str, *, create_only: bool = False) temporary_path = Path(temporary) try: with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle: - os.chmod(temporary_path, mode) + if private: + protect_private_path(temporary_path) + else: + os.chmod(temporary_path, mode) handle.write(text) handle.flush() os.fsync(handle.fileno()) @@ -56,3 +67,9 @@ def verify_state_text_durable(path: Path, text: str) -> None: raise RuntimeError("Todo Markdown projection readback mismatch") os.fsync(handle.fileno()) fsync_state_directory(path) + + +def atomic_write_private_json(path: Path, payload: dict[str, Any]) -> None: + """Publish private complete JSON after enforcing platform access controls.""" + text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + atomic_write_state_text(path, text, private=True) diff --git a/loopx/control_plane/runtime/private_permissions.py b/loopx/control_plane/runtime/private_permissions.py new file mode 100644 index 0000000000..628da79111 --- /dev/null +++ b/loopx/control_plane/runtime/private_permissions.py @@ -0,0 +1,193 @@ +"""Platform permissions for caller-owned private local IO; no authority decisions.""" +from __future__ import annotations + +import os +from pathlib import Path +import stat + + +def protect_private_path(path: Path, *, directory: bool = False) -> None: + """Restrict an existing private leaf to its current account and OS admins. + + The caller owns the namespace/locking. Reparse points are never repaired. + Windows uses a protected DACL, not chmod's emulated POSIX write flag. + """ + info = path.lstat() + if (stat.S_ISLNK(info.st_mode) + or getattr(info, "st_file_attributes", 0) & 0x400 + or (not stat.S_ISDIR(info.st_mode) if directory else not stat.S_ISREG(info.st_mode))): + raise OSError("private IO path is not a regular owned leaf") + if os.name != "nt": + os.chmod(path, 0o700 if directory else 0o600) + return + _protect_windows_path(path, directory=directory) + + +def _protect_windows_path(path: Path, *, directory: bool) -> None: + # Native APIs are loaded only on Windows. No subprocess, external package, + # ambient inherited ACE or caller-supplied principal is trusted here. + import ctypes as ct + from ctypes import wintypes as w + + adv = ct.WinDLL("advapi32", use_last_error=True) + kernel = ct.WinDLL("kernel32", use_last_error=True) + pointer = ct.c_void_p + kernel.GetCurrentProcess.restype = w.HANDLE + kernel.CloseHandle.argtypes = [w.HANDLE] + kernel.LocalFree.argtypes = [pointer] + kernel.LocalFree.restype = pointer + adv.OpenProcessToken.argtypes = [w.HANDLE, w.DWORD, ct.POINTER(w.HANDLE)] + adv.GetTokenInformation.argtypes = [w.HANDLE, ct.c_int, pointer, w.DWORD, ct.POINTER(w.DWORD)] + adv.ConvertSidToStringSidW.argtypes = [pointer, ct.POINTER(w.LPWSTR)] + adv.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [w.LPCWSTR, w.DWORD, ct.POINTER(pointer), ct.POINTER(w.DWORD)] + adv.GetSecurityDescriptorDacl.argtypes = [pointer, ct.POINTER(w.BOOL), ct.POINTER(pointer), ct.POINTER(w.BOOL)] + adv.SetNamedSecurityInfoW.argtypes = [w.LPWSTR, ct.c_int, w.DWORD, pointer, pointer, pointer, pointer] + adv.SetNamedSecurityInfoW.restype = w.DWORD + token = w.HANDLE() + sd = pointer() + sid_text = w.LPWSTR() + try: + if not adv.OpenProcessToken(kernel.GetCurrentProcess(), 0x8, ct.byref(token)): + raise ct.WinError(ct.get_last_error()) + size = w.DWORD() + adv.GetTokenInformation(token, 1, None, 0, ct.byref(size)) # TokenUser + if not size.value: + raise ct.WinError(ct.get_last_error()) + user = ct.create_string_buffer(size.value) + if not adv.GetTokenInformation(token, 1, user, size, ct.byref(size)): + raise ct.WinError(ct.get_last_error()) + sid = ct.cast(user, ct.POINTER(pointer))[0] + if not adv.ConvertSidToStringSidW(sid, ct.byref(sid_text)): + raise ct.WinError(ct.get_last_error()) + flags = "OICI" if directory else "" + descriptor = f"D:P(A;{flags};FA;;;{sid_text.value})(A;{flags};FA;;;SY)(A;{flags};FA;;;BA)" + if not adv.ConvertStringSecurityDescriptorToSecurityDescriptorW(descriptor, 1, ct.byref(sd), None): + raise ct.WinError(ct.get_last_error()) + present, defaulted = w.BOOL(), w.BOOL() + dacl = pointer() + if not adv.GetSecurityDescriptorDacl(sd, ct.byref(present), ct.byref(dacl), ct.byref(defaulted)) or not present.value or not dacl.value: + raise OSError("private IO DACL unavailable") + # SE_FILE_OBJECT; DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION. + error = adv.SetNamedSecurityInfoW(str(path), 1, 0x80000004, None, None, dacl, None) + if error: + raise ct.WinError(error) + finally: + if sid_text: + kernel.LocalFree(ct.cast(sid_text, pointer)) + if sd: + kernel.LocalFree(sd) + if token: + kernel.CloseHandle(token) + + +def require_private_path(path: Path, *, directory: bool = False, descriptor: int | None = None) -> None: + """Read OS access facts without repairing an untrusted caller's file.""" + info = path.lstat() + if (stat.S_ISLNK(info.st_mode) + or getattr(info, "st_file_attributes", 0) & 0x400 + or (not stat.S_ISDIR(info.st_mode) if directory else not stat.S_ISREG(info.st_mode))): + raise OSError("private IO path is not a regular owned leaf") + if descriptor is not None: + opened = os.fstat(descriptor) + if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): + raise OSError("private IO path changed after open") + info = opened + if os.name != "nt": + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise OSError("private IO permissions are invalid") + return + _require_windows_private_path(path, descriptor=descriptor) + + +def _require_windows_private_path(path: Path, *, descriptor: int | None) -> None: + import ctypes as ct + from ctypes import wintypes as w + import msvcrt + + adv = ct.WinDLL("advapi32", use_last_error=True) + kernel = ct.WinDLL("kernel32", use_last_error=True) + pointer = ct.c_void_p + out = ct.POINTER(pointer) + kernel.GetCurrentProcess.restype = w.HANDLE + kernel.CloseHandle.argtypes = [w.HANDLE] + kernel.LocalFree.argtypes = [pointer] + kernel.LocalFree.restype = pointer + adv.OpenProcessToken.argtypes = [w.HANDLE, w.DWORD, ct.POINTER(w.HANDLE)] + adv.GetTokenInformation.argtypes = [w.HANDLE, ct.c_int, pointer, w.DWORD, ct.POINTER(w.DWORD)] + adv.ConvertSidToStringSidW.argtypes = [pointer, ct.POINTER(w.LPWSTR)] + adv.GetNamedSecurityInfoW.argtypes = [w.LPCWSTR, ct.c_int, w.DWORD, out, out, out, out, out] + adv.GetNamedSecurityInfoW.restype = w.DWORD + adv.GetSecurityInfo.argtypes = [w.HANDLE, ct.c_int, w.DWORD, out, out, out, out, out] + adv.GetSecurityInfo.restype = w.DWORD + adv.GetSecurityDescriptorControl.argtypes = [pointer, ct.POINTER(w.WORD), ct.POINTER(w.DWORD)] + adv.GetAclInformation.argtypes = [pointer, pointer, w.DWORD, ct.c_int] + adv.GetAce.argtypes = [pointer, w.DWORD, out] + token, sd, owner, dacl = w.HANDLE(), pointer(), pointer(), pointer() + + def sid_text(sid: pointer) -> str: + value = w.LPWSTR() + if not sid or not adv.ConvertSidToStringSidW(sid, ct.byref(value)): + raise OSError("private IO SID unavailable") + try: + return str(value.value) + finally: + kernel.LocalFree(ct.cast(value, pointer)) + + try: + if not adv.OpenProcessToken(kernel.GetCurrentProcess(), 0x8, ct.byref(token)): + raise ct.WinError(ct.get_last_error()) + size = w.DWORD() + adv.GetTokenInformation(token, 1, None, 0, ct.byref(size)) + if not size.value: + raise OSError("private IO account unavailable") + user = ct.create_string_buffer(size.value) + if not adv.GetTokenInformation(token, 1, user, size, ct.byref(size)): + raise ct.WinError(ct.get_last_error()) + allowed = {sid_text(ct.cast(user, ct.POINTER(pointer))[0]), "S-1-5-18", "S-1-5-32-544"} + args = (1, 0x5, ct.byref(owner), None, ct.byref(dacl), None, ct.byref(sd)) + error = (adv.GetNamedSecurityInfoW(str(path), *args) if descriptor is None else + adv.GetSecurityInfo(w.HANDLE(msvcrt.get_osfhandle(descriptor)), *args)) + if error: + raise ct.WinError(error) + control, revision = w.WORD(), w.DWORD() + if (not sd or not dacl or sid_text(owner) not in allowed + or not adv.GetSecurityDescriptorControl(sd, ct.byref(control), ct.byref(revision)) + or not control.value & 0x1000): # SE_DACL_PROTECTED + raise OSError("private IO owner or protected DACL is invalid") + info = (w.DWORD * 3)() + if not adv.GetAclInformation(dacl, info, ct.sizeof(info), 2) or info[0] != 3: + raise OSError("private IO ACE set is invalid") + seen = set() + for index in range(info[0]): + ace = pointer() + if not adv.GetAce(dacl, index, ct.byref(ace)) or not ace.value: + raise OSError("private IO ACE unavailable") + header = (ct.c_ubyte * 4).from_address(ace.value) + # Only explicit ACCESS_ALLOWED_ACE, full control, current account/OS admins. + if header[0] != 0 or header[1] & 0x10 or ct.c_uint32.from_address(ace.value + 4).value != 0x1F01FF: + raise OSError("private IO ACE grants unexpected access") + principal = sid_text(pointer(ace.value + 8)) + if principal not in allowed: + raise OSError("private IO principal is invalid") + seen.add(principal) + if seen != allowed: + raise OSError("private IO account access is incomplete") + finally: + if sd: + kernel.LocalFree(sd) + if token: + kernel.CloseHandle(token) + + +if __name__ == "__main__": + # Fixed, package-owned OS IO helper for Node on Windows; no Goal decisions. + import sys + if len(sys.argv) != 3 or sys.argv[1] != "--verify-snapshot": + raise SystemExit(2) + try: + leaf = Path(sys.argv[2]) + require_private_path(leaf.parent, directory=True) + with leaf.open("rb") as handle: + require_private_path(leaf, descriptor=handle.fileno()) + except OSError: + raise SystemExit(1) from None diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 54b0b30420..8154cb599f 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -447,7 +447,12 @@ def render_scheduler_execution_args( f"unsupported scheduler runtime profile: {runtime_profile}" ) from exc return _render_scheduler_runtime_profile_args(profile) - if scheduler_execution_context is None: + if scheduler_execution_context is None or ( + isinstance(scheduler_execution_context, SchedulerExecutionContextResolution) + and scheduler_execution_context == resolve_scheduler_execution_context(None) + ): + # Resolution preserves an absent caller context. Rendering that absence + # must not infer a host or turn it into an invalid explicit empty config. return "" resolution = resolve_scheduler_execution_context(scheduler_execution_context) if not resolution.ok or resolution.context is None: diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index ffe2a9ea5e..4fc2382458 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -330,6 +330,9 @@ def observe_source_goal(current_goal_id: str, source_goal: dict[str, Any] | None ) attach_goal_acceptance_observations(payload, history=history) attach_goal_artifact_lifecycle_projections(payload, history=history) + from .first_delivery import attach_first_delivery_status + + attach_first_delivery_status(payload, runtime_root=runtime_root, agent_id=agent_lane_id) payload["projection_envelope"] = seal_projection_envelope( projection="status", observed_at=now_utc_iso(), diff --git a/loopx/control_plane/status/first_delivery.py b/loopx/control_plane/status/first_delivery.py new file mode 100644 index 0000000000..9b91932025 --- /dev/null +++ b/loopx/control_plane/status/first_delivery.py @@ -0,0 +1,26 @@ +"""Expose enrolled Turn progress through existing status consumer fields.""" +from pathlib import Path +from typing import Any + +from ..goals.checkpoint_context_io import pending_first_delivery_progress +from ..quota.states import quota_item_is_paused + + +def attach_first_delivery_status(payload: dict[str, Any], *, runtime_root: Path, agent_id: str | None) -> None: + for item in payload.get("attention_queue", {}).get("items", []): + goal_id = item.get("goal_id") + if not goal_id: + continue + progress = pending_first_delivery_progress(runtime_root, goal_id, agent_id, + registry_path=Path(payload["registry"]) if payload.get("registry") else None) + if progress is None: + continue + item["first_delivery_progress"] = progress + if not progress.get("settlement_identity") or quota_item_is_paused(item) or item.get("requires_user_action") is True: + continue + action = progress["next_action"] + item["recommended_action"] = action + for field in ("project_asset", "goal_channel_projection"): + projection = item.get(field) + if isinstance(projection, dict): + projection["next_action"] = action diff --git a/loopx/control_plane/todos/completion_state.ts b/loopx/control_plane/todos/completion_state.ts index 663902c0f1..15a767c25d 100644 --- a/loopx/control_plane/todos/completion_state.ts +++ b/loopx/control_plane/todos/completion_state.ts @@ -137,6 +137,15 @@ export function completionContinuationForWrite( return "active_goal"; } +/** Original closeout intent is distinct from a committed terminal transition. */ +export function originalTurnRequestsNoFollowup(todo: JsonObject, identity: JsonObject): boolean { + const requested = todo.completion_requested_continuation == null ? null : + requireTodoCompletionMetadata("completion_continuation", todo.completion_requested_continuation); + return normalizeTodoNoFollowup(todo.no_followup) === true || + (todo.status === "done" && todo.completion_continuation === "active_goal" && requested === "no_followup" && + [identity.effect_id, identity.turn_instance_id].includes(todo.completion_turn_key)); +} + export function selectTodoCompletionState(value: unknown): TodoCompletionStateResult { const request = requestObject(value, "evaluate"); const todo = requiredObject(request.todo, "todo"); @@ -144,8 +153,13 @@ export function selectTodoCompletionState(value: unknown): TodoCompletionStateRe request.requested_no_followup, "requested_no_followup", ); - const effectiveNoFollowup = requestedNoFollowup || - normalizeTodoNoFollowup(todo.no_followup) === true; + const deferNoFollowup = request.defer_no_followup === undefined ? false : + requiredBoolean(request.defer_no_followup, "defer_no_followup"); + if (deferNoFollowup && request.completion_identity_source !== "turn_settlement") { + throw new EffectRuntimeRequestError("Deferred closeout requires the original Turn settlement identity"); + } + const effectiveNoFollowup = !deferNoFollowup && + (requestedNoFollowup || normalizeTodoNoFollowup(todo.no_followup) === true); const continuation = completionContinuationForWrite( effectiveNoFollowup, requiredBoolean(request.has_successor, "has_successor"), @@ -163,7 +177,7 @@ export function selectTodoCompletionState(value: unknown): TodoCompletionStateRe throw new EffectRuntimeRequestError("completion_identity_source is unsupported"); } const recovery = String(todo.status ?? "") === "done" && - requestedNoFollowup && + requestedNoFollowup && !deferNoFollowup && normalizeTodoCompletionContinuation(todo.completion_continuation) === "active_goal" ? completionIdentitySource === "lifecycle_reentry" diff --git a/loopx/control_plane/todos/completion_transaction.py b/loopx/control_plane/todos/completion_transaction.py index 39209b52da..4e83ac2f98 100644 --- a/loopx/control_plane/todos/completion_transaction.py +++ b/loopx/control_plane/todos/completion_transaction.py @@ -424,12 +424,16 @@ def _valid_completion_settlement(result: Mapping[str, Any]) -> bool: and (state.get("recovery") is None or state.get("recovery") in _RECOVERIES) and isinstance(updates, Mapping) and all( - key in {"completion_continuation", "completion_recovery", "completion_receipt_id"} + key in {"completion_continuation", "completion_requested_continuation", "completion_recovery", "completion_receipt_id"} and isinstance(value, str) for key, value in updates.items() ) and updates.get("completion_continuation") == state.get("continuation") and updates.get("completion_recovery") == state.get("recovery") + and ( + updates.get("completion_requested_continuation") is None + or updates["completion_requested_continuation"] in _CONTINUATIONS + ) and ( updates.get("completion_receipt_id") is None or ( @@ -526,6 +530,7 @@ def reduce_todo_completion_transaction( dry_run: bool, validation_receipt: Mapping[str, Any] | None = None, completion_policy_request: Mapping[str, Any] | None = None, + defer_no_followup: bool = False, ) -> dict[str, Any]: """Reduce one coarse completion request through the TS authority.""" @@ -547,6 +552,8 @@ def reduce_todo_completion_transaction( else None ), } + if defer_no_followup: + params["defer_no_followup"] = True if completion_policy_request is not None: params["completion_policy_request"] = dict(completion_policy_request) result = effect_runtime_result("todo.completion.reduce", params) diff --git a/loopx/control_plane/todos/completion_transaction.ts b/loopx/control_plane/todos/completion_transaction.ts index 830f2636f0..654b04087b 100644 --- a/loopx/control_plane/todos/completion_transaction.ts +++ b/loopx/control_plane/todos/completion_transaction.ts @@ -23,6 +23,7 @@ import { } from "./completion_fence.ts"; import { buildTodoCompletionMetadataUpdates, + normalizeTodoNoFollowup, selectTodoCompletionState, type TodoCompletionRecovery, type TodoCompletionContinuation, @@ -144,6 +145,7 @@ interface DecodedTransactionRequest { projection_source: TodoCompletionProjectionSource; todo: JsonObject; requested_no_followup: boolean; + defer_no_followup: boolean; requested_completion_turn_key: string | null; requested_completion_identity_source: TodoCompletionIdentitySource | null; requested_has_successor: boolean; @@ -263,6 +265,8 @@ function decodeRequest(value: unknown): DecodedTransactionRequest { request.requested_no_followup, "requested_no_followup", ), + defer_no_followup: request.defer_no_followup === undefined ? false : + requireBoolean(request.defer_no_followup, "defer_no_followup"), requested_completion_turn_key: optionalNonEmptyString( request.requested_completion_turn_key, "requested_completion_turn_key", @@ -375,7 +379,7 @@ export function reduceTodoCompletionTransaction( schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, projection_source: request.projection_source, todo: request.todo, - requested_no_followup: request.requested_no_followup, + requested_no_followup: request.requested_no_followup && !request.defer_no_followup, requested_completion_turn_key: identity.key, requested_completion_identity_source: identity.source, goal_id: request.goal_id, @@ -385,7 +389,7 @@ export function reduceTodoCompletionTransaction( schema_version: TODO_COMPLETION_VALIDATION_PLAN_REQUEST_SCHEMA, projection_source: request.projection_source, todo: request.todo, - requested_no_followup: request.requested_no_followup, + requested_no_followup: request.requested_no_followup && !request.defer_no_followup, requested_completion_turn_key: identity.key, requested_completion_identity_source: identity.source, goal_id: request.goal_id, @@ -483,6 +487,7 @@ export function reduceTodoCompletionTransaction( schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, todo: request.todo, requested_no_followup: request.requested_no_followup, + defer_no_followup: request.defer_no_followup, has_successor: request.requested_has_successor || fenceRequest.todo.successor_todo_ids.length > 0, @@ -495,13 +500,22 @@ export function reduceTodoCompletionTransaction( normalized_status: "done", completion_continuation: completionStateResult.continuation, completion_recovery: completionStateResult.recovery, - no_followup: request.requested_no_followup, + no_followup: request.requested_no_followup && !request.defer_no_followup, successor_todo_ids: null, }); const updates = requireJsonObject( metadataResult.updates, "completion metadata updates", ); + // Keep the original closeout intent while the required checkpoint is pending. + // This is intent, not a terminal recovery or a completed closeout receipt. + if (request.defer_no_followup === true && + (request.requested_no_followup || normalizeTodoNoFollowup(request.todo.no_followup) === true)) { + if (completionStateResult.continuation !== "active_goal") { + throw new EffectRuntimeRequestError("Deferred no_followup cannot introduce a successor"); + } + updates.completion_requested_continuation = "no_followup"; + } // Checkpoint the committed phase, not its clock tick. Persisting this id // lets crash recovery and later reads retain the original hook identity. updates.completion_receipt_id = `tcw_${canonicalAuthoritySha256({ diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index 1284b96972..0f77e5978d 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -1,7 +1,7 @@ from __future__ import annotations import subprocess -from collections.abc import Mapping +from collections.abc import Callable, Mapping from json import loads as json_loads from pathlib import Path from typing import Any, cast @@ -495,6 +495,7 @@ def run_completion_validation_gate_with_source( goal_id: str, dry_run: bool, no_followup: bool = False, + defer_no_followup: bool = False, completion_turn_key: str | None = None, completion_identity_source: str | None = None, requested_has_successor: bool = False, @@ -502,6 +503,7 @@ def run_completion_validation_gate_with_source( requested_successor_todo_ids: list[str] | None = None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, + admit_validation: Callable[[dict[str, Any]], None] | None = None, ) -> dict[str, Any]: """Run the caller-approved completion validation gate, OUTSIDE the mutation lock. @@ -543,6 +545,7 @@ def run_completion_validation_gate_with_source( projection_source=projection_source, completion_turn_key=completion_turn_key, no_followup=no_followup, + defer_no_followup=defer_no_followup, dry_run=dry_run, completion_identity_source=completion_identity_source, goal_id=goal_id, @@ -556,6 +559,8 @@ def run_completion_validation_gate_with_source( ) completion_validation = None if transaction["decision"] == "execute_validation": + if admit_validation is not None: + admit_validation(transaction) effect = transaction["validation_effect"] completion_validation = run_declared_completion_validation_effect( effect=effect, @@ -571,6 +576,7 @@ def run_completion_validation_gate_with_source( projection_source=projection_source, completion_turn_key=completion_turn_key, no_followup=no_followup, + defer_no_followup=defer_no_followup, dry_run=dry_run, completion_identity_source=completion_identity_source, goal_id=goal_id, diff --git a/loopx/control_plane/todos/completion_validation_plan.ts b/loopx/control_plane/todos/completion_validation_plan.ts index 63fab39b40..9ccba34dc1 100644 --- a/loopx/control_plane/todos/completion_validation_plan.ts +++ b/loopx/control_plane/todos/completion_validation_plan.ts @@ -1,4 +1,5 @@ import type { JsonObject } from "../effect_program.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean, @@ -118,7 +119,11 @@ export function evaluateTodoCompletionValidationPlan( goal_id: request.goal_id, todo_id: request.todo_id, }); - if (fence.outcome === "replay") { + // The typed fence admits only a bound upgrade of an already committed + // RESULT. Closeout reuses that result's validation; it is not new work. + if (fence.outcome === "replay" || + fence.reason === "same_turn_terminal_upgrade" || + fence.reason === "lifecycle_reentry_terminal_upgrade") { return { schema_version: TODO_COMPLETION_VALIDATION_PLAN_RESULT_SCHEMA, effect: "skip", @@ -155,3 +160,58 @@ export function evaluateTodoCompletionValidationPlan( validation_timeout_seconds: declaration.value.validation_timeout_seconds, }; } + + +/** Recover an admitted validation effect, never a new completion authority. */ +export function terminalValidationRecovery(request: JsonObject, receipt: JsonObject): JsonObject { + const input = requireJsonObject(request.terminal_input, "terminal validation request"); + const issued = requireJsonObject(request.execution_result, "issued validation effects"); + if (issued.status !== "execute_validation") throw new EffectRuntimeRequestError("Validation recovery requires native effect admission"); + const transient = new Set(["observed_at", "delivery_context", "validation_source_provider_revision", + "validation_receipt", "goal_acceptance_validation_receipts", "result_validation_binding"]); + const binding = canonicalAuthoritySha256({read_context_id: receipt.read_context_id, + request: Object.fromEntries(Object.entries(input).filter(([key]) => !transient.has(key))), + validation_effect: issued.validation_effect ?? null, + acceptance_effects: issued.goal_acceptance_validation_effects ?? null, + acceptance_source: issued.goal_acceptance_source_binding ?? null}); + const previous = receipt.terminal_validation_effect == null ? null + : requireJsonObject(receipt.terminal_validation_effect, "terminal validation recovery record"); + const reject = (code: string): JsonObject => ({ok: false, error_code: code, + error: "Retain the original validation effect and candidate; reconcile its outcome before retrying."}); + if (previous !== null && (previous.schema_version !== "terminal_validation_effect_v1" || previous.binding_sha256 !== binding)) { + return reject("delivery_validation_binding_mismatch"); + } + if (previous !== null && previous.status !== "pending" && + (!["passed", "failed"].includes(String(previous.status)) || + previous.updates_sha256 !== canonicalAuthoritySha256(previous.updates))) { + return reject("delivery_validation_receipt_corrupt"); + } + if (request.phase === "begin_terminal_validation") { + if (previous?.status === "pending") return reject("delivery_validation_outcome_unknown"); + if (previous?.status === "passed") return {ok: true, effect: "replay", updates: previous.updates}; + return {ok: true, effect: "run", receipt: {...receipt, terminal_validation_effect: { + schema_version: "terminal_validation_effect_v1", binding_sha256: binding, status: "pending"}}}; + } + if (request.phase !== "finish_terminal_validation" || previous?.status !== "pending") { + return reject("delivery_validation_outcome_unknown"); + } + const updates = requireJsonObject(request.updates, "validation outcome updates"); + if (Object.keys(updates).some(key => !["validation_receipt", "goal_acceptance_source_binding", "goal_acceptance_validation_receipts"].includes(key))) { + throw new EffectRuntimeRequestError("Validation outcome contains non-effect fields"); + } + const outcomes: JsonObject[] = []; + if (issued.validation_effect != null) outcomes.push(requireJsonObject(updates.validation_receipt, "caller validation outcome")); + if (issued.goal_acceptance_validation_effects != null) { + if (!Array.isArray(updates.goal_acceptance_validation_receipts)) throw new EffectRuntimeRequestError("Acceptance outcomes missing"); + for (const row of updates.goal_acceptance_validation_receipts) { + outcomes.push(requireJsonObject(requireJsonObject(row, "criterion outcome").receipt, "criterion validation outcome")); + } + } + if (!outcomes.length) throw new EffectRuntimeRequestError("Validation outcome missing"); + // A known failed check remains retryable under the existing completion rule. + // Only an issued, fully successful effect group is recovered without running. + const passed = outcomes.every(outcome => outcome.passed === true); + return {ok: true, receipt: {...receipt, terminal_validation_effect: { + schema_version: "terminal_validation_effect_v1", binding_sha256: binding, + status: passed ? "passed" : "failed", updates, updates_sha256: canonicalAuthoritySha256(updates)}}}; +} diff --git a/loopx/control_plane/todos/completion_validation_store.py b/loopx/control_plane/todos/completion_validation_store.py index a90b384672..0932740a22 100644 --- a/loopx/control_plane/todos/completion_validation_store.py +++ b/loopx/control_plane/todos/completion_validation_store.py @@ -2,15 +2,15 @@ from __future__ import annotations -import os import re from collections.abc import Iterable, Mapping from pathlib import Path from typing import Any -from ...registry import atomic_write_json, read_json +from ...registry import read_json from ...file_lock import exclusive_cross_runtime_file_lock -from ..runtime.document_io import fsync_state_directory +from ..runtime.document_io import atomic_write_private_json as atomic_write_json, fsync_state_directory +from ..runtime.private_permissions import protect_private_path from .completion_validation_projection import ( completion_validation_declaration, completion_validation_declaration_sha256, @@ -67,9 +67,7 @@ def persist_completion_validation_declaration( "declaration_sha256": digest, "declaration": normalized, }, - preserve_mode=True, ) - os.chmod(path, 0o600) return digest @@ -96,6 +94,8 @@ def prepare_completion_validation_declaration( raise ValueError("prepared validation declaration digest mismatch") else: atomic_write_json(path, payload) + protect_private_path(path.parent, directory=True) + protect_private_path(path) # Also re-establish directory durability on an idempotent retry. fsync_state_directory(path) return digest diff --git a/loopx/control_plane/todos/contract.py b/loopx/control_plane/todos/contract.py index f51e7c6dbc..9cad964cb1 100644 --- a/loopx/control_plane/todos/contract.py +++ b/loopx/control_plane/todos/contract.py @@ -1012,6 +1012,11 @@ def _metadata_value_is_present(value: Any) -> bool: "no_followup" ), ), + _TodoMetadataField( + "completion_requested_continuation", + normalize_todo_completion_continuation, + invalid_message="completion_requested_continuation must be active_goal, successor or no_followup", + ), _TodoMetadataField( "completion_recovery", normalize_todo_completion_recovery, @@ -1209,6 +1214,7 @@ def format_todo_metadata_line( unblocks_todo_id: str | None = None, successor_todo_ids: Any = None, completion_continuation: str | None = None, + completion_requested_continuation: str | None = None, completion_recovery: str | None = None, completion_receipt_id: str | None = None, replan_obligation_id: str | None = None, @@ -1276,7 +1282,7 @@ def todo_block_metadata(block: dict[str, Any]) -> dict[str, Any]: normalized = normalize_todo_decision_scope_outcomes(value) elif key == "no_followup": normalized = normalize_todo_no_followup(value) - elif key == "completion_continuation": + elif key in {"completion_continuation", "completion_requested_continuation"}: normalized = normalize_todo_completion_continuation(value) elif key == "completion_recovery": normalized = normalize_todo_completion_recovery(value) @@ -1378,8 +1384,10 @@ def metadata_line_for_todo_block( metadata[key] = normalized else: metadata.pop(key, None) - elif key in {"completion_continuation", "completion_recovery"}: - normalized_completion = require_todo_completion_metadata(key, value) + elif key in {"completion_continuation", "completion_requested_continuation", "completion_recovery"}: + normalized_completion = require_todo_completion_metadata( + "completion_continuation" if key == "completion_requested_continuation" else key, value + ) if normalized_completion: metadata[key] = normalized_completion else: diff --git a/loopx/control_plane/todos/field_update.ts b/loopx/control_plane/todos/field_update.ts index d4f4c3edc6..625f78d03f 100644 --- a/loopx/control_plane/todos/field_update.ts +++ b/loopx/control_plane/todos/field_update.ts @@ -9,6 +9,7 @@ import { normalizeTodoAgent, stripPythonWhitespace } from "../coordination/todo_ import { AuthorityStoreProtocolError } from "../coordination/authority_store_codec.ts"; import { buildTodoCompletionMetadataUpdates, + requireTodoCompletionMetadata, TODO_COMPLETION_STATE_REQUEST_SCHEMA, } from "./completion_state.ts"; import { validateLegacyContinuationPolicyRepair } from "./legacy_continuation_policy_migration.ts"; @@ -124,9 +125,12 @@ function completionUpdates(block: JsonObject, intent: JsonObject, targetStatus: if (present(intent.completion_metadata_updates_override)) { const updates = requireJsonObject(intent.completion_metadata_updates_override, "completion metadata override"); if (Object.entries(updates).some(([key, value]) => - !["completion_continuation", "completion_recovery", "completion_receipt_id"].includes(key) || typeof value !== "string")) { + !["completion_continuation", "completion_requested_continuation", "completion_recovery", "completion_receipt_id"].includes(key) || typeof value !== "string")) { throw new EffectRuntimeRequestError("TypeScript Todo completion metadata updates shape mismatch"); } + if (updates.completion_requested_continuation !== undefined) { + requireTodoCompletionMetadata("completion_continuation", updates.completion_requested_continuation); + } if (updates.completion_receipt_id !== undefined && !/^tcw_[0-9a-f]{64}$/u.test(String(updates.completion_receipt_id))) { throw new EffectRuntimeRequestError("completion_receipt_id is invalid"); @@ -237,6 +241,7 @@ export function planTodoFieldUpdate(value: unknown): TodoFieldUpdatePlan { // state. Historical operation receipts retain the original completion. updates.no_followup = null; updates.completion_continuation = null; + updates.completion_requested_continuation = null; updates.completion_recovery = null; updates.completion_turn_key = null; } diff --git a/loopx/control_plane/todos/mutation_api.py b/loopx/control_plane/todos/mutation_api.py index af75eccbd2..afa749bd43 100644 --- a/loopx/control_plane/todos/mutation_api.py +++ b/loopx/control_plane/todos/mutation_api.py @@ -448,6 +448,8 @@ def complete_goal_todo( completion_result_file: Path | None = None, completion_turn_key: str | None = None, completion_identity_source: str | None = None, + delivery_read_context_id: str | None = None, + delivery_settlement_identity: Mapping[str, Any] | None = None, terminal_review_basis: Mapping[str, Any] | None = None, expected_todo_text_sha256: str | None = None, require_captured_todo_text: bool = False, @@ -458,6 +460,7 @@ def complete_goal_todo( claim_operation_id: str | None = None, note: str | None = None, no_followup: bool = False, + defer_no_followup: bool = False, successor_todo_ids: list[str] | None = None, claimed_by: str | None = None, clear_claim: bool = False, diff --git a/loopx/control_plane/todos/mutation_response.py b/loopx/control_plane/todos/mutation_response.py index 2875c62cc1..77faa6f236 100644 --- a/loopx/control_plane/todos/mutation_response.py +++ b/loopx/control_plane/todos/mutation_response.py @@ -157,6 +157,9 @@ def serialize_todo_update_result( "completion_continuation": normalize_todo_completion_continuation( effective_metadata.get("completion_continuation") ), + "completion_requested_continuation": normalize_todo_completion_continuation( + effective_metadata.get("completion_requested_continuation") + ), "completion_recovery": normalize_todo_completion_recovery( effective_metadata.get("completion_recovery") ), diff --git a/loopx/control_plane/todos/projection_document.py b/loopx/control_plane/todos/projection_document.py index d8c7e17efb..40c09ebc4e 100644 --- a/loopx/control_plane/todos/projection_document.py +++ b/loopx/control_plane/todos/projection_document.py @@ -6,10 +6,11 @@ from .machine_region import TodoRegion, find_todo_regions, visible_markdown_lines -def recovered_todo_projection_skeleton(goal_id: str) -> str: - """The display recovery codec; it does not restore missing Goal intent.""" +def _recovered_todo_projection_skeleton(goal_id: str, *, marked: bool) -> str: return ( - f"---\ngoal_id: {json.dumps(goal_id, ensure_ascii=False)}\n---\n\n" + f"---\ngoal_id: {json.dumps(goal_id, ensure_ascii=False)}\n" + + ('goal_source_completeness: "todo_projection_only"\n' if marked else '') + + "---\n\n" "# Recovered Todo projection\n\n" "> Regenerated from canonical Todo authority. Non-Todo sections " "are not in this provider snapshot and were not recovered. " @@ -18,6 +19,22 @@ def recovered_todo_projection_skeleton(goal_id: str) -> str: ) +def recovered_todo_projection_skeleton(goal_id: str) -> str: + """The display recovery codec; it does not restore missing Goal intent.""" + return _recovered_todo_projection_skeleton(goal_id, marked=True) + + +def is_legacy_todo_projection_skeleton(markdown: str, goal_id: str) -> bool: + """Recognize only the exact pre-marker codec, ignoring replaceable Todos. + + This compatibility decoder never classifies arbitrary Goal prose by words + or headings. Additional owner narrative no longer matches this old codec. + """ + expected = _recovered_todo_projection_skeleton(goal_id, marked=False) + return TodoProjectionDocument.parse(markdown).narrative.strip() == \ + TodoProjectionDocument.parse(expected).narrative.strip() + + @dataclass(frozen=True) class TodoProjectionDocument: markdown: str diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 1b8c7ff7a9..9892ae358f 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -128,6 +128,8 @@ def route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any] review_basis=call.get("terminal_review_basis"), expected_todo_text_sha256=call.get("expected_todo_text_sha256"), require_captured_todo_text=call.get("require_captured_todo_text") is True, + delivery_read_context_id=call.get("delivery_read_context_id"), + delivery_settlement_identity=call.get("delivery_settlement_identity"), completion_delivery_workspace=( call.get("completion_delivery_workspace") if complete else None ), @@ -142,6 +144,7 @@ def route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any] optional=True, ), no_followup=bool(call.get("no_followup")) if complete else False, + defer_no_followup=bool(call.get("defer_no_followup")) if complete else False, successor_todo_ids=require_completion_successor_todo_ids(call.get("successor_todo_ids")), claimed_by=call.get("claimed_by") if complete else None, clear_claim=bool(call.get("clear_claim")) if complete else False, @@ -282,6 +285,9 @@ def terminal_canonical_todo_if_promoted( review_basis: Mapping[str, Any] | None = None, expected_todo_text_sha256: str | None = None, require_captured_todo_text: bool = False, + delivery_read_context_id: str | None = None, + delivery_settlement_identity: Mapping[str, Any] | None = None, + defer_no_followup: bool = False, project: Path | None = None, state_file: Path | None = None, ) -> dict[str, Any] | None: @@ -397,6 +403,7 @@ def terminal_canonical_todo_if_promoted( "lease_expected_version": task_lease_expected_version, "allow_user_gate_auto_acquire": command == "complete", "requested_no_followup": no_followup, + **({"defer_no_followup": True} if defer_no_followup else {}), "requested_completion_turn_key": completion_turn_key, "requested_completion_identity_source": completion_identity_source, "linked_successor_todo_ids": successor_todo_ids, @@ -413,10 +420,40 @@ def terminal_canonical_todo_if_promoted( "dry_run": dry_run, "observed_at": now_local(), } - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + def invoke() -> Any: + nonlocal delivery_read_context_id + if delivery_read_context_id is not None and delivery_settlement_identity is None: + raise ValueError("delivery read context requires the original settlement identity") + if delivery_settlement_identity is not None: + from ..goals.checkpoint_context_io import delivery_result_context_input + from ..quota.settlement import SettlementIdentity + + if state_file is None: + raise ValueError("delivery freshness requires the registered state file") + context = delivery_result_context_input( + runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, + identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), + read_context_id=delivery_read_context_id, + ) + if context is not None: + request["delivery_context"] = context + # Freeze the actual work key before native validation. Recapture + # source facts on retries, never a newly issued decision key. + if delivery_read_context_id is None: + delivery_read_context_id = context.get("read_context_id") + for capture_attempt in range(3): + outcome = effect_runtime_result("coordination.local_authority.todo_terminal", request, + timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS) + if not isinstance(outcome, Mapping) or outcome.get("reason_code") != "delivery_source_capture_changed" or capture_attempt == 2: + return outcome + # Recapture IO, retaining the original Agent decision and read ID. + request["delivery_context"] = delivery_result_context_input( + runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, + identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), + read_context_id=delivery_read_context_id, + ) + + result = invoke() if isinstance(result, Mapping) and result.get("status") == "resolve_validation": # Admission and receipt recovery precede host-local declaration IO. # Resolving private argv grants no authority to run it; re-enter the @@ -429,29 +466,46 @@ def terminal_canonical_todo_if_promoted( registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, role=role, persist_if_resolved=not dry_run, ) - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + result = invoke() completion_validation_executed = False if isinstance(result, Mapping) and result.get("status") == "execute_validation": request["validation_source_provider_revision"] = result["provider_revision"] - request.update(execute_completion_validation_effects( - result, registry_path=registry_path, goal_id=goal_id, - delivery_workspace=completion_delivery_workspace, - validation_workspace_path=completion_validation_workspace_path, - )) + context = request.get("delivery_context") + recovery = None + if isinstance(context, Mapping) and context.get("read_context_id"): + from ..goals.checkpoint_context_io import terminal_validation_effect_io + + recovery = terminal_validation_effect_io( + runtime_root=runtime_root, request=request, context=context, execution_result=result, + ) + if recovery is not None and recovery["effect"] == "replay": + updates = recovery["updates"] + else: + updates = execute_completion_validation_effects( + result, registry_path=registry_path, goal_id=goal_id, + delivery_workspace=completion_delivery_workspace, + validation_workspace_path=completion_validation_workspace_path, + ) + if recovery is not None: + terminal_validation_effect_io( + runtime_root=runtime_root, request=request, context=context, + execution_result=result, updates=updates, + ) + request.update(updates) + if isinstance(context, Mapping) and context.get("read_context_id"): + from ..goals.checkpoint_context_io import bind_terminal_result_validation + + request["result_validation_binding"] = bind_terminal_result_validation( + runtime_root=runtime_root, request=request, context=context, + ) if completion_result_file is not None and not dry_run: _persist_validated_completion_result( request=request, source=completion_result_file, runtime_root=runtime_root, goal_id=goal_id, expected_descriptor=result_descriptor, ) - completion_validation_executed = True + completion_validation_executed = recovery is None or recovery["effect"] == "run" request["observed_at"] = now_local() - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + result = invoke() if not isinstance(result, Mapping): raise LocalCoordinationAuthorityUnavailable( "canonical Todo terminal transaction returned an invalid result", @@ -470,6 +524,11 @@ def terminal_canonical_todo_if_promoted( payload.get("status") == "failed" and payload.get("failure_kind") == "decision_rejection" ): + basis = payload.get("delivery_read_context") + if isinstance(basis, Mapping) and basis.get("error_code") == "checkpoint_read_context_stale": + from ..goals.checkpoint_context_io import CheckpointReadContextRejected + + raise CheckpointReadContextRejected(dict(basis)) raise LocalCoordinationAuthorityRejection( str(payload.get("reason") or "canonical Todo terminal request was rejected"), code=str(payload.get("reason_code") or "todo_terminal_rejected"), diff --git a/loopx/control_plane/todos/read_legacy_codec.json b/loopx/control_plane/todos/read_legacy_codec.json index 3bea730d27..f69d31ca80 100644 --- a/loopx/control_plane/todos/read_legacy_codec.json +++ b/loopx/control_plane/todos/read_legacy_codec.json @@ -100,7 +100,8 @@ "resume_when", "no_followup", "successor_todo_ids", - "completion_continuation" + "completion_continuation", + "completion_requested_continuation" ], "list_constants": { "_ITEM_FIELDS": [ @@ -132,6 +133,7 @@ "unblocks_todo_id", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "resume_when", "resume_monitor_generation", @@ -214,6 +216,7 @@ "unblocks_todo_id", "successor_todo_ids", "completion_continuation", + "completion_requested_continuation", "completion_recovery", "superseded_by", "resume_when", diff --git a/loopx/control_plane/turn_driver/checkpoint_continuation.py b/loopx/control_plane/turn_driver/checkpoint_continuation.py new file mode 100644 index 0000000000..b984489258 --- /dev/null +++ b/loopx/control_plane/turn_driver/checkpoint_continuation.py @@ -0,0 +1,115 @@ +"""Current-Agent IO for the existing Goal checkpoint and Turn receipt owners.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from ...file_lock import exclusive_file_lock, exclusive_cross_runtime_file_lock +from ...registry import atomic_write_json +from ..goals.checkpoint_context_io import ( + _checkpoint_effect, _receipt_path, _read_context_receipt, _resolve, + _local_source_facts, _source_guard, _inspect_attempt, CheckpointReadContextRejected, + read_checkpoint_context, checkpoint_continuation_required, +) +from ..quota.settlement import SettlementIdentity, read_heartbeat_settlement +from .host_failure import BuiltInHostError, build_host_failure_record + + +class CurrentAgentCheckpoint: + def __init__(self, registry: Path, root: Path, state: Path, identity: SettlementIdentity, + runner: Callable[[dict[str, Any]], dict[str, Any]]): + self.registry, self.root, self.state, self.identity, self.runner = registry, root, state, identity, runner + self._enabled: bool | None = None + + def enabled(self) -> bool: + if self._enabled is not None: + return self._enabled + readback = read_heartbeat_settlement(self.root, goal_id=self.identity.goal_id, + agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + turn_instance_id=self.identity.turn_instance_id, + replan_obligation_id=self.identity.replan_obligation_id, registry_path=self.registry) + self._enabled = readback is not None and checkpoint_continuation_required(readback) + return self._enabled + + def _save(self, path: Path, receipt: dict[str, Any]) -> None: + with exclusive_cross_runtime_file_lock(self.root / 'goals' / self.identity.goal_id / 'runs/index.jsonl', + operation='checkpoint-direction-binding'): + with _source_guard(self.root, self.identity.goal_id, self.state, self.registry): + current = _read_context_receipt(path) + if current.get('read_context_id') != receipt['read_context_id']: + raise ValueError('Direction input was replaced while the current Agent was judging') + atomic_write_json(path, receipt) + + def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first_delivery') -> dict[str, Any]: + if not self.enabled(): + return dict(result) + path = _receipt_path(self.root, self.identity) + with exclusive_file_lock(path): + receipt = _read_context_receipt(path) if path.exists() else None + if receipt is not None: + with exclusive_cross_runtime_file_lock(self.root / 'goals' / self.identity.goal_id / 'runs/index.jsonl', + operation='checkpoint-direction-readback'): + with _source_guard(self.root, self.identity.goal_id, self.state, self.registry): + attempt = _inspect_attempt(self.root, self.identity) + if attempt.get('status') != 'committed': + if attempt.get('status') == 'absent': + # Native readback cleared a proved-absent append. + # Consume its persisted receipt, not the old marker. + receipt = _read_context_receipt(path) + try: + _resolve(self.root, phase='check', identity=self.identity.as_dict(), + purpose=purpose, decision_scope='goal', checkpoint_continuation=True, + read_context_id=receipt['read_context_id'], receipt=receipt, + facts=_local_source_facts(self.root, self.registry, self.state, self.identity, + first_delivery=purpose == 'first_delivery')) + except CheckpointReadContextRejected as error: + if error.code not in {'checkpoint_read_context_stale', 'checkpoint_read_context_unknown_or_replaced'}: + raise + receipt = None + # Keep the actual input/decision binding on retry. The commit owner + # still resolves uncertainty and rechecks current sources. + if receipt is None or receipt.get('purpose', 'supplement_checkpoint') != purpose: + context = read_checkpoint_context(registry_path=self.registry, runtime_root_override=str(self.root), + goal_id=self.identity.goal_id, agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, + replan_obligation_id=self.identity.replan_obligation_id, turn_instance_id=self.identity.turn_instance_id, + purpose=purpose, decision_scope='goal') + receipt = _read_context_receipt(path) + else: + context = {'read_context_id': receipt['read_context_id'], 'basis': receipt['decision_basis'], + 'settlement_identity': self.identity.as_dict(), 'purpose': purpose, + 'vision_authoring': receipt['vision_authoring'], 'original_outcome': receipt.get('original_outcome')} + decision = receipt.get('current_agent_decision') + if decision is None: + dispatch = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { + 'phase': 'direction_dispatch', 'receipt': receipt}) + if dispatch.get('ok') is not True: + raise CheckpointReadContextRejected(dispatch) + receipt['direction_host_attempt_count'] = dispatch['attempt'] + self._save(path, receipt) + try: + decision = self.runner(context) + except BuiltInHostError as error: + receipt['direction_host_failure'] = build_host_failure_record(error.failure_kind, + attempt=dispatch['attempt']) + self._save(path, receipt) + raise ValueError(error.reason) from error + if decision.get('result_kind') in {'wait', 'user_action_required', 'iteration_failed'}: + receipt['direction_stopped'] = True + self._save(path, receipt) + raise ValueError('Current Agent stopped direction continuation; result retained') + bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { + 'phase': 'direction_response', 'identity': self.identity.as_dict(), 'receipt': receipt, 'decision': decision}) + if bound.get('ok') is not True: + raise ValueError(bound.get('error', 'Direction response binding rejected')) + receipt = {**receipt, 'current_agent_decision': decision, 'current_agent_binding': bound['binding']} + receipt.pop('direction_host_failure', None) + self._save(path, receipt) + else: + bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { + 'phase': 'direction_response', 'identity': self.identity.as_dict(), 'receipt': receipt, 'decision': decision}) + if bound.get('binding') != receipt.get('current_agent_binding'): + raise ValueError('Original direction response binding is unavailable') + return {**result, 'agent_vision': decision.get('agent_vision'), + 'vision_unchanged_reason': decision.get('vision_unchanged_reason'), + 'checkpoint_read_context_id': context['read_context_id']} diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index b36c8d2437..9807f0c566 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -41,6 +41,7 @@ from ..capability_hooks import capability_result_fields from .execution_profile import require_supported_reasoning_effort from .host_failure import BuiltInHostError +from .host_candidate import render_checkpoint_prompt from .host_process_transport import HostOutputLines, run_host_process from .transaction import LOOPX_TURN_RESULT_SCHEMA_VERSION, TRANSACTION_PHASES @@ -195,6 +196,13 @@ def _has_subagent_topology(request: Mapping[str, Any] | None) -> bool: def codex_cli_result_schema( request: Mapping[str, Any] | None = None, ) -> dict[str, Any]: + if request and request.get("phase") == "checkpoint_direction": + return {"type": "object", "additionalProperties": False, "properties": { + "read_context_id": {"type": "string"}, + "agent_vision_json": {"type": "string"}, + "vision_unchanged_reason": {"type": "string", "maxLength": 240}, + "stop_reason": {"type": "string", "maxLength": 240}, + }, "required": ["read_context_id", "agent_vision_json", "vision_unchanged_reason", "stop_reason"]} text_limits = dict(HOST_RESULT_TEXT_LIMITS) properties: dict[str, Any] = { "schema_version": { @@ -324,6 +332,8 @@ def _prompt(request: Mapping[str, Any]) -> str: request_json = json.dumps( model_request, ensure_ascii=False, sort_keys=True, separators=(",", ":") ) + if request.get("phase") == "checkpoint_direction": + return render_checkpoint_prompt(request) instructions = [ "Execute exactly one bounded LoopX Turn in the current workspace.", ("Use turn_authority as the verified authority for this Turn. " if staged else "Use the TurnEnvelope as the source of truth. ") @@ -340,6 +350,10 @@ def _prompt(request: Mapping[str, Any]) -> str: if staged: instructions.append(replan_prompt_instruction(authority)) instructions.extend(request.get("capability_instructions") or ()) + if request.get("delivery_result_context"): + instructions.append("Use delivery_result_context.basis as the requirements and dependencies read for this candidate. The adapter carries its original key with validation and submission.") + if request.get("result_revalidation"): + instructions.append("This is revalidation of a retained candidate on the original Turn. Compare it with the new basis, preserve valid artifacts, and implement only changes demanded by the new requirements before returning a fresh result.") boundary = _mapping(_mapping(request.get("turn_envelope")).get("boundary")) if boundary.get("checkpointed_boundary_authority"): instructions.append( diff --git a/loopx/control_plane/turn_driver/codex_operation_host.py b/loopx/control_plane/turn_driver/codex_operation_host.py index 1869ee1635..cd551c1687 100644 --- a/loopx/control_plane/turn_driver/codex_operation_host.py +++ b/loopx/control_plane/turn_driver/codex_operation_host.py @@ -133,7 +133,7 @@ def run_codex_operation_host( model=model, reasoning_effort=reasoning_effort, resume_thread_id=binding["session_id"] if binding else None, - dynamic_tools=[operations.OPERATION_TOOL], + dynamic_tools=[] if request.get("phase") == "checkpoint_direction" else [operations.OPERATION_TOOL], host_config=host_config, launch_guard=lambda: launch_scope("codex_operation_process_launch"), response_timeout_sec=30 if timeout_seconds is None else min(timeout_seconds, 30), @@ -142,7 +142,7 @@ def run_codex_operation_host( ) selection.store(session.thread_id) - session.bound_tool_handler = operations.operation_tool_handler( + session.bound_tool_handler = None if request.get("phase") == "checkpoint_direction" else operations.operation_tool_handler( runtime_root=runtime_root, registry_path=registry_path, lineage=lineage, @@ -163,7 +163,7 @@ def run_codex_operation_host( operations.operation_continuations( runtime_root=runtime_root, registry_path=registry_path, lineage=lineage, route=route, goal_ref=request.get("goal_ref"), - ) if (runtime_root / "chat" / "actions" / "actions.json").is_file() else [] + ) if request.get("phase") != "checkpoint_direction" and (runtime_root / "chat" / "actions" / "actions.json").is_file() else [] ) def on_event(kind: str, event: dict[str, Any]) -> None: @@ -183,7 +183,8 @@ def on_event(kind: str, event: dict[str, Any]) -> None: recovery_kind="resume_session", ) from exc - child_observer = native_child_observer(request, runtime_root=runtime_root, lineage=lineage, + child_observer = None if request.get("phase") == "checkpoint_direction" else native_child_observer( + request, runtime_root=runtime_root, lineage=lineage, registry_path=goal_admission.registry_path if goal_admission is not None else None) def observe_child(item: Mapping[str, Any]) -> None: @@ -198,9 +199,11 @@ def record_child() -> None: else: goal_admission.accept_result(record_child) - result = session.send( - _prompt(request) - + "\nUse loopx_operation for context/pending/prepare/inspect/consume/report. " + operation_prompt = ( + "" + if request.get("phase") == "checkpoint_direction" + else ( + "\nUse loopx_operation for context/pending/prepare/inspect/consume/report. " "Source conversations are not executor identity. context/pending/inspect do not require consumption. " "When the admitted task authorizes proposal preparation and supplies its terms, prepare may run before human confirmation or consume; " "prepare writes a canonical proposal, not a domain/external effect. execution_allowed=false is expected for prepare, not a reason to refuse it. " @@ -212,7 +215,11 @@ def record_child() -> None: + ("\nCanonical confirmed operation continuations for this exact binding: " + json.dumps(continuations, sort_keys=True) + ". Inspect these locators through loopx_operation and consume once before any effect." - if continuations else ""), + if continuations else "") + ) + ) + result = session.send( + _prompt(request) + operation_prompt, output_schema=codex_cli_result_schema(request), on_event=on_event, turn_start_guard=lambda: launch_scope("codex_operation_turn_start"), diff --git a/loopx/control_plane/turn_driver/codex_sessions.py b/loopx/control_plane/turn_driver/codex_sessions.py index 741a2df4a2..0bcd54594b 100644 --- a/loopx/control_plane/turn_driver/codex_sessions.py +++ b/loopx/control_plane/turn_driver/codex_sessions.py @@ -13,6 +13,7 @@ from ...boundary_authority import normalize_checkpointed_boundary_authority_entries from ...file_lock import exclusive_file_lock +from ..runtime.private_permissions import protect_private_path from ...runtime import validate_goal_id_path_segment from ..content_digest import BARE_SHA256_PATTERN from ..projects.registry_codec import ( @@ -172,6 +173,7 @@ def codex_cli_session_binding( goal_admission: FirstPartyHostGoalAdmission | None = None, session_scope: str = "todo", host: str = "codex-cli", + exact_session: bool = False, ) -> dict[str, str] | None: lineage = _lineage({"turn_envelope": dict(turn_envelope)}, session_scope=session_scope) session = select_codex_cli_session( @@ -186,6 +188,9 @@ def codex_cli_session_binding( return { "schema_version": "loopx_turn_session_binding_v0", **lineage, + **({"session_sha256": hashlib.sha256(json.dumps({key: session.get(key) for key in ( + "session_id", "session_profile_digest", "operation_profile_digest", "operation_transport")}, + sort_keys=True).encode()).hexdigest()} if exact_session else {}), } @@ -409,13 +414,13 @@ def _write_codex_cli_session( raise ValueError("Codex CLI returned an invalid session id") path = _session_path(runtime_root, lineage, session_scope) path.parent.mkdir(parents=True, exist_ok=True) + protect_private_path(path.parent, directory=True) descriptor, temporary_name = tempfile.mkstemp( prefix=f".{path.name}.", dir=path.parent ) temporary = Path(temporary_name) try: - if hasattr(os, "fchmod"): - os.fchmod(descriptor, 0o600) + protect_private_path(temporary) handle = os.fdopen(descriptor, "w", encoding="utf-8") descriptor = -1 with handle: @@ -459,7 +464,6 @@ def _write_codex_cli_session( handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) - path.chmod(0o600) finally: if descriptor >= 0: os.close(descriptor) diff --git a/loopx/control_plane/turn_driver/command_validation.py b/loopx/control_plane/turn_driver/command_validation.py index 1a765025c4..87a16a5087 100644 --- a/loopx/control_plane/turn_driver/command_validation.py +++ b/loopx/control_plane/turn_driver/command_validation.py @@ -121,7 +121,9 @@ def validate( completed = subprocess.run( normalized, cwd=project, - input=json.dumps(result, ensure_ascii=False, separators=(",", ":")), + input=json.dumps({**result, **({"delivery_result_context": _plan["delivery_result_context"]} + if _plan.get("delivery_result_context") is not None else {})}, + ensure_ascii=False, separators=(",", ":")), text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index f25d8ae889..bda9293611 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -24,6 +24,7 @@ FirstPartyHostRuntimeRejected, ) from ..goals.goal_vision import normalize_goal_vision_packet +from ..goals.checkpoint_context_io import CheckpointReadContextRejected, TurnResultWork from ..work_items.delivery_batch_scale import require_delivery_batch_scale from ..work_items.delivery_outcome import ( explain_turn_scoped_settlement_gap, @@ -808,6 +809,27 @@ def _host_failure( } +def _retain_result_for_revalidation(plan: Mapping[str, Any], journal: dict[str, Any], + result: Mapping[str, Any], error: CheckpointReadContextRejected) -> None: + terminal = len(journal.get("completed_phases") or []) == 5 and journal.get("quota_spend") is not None + phases = list(journal["completed_phases"] if terminal else TRANSACTION_PHASES[:2]) + kind = LoopXTurnResultKind.TERMINAL_CLOSEOUT_FAILED if terminal else LoopXTurnResultKind.VALIDATION_FAILED + failure = _host_failure(plan, kind=kind, + completed_phases=phases, failed_phase="terminal_closeout" if terminal else "validation", reason=str(error)) + prior_retry = (journal.get("result_revalidation") or {}) if terminal else {} + journal.update(status="failed", receipt=failure["receipt"], reason=str(error), + result_kind=kind.value, + validation_stage="result_freshness", completed_phases=phases, + result_revalidation={"candidate": prior_retry.get("candidate", journal.get("host_result", result)), + "original_context": prior_retry.get("original_context", journal.get("result_work_context")), + "rejection": error.payload["checkpoint_read_context"], + **({"phase": "terminal_closeout", "validation": {}, "reread_required": True} if terminal else {}), + "changed_components": error.payload["checkpoint_read_context"].get("changed_components", []), + "instruction": "Recheck this retained candidate against the new work basis. Reuse valid artifacts; change implementation only where the new requirements demand it."}) + journal.pop("task_validation", None) + journal.pop("result_work_binding", None) + + def _run_host( request: Mapping[str, Any], *, @@ -845,6 +867,7 @@ def count_stderr(text: str) -> None: "ok": False, "reason": "host process " + observed["outcome"], "returncode": observed["returncode"], + "failure_kind": "executor_timeout" if observed["outcome"] == "timeout" else "transport_lost", } if not observed["output_complete"]: return { @@ -942,6 +965,7 @@ def _host_result_stage( confirm_start: Callable[[], None] | None = None, usage_runtime_root: Path | None = None, usage_goal_id: str = "", + session_binding_resolver: SessionBindingResolver | None = None, ) -> 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 @@ -950,6 +974,7 @@ def _host_result_stage( if isinstance(journal.get("host_result"), dict) else None ) + dispatched_result = "typed_result" not in completed_phases if "typed_result" not in completed_phases: journal["host_attempt_count"] = int(journal.get("host_attempt_count") or 0) + 1 persist_journal(journal) @@ -1057,6 +1082,13 @@ def _host_result_stage( ) normalized = dict(validation["result"]) + if dispatched_result and session_binding_resolver is not None and normalized.get("result_kind") in { + LoopXTurnResultKind.VALIDATED_COMPLETION.value, + LoopXTurnResultKind.VALIDATED_PROGRESS.value, + LoopXTurnResultKind.REPAIR_REQUIRED.value, + LoopXTurnResultKind.REPLAN_REQUIRED.value, + } and "checkpoint_host_binding" not in journal: + journal["checkpoint_host_binding"] = session_binding_resolver(request["turn_envelope"]) if len(completed_phases) < 2: completed_phases = list(TRANSACTION_PHASES[:2]) journal.update( @@ -1158,6 +1190,74 @@ def _task_validation_stage( return completed_phases, None +def _terminal_result_validation_failure(plan: Mapping[str, Any], journal: dict[str, Any], + reason: str, *, persist_journal: JournalPersist, effects: dict[str, bool]) -> dict[str, Any]: + failure = _host_failure(plan, kind=LoopXTurnResultKind.TERMINAL_CLOSEOUT_FAILED, + completed_phases=list(journal["completed_phases"]), failed_phase="terminal_closeout", reason=reason) + journal.update(status="failed", receipt=failure["receipt"], reason=reason, + result_kind=LoopXTurnResultKind.TERMINAL_CLOSEOUT_FAILED.value) + persist_journal(journal) + return execution_payload(plan, journal, execute=True, replayed=False, effects=effects) + + +def _terminal_result_validation_stage( + plan: Mapping[str, Any], request: Mapping[str, Any], *, journal: dict[str, Any], + persist_journal: JournalPersist, effects: dict[str, bool], host_runner: HostRunner | None, + argv: Sequence[str] | None, project: Path, timeout_seconds: float | None, + task_validator: TaskValidator | None, + confirm_start: Callable[[], None] | None, runtime_root: Path, goal_id: str, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Revalidate an uncommitted terminal result while retaining settled phases.""" + retry = journal["result_revalidation"] + view = {"schema_version": journal["schema_version"], "goal_id": journal["goal_id"], + "turn_key": journal["turn_key"], "plan": journal["plan"], "status": "in_progress", + "completed_phases": [], "host_attempt_count": journal.get("host_attempt_count", 0), + **retry.get("validation", {})} + if view.get("status") == "failed": + if (view.get("receipt") or {}).get("failed_phase") == "validation": + view["completed_phases"] = [] + view.pop("host_result", None) + view.pop("task_validation", None) + for key in ("receipt", "reason", "host_recovery", "host_failure"): + view.pop(key, None) + view["status"] = "in_progress" + if view.get("work_context") != journal["result_work_context"]: + # A cold view must keep the facts actually supplied to its Host and + # validator. A replacement read requires both stages again. + view = {"schema_version": journal["schema_version"], "goal_id": journal["goal_id"], + "turn_key": journal["turn_key"], "plan": journal["plan"], "status": "in_progress", + "completed_phases": [], "host_attempt_count": journal.get("host_attempt_count", 0), + "work_context": journal["result_work_context"]} + + def persist_view(snapshot): + retry["validation"] = {key: value for key, value in snapshot.items() + if key not in {"schema_version", "goal_id", "turn_key", "plan"}} + journal["host_attempt_count"] = snapshot.get("host_attempt_count", journal.get("host_attempt_count", 0)) + persist_journal(journal) + + failed = partial(_terminal_result_validation_failure, plan, journal, + persist_journal=persist_journal, effects=effects) + + result, phases, terminal = _host_result_stage(plan, request, host_runner=host_runner, + argv=argv, completion_lifecycle_configured=True, project=project, + timeout_seconds=timeout_seconds, journal=view, persist_journal=persist_view, + effects=effects, confirm_start=confirm_start, usage_runtime_root=runtime_root, usage_goal_id=goal_id) + if terminal is not None: + return None, failed(str(view.get("reason") or "Terminal result revalidation failed")) + assert result is not None + if result.get("result_kind") != LoopXTurnResultKind.VALIDATED_COMPLETION.value: + return None, failed("Terminal result revalidation requires a validated completion; preserve the settled receipts.") + _, terminal = _task_validation_stage({**plan, "delivery_result_context": journal["result_work_context"]}, + result, task_validator=task_validator, completed_phases=phases, journal=view, + persist_journal=persist_view, effects=effects) + if terminal is not None: + return None, failed(str(view.get("reason") or "Terminal candidate validation failed")) + journal["host_result"] = result + journal["task_validation"] = view["task_validation"] + persist_journal(journal) + return result, None + + def _ensure_turn_settlement_plan( plan: Mapping[str, Any], transaction_plan: dict[str, Any], @@ -1215,6 +1315,7 @@ def _typed_settlement_stage( post_settlement: PostSettlement | None, source_effects: TurnSettlementEffectAdmission | None, persist_source_journal: SourceJournalPersist | None, + post_result_checkpoint: Callable[[], bool] | None, ) -> dict[str, Any]: transaction_plan = ( plan.get("transaction") if isinstance(plan.get("transaction"), Mapping) else {} @@ -1240,6 +1341,21 @@ def _typed_settlement_stage( completion_intent=completion_intent, ) ) + persist_journal(journal) + + freshness_error: CheckpointReadContextRejected | None = None + + def completion_effect(callback: CompletionWriteback, effect_ref: str) -> Mapping[str, Any]: + nonlocal freshness_error + try: + return invoke_result_effect(callback, result, effect_ref) + except CheckpointReadContextRejected as error: + if error.code not in {"checkpoint_read_context_stale", "delivery_validation_binding_mismatch"}: + raise + # The Todo owner rejected before commit. Let the typed settlement + # owner abort its prepared effect before reopening validation. + freshness_error = error + return {"ok": False, "appended": False, "reason": str(error)} def writeback_effect(effect_ref: str) -> Mapping[str, Any]: if completion_intent_error: @@ -1250,13 +1366,14 @@ def writeback_effect(effect_ref: str) -> Mapping[str, Any]: } if ( result.get("result_kind") == LoopXTurnResultKind.VALIDATED_COMPLETION.value - and not terminal_closeout_required + and (not terminal_closeout_required or + (post_result_checkpoint is not None and post_result_checkpoint())) ): if completion_writeback is None: raise ValueError( "validated_completion requires a todo lifecycle adapter" ) - return invoke_result_effect(completion_writeback, result, effect_ref) + return completion_effect(completion_writeback, effect_ref) return invoke_result_effect(writeback, result, effect_ref) def spend_effect(effect_ref: str) -> Mapping[str, Any]: @@ -1293,7 +1410,7 @@ def spend_effect(effect_ref: str) -> Mapping[str, Any]: terminal_checkpoint = None if terminal_closeout_required: assert terminal_closeout is not None - terminal_effect = partial(invoke_result_effect, terminal_closeout, result) + terminal_effect = partial(completion_effect, terminal_closeout) terminal_checkpoint = journal_adapter.checkpoint_terminal settlement_result = execute_turn_driver_settlement( @@ -1333,6 +1450,10 @@ def spend_effect(effect_ref: str) -> Mapping[str, Any]: journal["settlement_result"] = settlement_result_payload(settlement_result) if settlement_result.failure is not None: + if freshness_error is not None: + _retain_result_for_revalidation(plan, journal, result, freshness_error) + persist_journal(journal) + return execution_payload(plan, journal, execute=True, replayed=False, effects=effects) result_kind, completed_phases, failed_phase = turn_settlement_failure_outcome( settlement_result ) @@ -1463,6 +1584,8 @@ def run_loopx_turn_once( admit_start: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, confirm_start: Callable[[], None] | None = None, goal_admission: FirstPartyHostGoalAdmission | None = None, + result_work: TurnResultWork | None = None, + post_result_checkpoint: Callable[[], bool] | None = None, ) -> dict[str, Any]: if host_runner is not None and host_argv is not None: raise ValueError("run-once accepts either host_argv or host_runner, not both") @@ -1610,10 +1733,25 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: and receipt.get("failed_phase") == "validation" and journal.get("validation_stage") != "task_postcondition" ) + terminal_retry = (journal or {}).get("result_revalidation") or {} + terminal_view = terminal_retry.get("validation", {}) + if terminal_retry.get("phase") == "terminal_closeout" and terminal_view.get("status") == "failed": + assessment = assess_existing_turn_recovery({"schema_version": journal["schema_version"], + "goal_id": goal_id, "turn_key": turn_key, "plan": journal["plan"], **terminal_view}, request, + goal_id=goal_id, agent_id=str(plan["turn_envelope"]["agent_id"]), turn_key=turn_key, + retry_failed=True, session_binding_resolver=session_binding_resolver) + request = require_turn_recovery_continuation(assessment) + terminal_work_changed = (result_work is not None and terminal_retry.get("phase") == "terminal_closeout" + and terminal_retry.get("validation", {}).get("work_context") != result_work.read()) needs_host = ( validation_reinvokes_host or journal is None or "typed_result" not in list(journal.get("completed_phases") or []) + or terminal_work_changed + or isinstance(journal.get("result_revalidation"), dict) and + journal["result_revalidation"].get("phase") == "terminal_closeout" and + ("typed_result" not in journal["result_revalidation"].get("validation", {}).get("completed_phases", []) or + journal["result_revalidation"].get("validation", {}).get("status") == "failed") ) if needs_host and goal_admission is not None: goal_admission.require_current() @@ -1695,6 +1833,27 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: journal["admission"] = admission persist_journal(journal) + original_result_committed = (result_work is not None and + isinstance(journal.get("result_work_context"), Mapping) and ( + ("durable_writeback" in journal.get("completed_phases", []) and + post_result_checkpoint is not None and post_result_checkpoint()) or result_work.result_committed())) + if result_work is not None and not original_result_committed: + revalidation = journal.get("result_revalidation") + context = result_work.read() + if revalidation is not None and (context == revalidation.get("original_context") or revalidation.get("reread_required") is True): + context = result_work.read(reread=True) + if context is not None: + previous = journal.get("result_work_context") + if previous is not None and revalidation is None and previous != context: + raise ValueError("Original Turn work input was replaced; preserve its candidate and reconcile") + journal["result_work_context"] = context + request["delivery_result_context"] = context + if revalidation is not None: + request["result_revalidation"] = revalidation + revalidation["reread_required"] = False + # Durable work input precedes dispatch, including cold retries. + persist_journal(journal) + effects = dict(empty_effects) def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: @@ -1710,12 +1869,23 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: payload["recovery"] = dict(journal["recovery_audit"]) return payload + terminal_revalidation = (journal.get("result_revalidation") or {}).get("phase") == "terminal_closeout" + if terminal_revalidation: + _, terminal = _terminal_result_validation_stage(plan, request, journal=journal, + persist_journal=persist_journal, effects=effects, host_runner=host_runner, argv=argv, + project=project, timeout_seconds=timeout_seconds, task_validator=task_validator, + confirm_start=confirm_start if admission is not None and admission.get("reserved") is True else None, + runtime_root=runtime_root, goal_id=goal_id) + if terminal is not None: + return finish_recovery(terminal) + result, completed_phases, terminal = _host_result_stage( plan, request, host_runner=host_runner, usage_runtime_root=runtime_root, usage_goal_id=goal_id, + session_binding_resolver=session_binding_resolver, argv=argv, completion_lifecycle_configured=all( callback is not None @@ -1740,8 +1910,24 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: return finish_recovery(terminal) assert result is not None + context = journal.get("result_work_context") + def freshness_failure(error: CheckpointReadContextRejected) -> dict[str, Any]: + _retain_result_for_revalidation(plan, journal, result, error) + persist_journal(journal) + return finish_recovery(execution_payload(plan, journal, execute=True, + replayed=False, effects=effects)) + + protected_result = result_work is not None and not original_result_committed and isinstance(context, Mapping) and result.get("result_kind") == LoopXTurnResultKind.VALIDATED_COMPLETION.value + if protected_result: + try: + result_work.check(context) + except CheckpointReadContextRejected as error: + if error.code != "checkpoint_read_context_stale": + raise + return freshness_failure(error) + completed_phases, terminal = _task_validation_stage( - plan, + {**plan, "delivery_result_context": context} if context is not None else plan, result, task_validator=task_validator, completed_phases=completed_phases, @@ -1752,6 +1938,26 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: if terminal is not None: return finish_recovery(terminal) + if protected_result: + try: + result_work.check(context) + binding = result_work.bind(context, result, journal["task_validation"], + binding=journal.get("result_work_binding")) + except CheckpointReadContextRejected as error: + if error.code not in {"checkpoint_read_context_stale", "delivery_validation_binding_mismatch"}: + raise + return freshness_failure(error) + journal["result_work_binding"] = binding.get("binding", journal.get("result_work_binding")) + if terminal_revalidation: + journal.pop("result_revalidation") + persist_journal(journal) + + # Replay carries the same work key as the original successful request, + # even when the read receipt is no longer available. The Todo owner, + # not this transport field, decides whether that request is a replay. + if result.get("result_kind") == LoopXTurnResultKind.VALIDATED_COMPLETION.value and journal.get("result_work_binding") is not None: + result = {**result, "result_work_binding": journal["result_work_binding"]} + with settlement_admission() if settlement_admission is not None else nullcontext(): settled = _typed_settlement_stage( plan, @@ -1764,6 +1970,7 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: completion_writeback=completion_writeback, completion_intent=completion_intent, terminal_closeout=terminal_closeout, + post_result_checkpoint=post_result_checkpoint, spend=spend, effect_resolvers=turn_effect_resolvers( writeback=writeback_resolver, diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts new file mode 100644 index 0000000000..71a27c1613 --- /dev/null +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -0,0 +1,54 @@ +/** Receipt-backed delivery observation; never settlement or direction authority. */ +import type {JsonObject} from "../effect_program.ts"; +import {jsonObject, requireJsonObject} from "../runtime_decode.ts"; +import {originalTurnRequestsNoFollowup} from "../todos/completion_state.ts"; + +export function evaluateFirstDelivery(value: unknown): JsonObject { + const request = requireJsonObject(value, "first delivery"); + if (request.phase === "project") { + if (request.observation_unavailable === true) { + return {schema_version: "first_delivery_progress_v0", stage: "observation_unavailable", + goal_completion_certified: false, + next_action: "Inspect unavailable checkpoint observations before resuming the affected Turn."}; + } + const checkpoint = jsonObject(jsonObject(request.writeback_run)?.vision_checkpoint); + const completion = jsonObject(request.completion_event); + const resultCommitted = request.result_committed === true || + ["completed", "terminal_no_followup", "receipt_repaired"].includes(String(completion?.status)); + const outcomeRetained = checkpoint?.required === true && checkpoint.satisfied === false; + const direction = jsonObject(request.direction_receipt); + const notRequired = checkpoint?.decision === "not_required" && checkpoint.required === false && checkpoint.satisfied === true; + const directionCommitted = checkpoint?.satisfied === true && + (jsonObject(checkpoint.read_context)?.purpose === "first_delivery" || + (request.checkpoint_continuation === true && checkpoint.read_context != null)); + const completionTodo = jsonObject(request.completion_todo); + const identity = jsonObject(request.identity); + const terminalRequired = completionTodo !== null && identity !== null && + originalTurnRequestsNoFollowup(completionTodo, identity); + const settlementComplete = request.settlement_complete === true && + (!terminalRequired || request.terminal_settlement_complete === true); + const unknown = request.unknown === true || + (jsonObject(request.direction_receipt)?.commit_attempt != null && !directionCommitted); + const stage = unknown ? "operation_unknown" + : !resultCommitted && !outcomeRetained && !directionCommitted && !notRequired ? "result_review_pending" + : !directionCommitted && direction?.direction_stopped === true ? "direction_stopped" + : !directionCommitted && direction?.direction_host_failure != null ? "direction_failed" + : !directionCommitted && !notRequired ? "direction_pending" + : settlementComplete ? "settled" : "settlement_pending"; + return {schema_version: "first_delivery_progress_v0", stage, + result_committed: resultCommitted, + outcome_retained: outcomeRetained || directionCommitted, + direction_committed: directionCommitted, + direction_required: !notRequired, + quota_spent: request.quota_spent === true, + goal_completion_certified: false, + next_action: stage === "operation_unknown" ? "Read back the original Turn and its run artifacts; retain its identity." + : stage === "result_review_pending" ? "Read the original Turn's result basis and validate the candidate before committing it." + : stage === "direction_stopped" ? "Result retained; the current Agent stopped. Inspect the original Turn before explicitly resuming." + : stage === "direction_failed" ? "Result retained; retry the original Turn's direction using the existing Host recovery policy." + : stage === "direction_pending" ? "Result retained; review the current direction using the original Turn, then resume its remaining settlement." + : stage === "settlement_pending" ? "Resume the original Turn's remaining settlement; retain successful writeback and quota receipts." + : "Turn settlement is complete. Read the current Goal obligations before selecting further work."}; + } + throw new TypeError("Delivery observation requires the project phase"); +} diff --git a/loopx/control_plane/turn_driver/host_candidate.py b/loopx/control_plane/turn_driver/host_candidate.py index efdfbd7de4..2d9afac2ea 100644 --- a/loopx/control_plane/turn_driver/host_candidate.py +++ b/loopx/control_plane/turn_driver/host_candidate.py @@ -186,6 +186,9 @@ def extract_turn_authority(request: Mapping[str, Any]) -> dict[str, Any]: if key not in {"replan_action_packet", "replan_read_argv", "core_goal", "planning_horizon", "acceptance_index"}} authority["action_signature"] = signature authority.update(host_context_projection(request)) + for field in ("delivery_result_context", "result_revalidation"): + if request.get(field) is not None: + authority[field] = request[field] return authority @@ -292,6 +295,7 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals f"{replan_instruction}" f"{acceptance_instruction}" f"{work_instruction}" + "Use delivery_result_context as the actual requirements read for work. If result_revalidation is present, recheck its retained candidate against the new basis and reuse valid artifacts. These inputs grant no new permissions.\n" f"Turn authority JSON:\n{authority_json}\n\n" "When finished, return only one JSON object (no Markdown fence) with " "these public-safe fields:\n" @@ -342,6 +346,21 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals ) +def render_checkpoint_prompt(request: Mapping[str, Any]) -> str: + """Continue this Agent on the exact post-outcome read, without work effects.""" + return ( + "Continue the current Agent's original Turn after its durable outcome. " + "Read checkpoint_context.basis and judge the current direction. Do not repeat implementation, " + "validation, Todo/Goal writes, quota spend, or scheduler actions. Return one JSON object with " + "the exact read_context_id and either a fresh Vision packet serialized as agent_vision_json " + "or an evidence-backed vision_unchanged_reason (<=240 chars) with empty agent_vision_json. " + "An unchanged reason requires the baseline in this basis. Use its vision_authoring contract. " + "A repeated conclusion is allowed; do not copy a pre-outcome decision. If permission, stop or unavailable " + "context prevents judging, return a bounded stop_reason with both decision fields empty. Otherwise leave stop_reason empty.\n" + + json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + ) + + def parse_model_json(text: str) -> dict[str, Any] | None: """Parse the host final assistant message as one JSON object. diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index f4cb77fed8..37b0fb2be4 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -12,6 +12,7 @@ import { normalizeVisionUnchangedReason } from "../goals/vision_checkpoint.ts"; import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { projectMcpInteraction } from "./host_interaction.ts"; import { + jsonObject, requireBoolean, requireJsonObject, requireNonEmptyString, @@ -23,6 +24,7 @@ export const HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v0"; export const HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v1"; +export const HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v2"; export const HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION = "loopx_host_todo_completion_reduction_v0"; export const HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION = @@ -35,6 +37,8 @@ export const HOST_ADAPTER_SETTLEMENT_STEP_KINDS = [ "durable_writeback", "quota_spend", "terminal_closeout", + "delivery_context", + "direction_context", ] as const; const TODO_ID_PATTERN = /^todo_[a-z0-9_-]{3,64}$/; @@ -56,7 +60,11 @@ interface HostTodoCompletionRequest { vision_path: string | null; vision_unchanged_reason: string | null; checkpoint_read_context_id: string | null; + first_delivery: boolean; + current_agent_checkpoint: boolean; + delivery_read_context_id: string | null; goal_instance_id: string | null; + turn_instance_id: string | null; provider_outcomes: readonly ProviderOutcome[]; } @@ -82,7 +90,7 @@ interface GuardSelection extends JsonObject { function decodePhase(value: JsonObject): HostTodoCompletionPhase { requireStringLiteral( value.schema_version, - [HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION] as const, + [HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION] as const, "schema_version", "schema_version is unsupported", ); @@ -131,14 +139,22 @@ function decodeRequest( const todoId = typedTodoId(value.todo_id, "todo_id"); const optionalText = (field: string): string | null => value[field] == null ? null : requireNonEmptyString(value[field], field); - const visionPath = optionalText("vision_path"); - const unchanged = normalizeVisionUnchangedReason(optionalText("vision_unchanged_reason")); + const defaultCheckpoint = value.current_agent_checkpoint === true; + const visionPath = defaultCheckpoint && !value.checkpoint_read_context_id ? null : optionalText("vision_path"); + const unchanged = defaultCheckpoint && !value.checkpoint_read_context_id ? null : normalizeVisionUnchangedReason(optionalText("vision_unchanged_reason")); const readContextId = optionalText("checkpoint_read_context_id"); + const firstDelivery = value.schema_version === HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION; + const resultContextId = optionalText("delivery_read_context_id"); + if (value.first_delivery === true && !firstDelivery) throw new EffectRuntimeRequestError("First delivery requires the v2 host contract."); + if (firstDelivery && value.first_delivery !== true) throw new EffectRuntimeRequestError("v2 requires explicit first_delivery opt-in."); + if (firstDelivery && value.no_follow_up === true && !defaultCheckpoint) { + throw new EffectRuntimeRequestError("Protected no-follow-up completion is not supported by this protocol stage; no effects were admitted. Use the existing ordinary contract or a real successor intent, without changing an unresolved original Turn."); + } if (visionPath && unchanged) { throw new EffectRuntimeRequestError("choose a vision patch or an unchanged reason, not both"); } if ((visionPath || unchanged || readContextId || phase === "vision_refresh" || phase === "vision_context") && - value.schema_version !== HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION) { + value.schema_version !== HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION && !firstDelivery) { throw new EffectRuntimeRequestError("host vision authoring requires v1"); } if (phase === "vision_refresh" && !visionPath && !unchanged) { @@ -147,7 +163,7 @@ function decodeRequest( if (phase === "vision_context" && (visionPath || unchanged || readContextId)) { throw new EffectRuntimeRequestError("vision context reads cannot submit a decision or receipt"); } - if (readContextId && phase !== "vision_refresh") { + if (readContextId && phase !== "vision_refresh" && !firstDelivery) { throw new EffectRuntimeRequestError("checkpoint read context belongs only to vision recovery"); } const goalId = requireNonEmptyString(value.goal_id, "goal_id"); @@ -194,6 +210,10 @@ function decodeRequest( vision_path: visionPath, vision_unchanged_reason: unchanged, checkpoint_read_context_id: readContextId, + first_delivery: firstDelivery, + current_agent_checkpoint: defaultCheckpoint, + delivery_read_context_id: resultContextId, + turn_instance_id: optionalText("turn_instance_id"), goal_instance_id: parsedGoalRef?.kind === "parsed" ? parsedGoalRef.value.goalInstanceId.value : null, @@ -202,10 +222,14 @@ function decodeRequest( if (phase === "finalize") { request.provider_outcomes = decodeProviderOutcomes(value.provider_outcomes); } + if (firstDelivery && (visionPath || unchanged) && !readContextId) { + throw new EffectRuntimeRequestError("Read the post-result direction context before authoring Vision; an adapter cannot bind old Vision to a new read."); + } return request; } function turnInstanceId(request: HostTodoCompletionRequest): string { + if (request.turn_instance_id !== null) return request.turn_instance_id; const identityParts = request.goal_instance_id === null ? [request.goal_id, request.agent_id, request.todo_id] : [ @@ -286,10 +310,14 @@ function exactIdentityConditions( ]; } -function guardContinueWhen(identity: JsonObject): JsonObject { +function guardContinueWhen(identity: JsonObject, requireOriginalWork = true): JsonObject { const path = ["heartbeat_receipt", "settlement_identity"]; return all( equals(["ok"], true), + ...(requireOriginalWork ? [any(nullish(["heartbeat_receipt", "result_freshness"]), + equals(["work_input_restored"], true), + equals(["result_committed"], true), + objectAt(["writeback_run"]))] : []), any( all( objectAt(path), @@ -378,6 +406,7 @@ function writebackArgs(request: HostTodoCompletionRequest, identity: JsonObject) ...(request.vision_path ? ["--agent-vision-json", request.vision_path] : []), ...(request.vision_unchanged_reason ? ["--vision-unchanged-reason", request.vision_unchanged_reason] : []), ...(request.checkpoint_read_context_id ? ["--checkpoint-read-context", request.checkpoint_read_context_id] : []), + ...(request.first_delivery ? ["--first-delivery", "--progress-scope", "goal"] : []), ]; } @@ -402,11 +431,12 @@ function providerSteps( : []), ]; const lifecycleArgs = request.completion_args.filter( - (arg) => arg !== "--no-follow-up", + (arg) => request.current_agent_checkpoint || arg !== "--no-follow-up", ); if (request.goal_instance_id) { lifecycleArgs.push("--goal-instance-id", request.goal_instance_id); } + if (request.delivery_read_context_id) lifecycleArgs.push("--delivery-read-context", request.delivery_read_context_id); const steps: ProviderStep[] = [ { step_kind: "guard", @@ -420,7 +450,7 @@ function providerSteps( "--execution-mode", request.execution_mode, ], - continue_when: guardContinueWhen(identity), + continue_when: guardContinueWhen(identity, !request.first_delivery), }, { step_kind: "lifecycle_completion", @@ -477,6 +507,15 @@ function providerSteps( continue_when: null, }); } + if (request.first_delivery && !request.checkpoint_read_context_id) { + const purpose = request.delivery_read_context_id || request.current_agent_checkpoint ? "first_delivery" : "delivery_result"; + const context: ProviderStep = {step_kind: purpose === "delivery_result" ? "delivery_context" : "direction_context", + args: ["checkpoint-context", "--goal-id", request.goal_id, "--agent-id", request.agent_id, + "--todo-id", request.todo_id, "--turn-instance-id", turnId, "--purpose", purpose, "--decision-scope", "goal", + ...(request.goal_instance_id ? ["--goal-instance-id", request.goal_instance_id] : [])], + legacy_args: null, continue_when: null}; + return [...steps.slice(0, purpose === "first_delivery" ? 2 : 1), context]; + } return steps; } @@ -741,6 +780,39 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { const steps = providerSteps(request, identity); const outcomes = request.provider_outcomes; validateOutcomeOrder(outcomes, steps); + if (request.first_delivery && !request.checkpoint_read_context_id) { + const last = outcomes.at(-1); + const context = last ? parseObject(last.output) : null; + const postResult = request.delivery_read_context_id !== null || request.current_agent_checkpoint; + const expectedStep = postResult ? "direction_context" : "delivery_context"; + if (outcomes.length !== steps.length || last?.step_kind !== expectedStep || context?.ok !== true || + !identityMatches(context, identity)) { + return reduction("finalize", "provider_result", identity, null, context ?? {ok: false, error: "First delivery stopped before context readback."}); + } + const committedContext = jsonObject(context.context); + if (request.current_agent_checkpoint && context.status === "committed" && + committedContext?.purpose === "first_delivery" && typeof committedContext.read_context_id === "string" && + committedContext.read_context_id.length > 0) { + // Resume the proven original append, never attach a new read to an old decision. + return {...reduction("finalize", "execute", identity, + providerEffect(providerSteps({...request, checkpoint_read_context_id: committedContext.read_context_id}, identity)), null), + resume_checkpoint_read_context_id: committedContext.read_context_id}; + } + return reduction("finalize", "provider_result", identity, null, { + schema_version: HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, ok: true, completed: postResult, + settlement_complete: false, settlement_identity: identity, + stage: postResult ? "direction_pending" : "result_review_pending", + ...(postResult ? {completion: outcomeAt(outcomes, 1)} : {}), context, + recovery: {tool: "complete_task", ...(request.current_agent_checkpoint ? {} : {first_delivery: true}), + delivery_read_context_id: request.delivery_read_context_id ?? context.read_context_id, + ...(postResult ? {read_context_id: context.read_context_id} : {}), + instruction: postResult + ? request.current_agent_checkpoint + ? "Read this post-result context and judge direction. Continue complete_task with its read_context_id, the new Vision decision and your original completion arguments. The original result key is restored automatically; replay does not repeat work." + : "Read this post-result context and judge direction. Call complete_task with the original completion intent, both read identities and the new Vision decision. Result replay does not repeat work." + : "Read this result basis, check the candidate and validation, then call complete_task with this delivery_read_context_id and the same intended result. Do not submit Vision until the post-result context is returned."}, + }); + } if (outcomes.length === 0) { return reduction( @@ -753,6 +825,16 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { } const guard = guardSelection(outcomes[0].output); + const guardPayload = parseObject(outcomes[0].output); + if (jsonObject(guardPayload?.heartbeat_receipt)?.result_freshness === "turn_result_freshness_required_v1" && + guardPayload?.work_input_restored !== true && guardPayload?.result_committed !== true) { + return reduction("finalize", "blocked", identity, null, { + ...blockedResult(request, "validation", "No original work read preceded this result. Revalidate the retained candidate against the returned basis before retrying."), + error_code: "delivery_work_read_required", settlement_identity: identity, + context: guardPayload?.delivery_result_context ?? null, + recovery: {tool: "complete_task", instruction: "Read the returned work basis and validate this candidate on the original Turn, then retry the same completion. The adapter carries its original key; preserve valid artifacts."}, + }); + } if (guard.state !== "selected") { return reduction( "finalize", @@ -981,6 +1063,7 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { ok: true, completed: true, status: "done", + ...(request.current_agent_checkpoint ? {settlement_complete: true, stage: "settled"} : {}), goal_id: request.goal_id, todo_id: request.todo_id, settlement_identity: identity, @@ -1029,7 +1112,7 @@ export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { "--turn-instance-id", String(identity.turn_instance_id), ...(request.goal_instance_id ? ["--goal-instance-id", request.goal_instance_id] - : [])], + : []), ...(request.first_delivery ? ["--purpose", "first_delivery", "--decision-scope", "goal"] : [])], }; } if (phase === "vision_refresh") { diff --git a/loopx/control_plane/turn_driver/settlement.py b/loopx/control_plane/turn_driver/settlement.py index ee68b9cf2d..f805963e6b 100644 --- a/loopx/control_plane/turn_driver/settlement.py +++ b/loopx/control_plane/turn_driver/settlement.py @@ -389,12 +389,12 @@ def terminal_closeout_requirement( writeback_payload = ( stored_writeback if isinstance(stored_writeback, Mapping) else {} ) - observed_completion = terminal_payload.get("completion") or writeback_payload.get( - "completion" - ) + observed_completion = journal.get("completion_intent") or terminal_payload.get("completion") or writeback_payload.get("completion") try: if not isinstance(observed_completion, Mapping): observed_completion = completion_intent(result) + if isinstance(journal, dict): + journal["completion_intent"] = dict(observed_completion) envelope = plan.get("turn_envelope") selected = selected_turn_todo(envelope) if isinstance(envelope, Mapping) else {} outcome = require_loopx_turn_completion_outcome( diff --git a/loopx/control_plane/turn_driver/turn_journal.ts b/loopx/control_plane/turn_driver/turn_journal.ts index 91e93362a4..1a96088e3a 100644 --- a/loopx/control_plane/turn_driver/turn_journal.ts +++ b/loopx/control_plane/turn_driver/turn_journal.ts @@ -318,7 +318,7 @@ function preparedEffectCheck( }; } -function hostRetryPolicyCheck(journal: JsonObject): TurnRecoveryCheck | null { +export function hostRetryPolicyCheck(journal: JsonObject): TurnRecoveryCheck | null { if (!Object.hasOwn(journal, "host_failure")) return null; let failure: JsonObject; try { failure = normalizeHostFailure(journal.host_failure); } diff --git a/loopx/control_plane/turn_driver/turn_journal_effects.ts b/loopx/control_plane/turn_driver/turn_journal_effects.ts index 0518c3172b..3c55b3f05f 100644 --- a/loopx/control_plane/turn_driver/turn_journal_effects.ts +++ b/loopx/control_plane/turn_driver/turn_journal_effects.ts @@ -141,6 +141,92 @@ function samePrefix(left: readonly string[], right: readonly string[]): boolean return left.length <= right.length && left.every((phase, index) => phase === right[index]); } +function resultValidationInvalidated(existing: JsonObject, incoming: JsonObject, + previous: JournalState, next: JournalState): boolean { + if (previous.status !== "in_progress" || next.status !== "failed" || + previous.completedPhases.length !== 3 || next.completedPhases.length !== 2 || + next.failedPhase !== "validation" || incoming.validation_stage !== "result_freshness") return false; + // Only a rejected, uncommitted result may invalidate its validation checkpoint. + // Prepared effects must already have been aborted by the settlement owner. + if ([existing, incoming].some(journal => ["writeback", "quota_spend", "terminal_closeout", + "effect_attempts", "source_effect_hold"].some(key => journal[key] !== undefined))) return false; + const context = asObject(existing.result_work_context); + const retry = asObject(incoming.result_revalidation); + const rejection = asObject(retry.rejection); + return rejection.ok === false && + ["checkpoint_read_context_stale", "delivery_validation_binding_mismatch"].includes(String(rejection.error_code)) && + typeof context.read_context_id === "string" && context.read_context_id.length > 0 && + typeof existing.task_validation === "object" && existing.task_validation !== null && + typeof existing.host_result === "object" && existing.host_result !== null && + operationId(context) === operationId(asObject(incoming.result_work_context)) && + operationId(context) === operationId(asObject(retry.original_context)) && + operationId(asObject(existing.host_result)) === operationId(asObject(retry.candidate)) && + incoming.task_validation === undefined && incoming.result_work_binding === undefined; +} + +function requireTerminalResultRevalidation(existing: JsonObject, incoming: JsonObject, + previous: JournalState, next: JournalState): void { + const prior = asObject(existing.result_revalidation); + const retry = asObject(incoming.result_revalidation); + if (prior.phase !== "terminal_closeout" && retry.phase !== "terminal_closeout") return; + if (previous.completedPhases.length !== 5 || next.completedPhases.length !== 5 || + [existing, incoming].some(journal => journal.terminal_closeout !== undefined || journal.effect_attempts !== undefined) || + ["writeback", "quota_spend"].some(key => existing[key] == null || incoming[key] == null || + operationId(asObject(existing[key])) !== operationId(asObject(incoming[key])))) { + conflict("Terminal result revalidation must retain the original settled effects"); + } + if (retry.phase === "terminal_closeout") { + const rejection = asObject(retry.rejection); + if (rejection.ok !== false || !["checkpoint_read_context_stale", "delivery_validation_binding_mismatch"] + .includes(String(rejection.error_code))) conflict("Terminal result revalidation requires a known result rejection"); + const original = prior.phase === "terminal_closeout" ? prior : { + candidate: existing.host_result, original_context: existing.result_work_context}; + if (["candidate", "original_context"].some(key => + operationId(asObject(retry[key])) !== operationId(asObject(original[key])))) { + conflict("Terminal result revalidation must retain its original candidate and basis"); + } + if (typeof asObject(retry.original_context).read_context_id !== "string" || + !String(asObject(retry.original_context).read_context_id)) { + conflict("Terminal result revalidation requires its original work key"); + } + const view = asObject(retry.validation); + if (Object.keys(view).length) { + const viewPhases = view.completed_phases; + if (!Array.isArray(viewPhases) || viewPhases.length > 3 || + typeof asObject(view.work_context).read_context_id !== "string" || + !viewPhases.every((phase, index) => phase === transactionPhases[index]) || + !["in_progress", "failed"].includes(String(view.status)) || + ["writeback", "quota_spend", "terminal_closeout", "effect_attempts"].some(key => view[key] !== undefined) || + journalPhaseViolation(String(view.status), viewPhases as string[], + typeof asObject(view.receipt).failed_phase === "string" ? String(asObject(view.receipt).failed_phase) : null)) { + conflict("Terminal result revalidation may execute only Host and validation stages"); + } + } + if (["host_result", "task_validation"].some(key => + operationId(asObject(existing[key])) !== operationId(asObject(incoming[key]))) && + (!Array.isArray(view.completed_phases) || view.completed_phases.length !== 3 || + asObject(view.task_validation).ok !== true || + ["host_result", "task_validation"].some(key => + operationId(asObject(view[key])) !== operationId(asObject(incoming[key]))))) { + // Clearing old validation when recording a rejection is allowed; publishing + // a replacement candidate requires the completed validation view. + if (incoming.task_validation !== undefined || + operationId(asObject(existing.host_result)) !== operationId(asObject(incoming.host_result))) { + conflict("Terminal result revalidation cannot publish an unvalidated replacement"); + } + } + } else { + const view = asObject(prior.validation); + if (!Array.isArray(view.completed_phases) || view.completed_phases.length !== 3 || + asObject(view.task_validation).ok !== true || incoming.result_work_binding == null || + operationId(asObject(view.work_context)) !== operationId(asObject(incoming.result_work_context)) || + operationId(asObject(view.host_result)) !== operationId(asObject(incoming.host_result)) || + operationId(asObject(view.task_validation)) !== operationId(asObject(incoming.task_validation))) { + conflict("Terminal result revalidation requires the newly validated candidate binding"); + } + } +} + function requireJournalState(journal: JsonObject): JournalState { if (journal.schema_version !== "loopx_turn_journal_v0") { throw new EffectRuntimeRequestError( @@ -303,6 +389,13 @@ function requireJournalTransition( if (operationId(asObject(existing.plan), previousNumbers) !== operationId(asObject(incoming.plan), nextNumbers)) { conflict("Turn journal transaction plan is immutable", "journal_plan_conflict"); } + requireTerminalResultRevalidation(existing, incoming, previous, next); + for (const key of ["completion_intent", "checkpoint_host_binding"]) { + if (existing[key] !== undefined && operationId(asObject(existing[key])) !== + operationId(asObject(incoming[key]))) { + conflict(`Original Turn ${key} is immutable`); + } + } if (terminalStatuses.has(previous.status)) { const auditOnly = previous.status === next.status && @@ -329,7 +422,7 @@ function requireJournalTransition( samePrefix(next.completedPhases, previous.completedPhases); if ( !samePrefix(previous.completedPhases, next.completedPhases) && - !validationRetryRewind + !validationRetryRewind && !resultValidationInvalidated(existing, incoming, previous, next) ) { conflict("Turn journal completed phases cannot regress or fork"); } diff --git a/loopx/dsh_goal_mode/turn_host_adapter.py b/loopx/dsh_goal_mode/turn_host_adapter.py index 24dc19619d..e76cbdc7e9 100644 --- a/loopx/dsh_goal_mode/turn_host_adapter.py +++ b/loopx/dsh_goal_mode/turn_host_adapter.py @@ -44,6 +44,7 @@ extract_turn_authority as extract_turn_authority, parse_model_json as parse_model_json, render_prompt as render_prompt, + render_checkpoint_prompt, ) from ..control_plane.turn_driver.execution_profile import ( MANAGED_MODEL_DEFAULT, @@ -462,7 +463,7 @@ def _execute_turn_host_request( ) try: - prompt = render_prompt(authority) + prompt = render_checkpoint_prompt(request) if request.get("phase") == "checkpoint_direction" else render_prompt(authority) runner = ( load_dsh_runner(config.dsh_runner.expanduser().resolve()) if config.dsh_runner is not None @@ -548,6 +549,10 @@ def _execute_turn_host_request( try: candidate = parse_model_json(outcome["final_response"]) + if request.get("phase") == "checkpoint_direction": + if candidate is None: + raise DshHostResultError("dsh direction response requires a JSON object") + return candidate if candidate is None and warn is not None: warn("adapter: dsh final response did not contain a JSON result object") return build_result( diff --git a/loopx/file_lock.py b/loopx/file_lock.py index 09959b2b80..66ca2e1d17 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -345,7 +345,20 @@ def _write_holder_sidecar(holder_path: Path, record: dict[str, object]) -> None: holder_file.write("\n") holder_file.flush() os.fsync(holder_file.fileno()) - os.replace(temporary_path, holder_path) + # A Windows reader may briefly deny DELETE sharing. Keep the old + # complete diagnostic until atomic replacement succeeds; persistent + # access denial still propagates within this bounded IO window. + deadline = time.monotonic() + 0.5 + while True: + try: + os.replace(temporary_path, holder_path) + break + except OSError as error: + remaining = deadline - time.monotonic() + if (os.name != "nt" or getattr(error, "winerror", None) != 5 + or remaining <= 0): + raise + time.sleep(min(0.05, remaining)) finally: temporary_path.unlink(missing_ok=True) diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 02aeb55aec..70fb054f31 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -14,6 +14,7 @@ from .control_plane.host_adapter_settlement import ( HostTodoSettlementRequest, + classify_host_guard_snapshot, host_vision_request, project_host_interaction, refresh_host_todo_vision, @@ -174,16 +175,75 @@ def should_run_args(self, goal_id: str, agent_id: str | None) -> tuple[list[str] ], ) - def should_run(self) -> str: + def should_run(self, revalidate_todo_id: str = "") -> str: goal_id, _ = self.context() if not goal_id: return self.no_goal_message() args, legacy_args = self.should_run_args(goal_id, self.bound_agent_id()) - return project_host_interaction(self.run_cli(args, legacy_args=legacy_args)) + suffix = ["--todo-id", revalidate_todo_id] if revalidate_todo_id else [] + output = self.run_cli([*args, *suffix], legacy_args=[*legacy_args, *suffix]) + if revalidate_todo_id: + guard = json.loads(output) + if guard.get("ok") is True and guard.get("delivery_result_context"): + identity = guard["delivery_result_context"]["settlement_identity"] + refreshed = json.loads(self.run_cli(["checkpoint-context", "--goal-id", goal_id, + "--agent-id", self.bound_agent_id(), *suffix, + "--turn-instance-id", identity["turn_instance_id"], "--purpose", "delivery_result"])) + if refreshed.get("ok") is not True: + return json.dumps(refreshed) + guard["delivery_result_context"] = {key: refreshed[key] for key in + ("ok", "basis", "read_context_id", "settlement_identity")} + guard["result_revalidation_required"] = True + guard["next_action"] = "Revalidate the retained candidate against this basis, then call complete_task for the same Todo. The original Turn key is carried automatically." + output = json.dumps(guard) + return project_host_interaction(output) + + def _original_work_turn(self, todo_id: str) -> str | None: + """Resolve installed admission through the same owner used by the CLI.""" + from pathlib import Path + from .history import load_registry + from .paths import resolve_runtime_root + from .control_plane.goals.checkpoint_context_io import restore_default_turn_id + + goal_id, registry = self.context() + if not registry or not Path(registry).is_file(): + return None # Older host contracts retain their deterministic identity. + path = Path(registry) + return restore_default_turn_id(runtime_root=resolve_runtime_root(load_registry(path), None, registry_path=path), + registry_path=path, goal_id=goal_id, agent_id=self.bound_agent_id(), todo_id=todo_id, pending_only=False) def list_todos(self) -> str: return self.should_run() + def _checkpoint_continuation(self, todo_id: str, turn_id: str | None) -> bool: + if turn_id is None: + return False + from pathlib import Path + from .history import load_registry + from .paths import resolve_runtime_root + from .control_plane.quota.settlement import read_heartbeat_settlement + from .control_plane.goals.checkpoint_context_io import checkpoint_continuation_required + + goal_id, registry = self.context() + path = Path(registry) + root = resolve_runtime_root(load_registry(path), None, registry_path=path) + readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=self.bound_agent_id(), + todo_id=todo_id, turn_instance_id=turn_id, registry_path=path, goal_ref=self.goal_ref()) + return readback is not None and checkpoint_continuation_required(readback) + + def _stored_terminal_intent(self, todo_id: str, turn_id: str) -> bool: + from pathlib import Path + from .todos import list_goal_todos + from .control_plane.quota.settlement import SettlementIdentity + from .control_plane.goals.checkpoint_context_io import _checkpoint_effect + + goal_id, registry = self.context() + todo = list_goal_todos(registry_path=Path(registry), goal_id=goal_id, todo_id=todo_id).get("todo") + identity = SettlementIdentity(goal_id=goal_id, agent_id=self.bound_agent_id(), + todo_id=todo_id, turn_instance_id=turn_id) + return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "direction_terminal_intent", "todo": todo, "identity": identity.as_dict()})["no_followup"] + def host_prompt(self) -> str: from .claude_goal_mode.scripts.goalmode_cmd import loop_execution_content state = self.state() @@ -226,6 +286,9 @@ def complete_task( successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: goal_id, _ = self.context() if not goal_id: @@ -233,6 +296,10 @@ def complete_task( identity_error = self._identity_error(agent_id) if identity_error: return identity_error + turn_id = self._original_work_turn(todo_id) + current_checkpoint = self._checkpoint_continuation(todo_id, turn_id) + if current_checkpoint and not no_follow_up: + no_follow_up = self._stored_terminal_intent(todo_id, turn_id) if next_agent_todo and no_follow_up: return json.dumps( { @@ -278,8 +345,13 @@ def complete_task( scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, completion_args=tuple(args), + first_delivery=first_delivery, + delivery_read_context_id=delivery_read_context_id or None, + checkpoint_read_context_id=read_context_id or None, no_follow_up=no_follow_up, goal_ref=self.goal_ref(), + turn_instance_id=turn_id, + current_agent_checkpoint=current_checkpoint, ) with host_vision_request(request, agent_vision, vision_unchanged_reason, allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: @@ -289,6 +361,7 @@ def review_task_vision( self, todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", + first_delivery: bool = False, ) -> str: goal_id, _ = self.context() if not goal_id: @@ -296,6 +369,7 @@ def review_task_vision( identity_error = self._identity_error(agent_id) if identity_error: return identity_error + turn_id = self._original_work_turn(todo_id) request = HostTodoSettlementRequest( goal_id=goal_id, agent_id=agent_id, todo_id=todo_id, runtime_profile=self.config.runtime_profile, @@ -303,7 +377,10 @@ def review_task_vision( scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, completion_args=(), checkpoint_read_context_id=read_context_id or None, + first_delivery=first_delivery, goal_ref=self.goal_ref(), + turn_instance_id=turn_id, + current_agent_checkpoint=self._checkpoint_continuation(todo_id, turn_id), ) with host_vision_request(request, agent_vision, vision_unchanged_reason, allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: @@ -343,9 +420,13 @@ def host_prompt() -> str: return control.host_prompt() @server.tool() - def should_run() -> str: - """Whether the bound goal and agent should run now.""" - return control.should_run() + def should_run(revalidate_todo_id: str = "") -> str: + """Read admitted work and its original result basis. After a stale result, + pass its Todo as revalidate_todo_id to read new facts on the same Turn. + Revalidate the retained candidate before complete_task; no token copying + is needed. Ordinary calls preserve the original basis across restarts. + """ + return control.should_run(revalidate_todo_id) @server.tool() def list_todos() -> str: @@ -362,6 +443,7 @@ def review_task_vision( todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", + first_delivery: bool = False, ) -> str: """Supply a missing vision decision for a previously completed MCP Todo. First call with only todo_id and agent_id to read the current basis. @@ -375,7 +457,7 @@ def review_task_vision( An unchanged reason requires an existing valid vision. Recheck should_run; checkpoint success alone does not certify Goal completion or clear gates. """ - return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason, read_context_id) + return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason, read_context_id, first_delivery) @server.tool() def complete_task( @@ -388,9 +470,21 @@ def complete_task( successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: """Complete verified work and settle once using claim_operation_id from claim_task. Reuse that reference on retries; ownership and expiry are checked internally. + Ordinary work uses the result + basis returned by should_run; completion carries its original key. After + stale rejection call should_run(revalidate_todo_id=todo_id), revalidate + the retained candidate, then retry with the same completion intent. + With first_delivery=true, first + call without Vision to read the result basis; verify it and echo its + delivery_read_context_id. Read the returned post-result direction basis, + then call with both original read identities and a newly judged Vision. + An interrupted call retains the same Todo/Turn; do not repeat work. Link existing planned successors with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. no_follow_up closes this Todo's continuation, NOT the Goal's vision. @@ -412,6 +506,9 @@ def complete_task( successor_todo_ids=successor_todo_ids, agent_vision=agent_vision, vision_unchanged_reason=vision_unchanged_reason, + first_delivery=first_delivery, + delivery_read_context_id=delivery_read_context_id, + read_context_id=read_context_id, ) return server, control diff --git a/loopx/quota.py b/loopx/quota.py index ed4b56de23..7af0873980 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -946,7 +946,7 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): return read_replan_explore_context(source_status=status_payload, goal_id=read_goal_id, agent_id=read_agent_id) - return _build_quota_should_run( + payload = _build_quota_should_run( status_payload, goal_id=goal_id, agent_id=agent_id, @@ -974,6 +974,22 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): authoritative_fallback_todo_items=authoritative_fallback_todo_items, replan_explore_reader=read_explore_context, ) + observation_root = runtime_root or status_payload.get("runtime_root") + if goal_ref is None and agent_id and observation_root: + from .control_plane.goals.checkpoint_context_io import pending_first_delivery_progress + + delivery_progress = pending_first_delivery_progress( + Path(observation_root), goal_id, agent_id, + registry_path=Path(status_payload["registry"]) if status_payload.get("registry") else None, + ) + if delivery_progress is not None: + payload["first_delivery_progress"] = delivery_progress + # Observation cannot grant work or override an owner pause/health hold. + if (delivery_progress.get("settlement_identity") and payload.get("ok") + and payload.get("state") != "paused" + and delivery_progress.get("stage") != "result_review_pending"): + payload["recommended_action"] = delivery_progress["next_action"] + return payload def _quota_spend_index_basis( diff --git a/loopx/semantics/project_registry_io.py b/loopx/semantics/project_registry_io.py index fa8a2635e0..54deae5cb9 100644 --- a/loopx/semantics/project_registry_io.py +++ b/loopx/semantics/project_registry_io.py @@ -50,7 +50,7 @@ ) DIRECT_WRITE_METHODS = frozenset({"write_bytes", "write_text"}) ALLOWED_DIRECT_CLASSIFICATIONS = frozenset( - {"codec_internal", "global_registry_io", "legacy_registry_source"} + {"codec_internal", "global_registry_io", "legacy_registry_source", "source_digest_guard"} ) @@ -561,6 +561,12 @@ def validate_project_registry_io_manifest( elif kind in {"direct_json_read", "direct_json_write"}: if classification not in ALLOWED_DIRECT_CLASSIFICATIONS: errors.append(f"unclassified direct project registry I/O: {site}") + elif classification == "source_digest_guard" and ( + kind != "direct_json_read" or row.get("api") != "readFileSync" + ): + # A synchronous byte comparison is not a registry decoder or + # writer. This census-local classification cannot exempt either. + errors.append(f"source digest guard must be a synchronous byte read: {site}") else: errors.append(f"project registry I/O site has unknown kind: {site}") return errors diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 160b95952d..dce10ee120 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -224,6 +224,8 @@ "loopx/control_plane/turn_driver/executor.py::_run_task_validator", "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt", "loopx/control_plane/turn_driver/executor.py::_task_validation_stage", + "loopx/control_plane/turn_driver/executor.py::_retain_result_for_revalidation", + "loopx/control_plane/turn_driver/executor.py::_terminal_result_validation_failure", "loopx/control_plane/turn_driver/transaction.py::_result_kind" ], "call_producers": { @@ -534,11 +536,11 @@ } }, "host_adapter_settlement_step_kind": { - "meaning": "Ordered provider steps of one host-adapter settlement plan. The TypeScript owner authors the plan and the Python transport validates the plan it receives against the same sequence, so the two runtimes must carry the same order. Distinct from settlement_step_kind: that vocabulary names the turn settlement phases, whose validation phase is never executed, while every kind here is a command the host adapter runs.", + "meaning": "Provider step kinds used by host-adapter settlement plans. TypeScript authors each phase's ordered plan and Python validates that phase's sequence before execution. Ordinary settlement uses guard, lifecycle_completion, durable_writeback, quota_spend and optional terminal_closeout; first-delivery preparation uses delivery_context or direction_context in its phase-specific sequence. Distinct from settlement_step_kind, which names the turn settlement phases including seeded validation.", "tier": "cross_runtime", "status": "canonical", "owners": { - "python": "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE", + "python": "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_KINDS", "typescript": "loopx/control_plane/turn_driver/host_todo_completion.ts::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" }, "values": [ @@ -546,14 +548,18 @@ "lifecycle_completion", "durable_writeback", "quota_spend", - "terminal_closeout" + "terminal_closeout", + "delivery_context", + "direction_context" ], "value_notes": { "guard": "The admission step: it runs the real quota should-run command with the turn instance id, and its continue_when decides whether the later steps run at all.", "lifecycle_completion": "The completion step: it runs the Todo completion arguments before anything is written back.", "durable_writeback": "The writeback step: it persists the completed work, conditioned on the completion result.", "quota_spend": "The quota step: it debits the slot, conditioned on the writeback result, and accepts an idempotent replay of an already committed spend.", - "terminal_closeout": "The optional last step, emitted only when the Todo needs a terminal closeout; it carries no continue_when because nothing follows it." + "terminal_closeout": "The optional last step, emitted only when the Todo needs a terminal closeout; it carries no continue_when because nothing follows it.", + "delivery_context": "The last step of the first-delivery preparation plan after guard; it reads the retained result basis before lifecycle completion and does not settle or spend.", + "direction_context": "The last step after guard and lifecycle_completion when first-delivery direction input is required; it reads the post-result basis without inventing a new Turn or quota debit." } }, "settlement_binding_kind": { diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index b172e508a2..34014865a8 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -108,6 +108,7 @@ from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs from .control_plane.goals.checkpoint_context_io import ( checkpoint_commit_guard, commit_checkpoint_run, require_complete_checkpoint_index, inspect_checkpoint_replay, + first_delivery_context_enrolled, result_work_required, checkpoint_continuation_required, ) from .registry import registry_goals as registry_goals, resolve_state_file as resolve_state_file from .runtime import validate_goal_id_path_segment @@ -732,6 +733,7 @@ def refresh_state_run( merge_agent_vision_patch: bool = False, vision_unchanged_reason: str | None = None, checkpoint_read_context_id: str | None = None, + first_delivery: bool = False, progress_observation: dict[str, Any] | None = None, explore_result: dict[str, Any] | None = None, completion_todo_id: str | None = None, @@ -753,6 +755,8 @@ def refresh_state_run( raise ValueError("--next-action-basis requires --next-action") if checkpoint_read_context_id and not turn_instance_id: raise ValueError("--checkpoint-read-context requires the original Turn identity") + if first_delivery and not (turn_instance_id and agent_id and (todo_id or replan_obligation_id)): + raise ValueError("--first-delivery requires the original Goal/Agent/Todo or obligation/Turn") validate_public_safe_text("classification", classification) if usage_measurement is not None and usage_codex_session is not None: raise ValueError("--usage-json cannot be combined with --usage-codex-session") @@ -856,7 +860,9 @@ def refresh_state_run( settlement_readback = None refresh_recovery = None prior_writeback_run = None + default_checkpoint = False checkpoint_supplement = False + first_delivery_checkpoint = False if todo_id or normalized_replan_obligation_id or turn_instance_id: if checkpoint_read_context_id or agent_vision_packet or vision_unchanged_reason: require_complete_checkpoint_index(runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl") @@ -876,6 +882,11 @@ def refresh_state_run( source_admission=source_admission, borrow_source_admission=source_admission is not None, refresh_retry=(refresh_retry_request := { + **({"first_delivery_request": { + "classification": classification, "recommended_action": recommended_action, + "agent_lane": agent_lane, "progress_scope": progress_scope, + "completion_todo_id": completion_todo_id, "completion_turn_key": completion_turn_key, + }} if first_delivery else {}), "checkpoint_read_context_id": checkpoint_read_context_id, "external_delivery": external_delivery, "vision": agent_vision_packet, @@ -926,7 +937,22 @@ def refresh_state_run( recovery_payload, registry_path=registry_path, runtime_root=runtime_root, goal_id=safe_goal_id, project=project, state_file=state_file, ) - if checkpoint_read_context_id and not checkpoint_supplement: + first_delivery_checkpoint = ( + refresh_recovery["decision"] == "append" + and first_delivery_context_enrolled(runtime_root, settlement_identity, + result_only=result_work_required(settlement_readback)) + ) + default_checkpoint = checkpoint_continuation_required(settlement_readback) + if first_delivery != first_delivery_checkpoint and not default_checkpoint: + raise ValueError("first delivery requires matching --first-delivery and persisted first_delivery context; " + "read and judge checkpoint-context --purpose first_delivery on the original Turn") + if first_delivery_checkpoint and not (agent_vision_packet or vision_unchanged_reason): + raise ValueError("first delivery direction is pending; read checkpoint-context --purpose first_delivery " + "for this original Turn, judge the returned state, then submit its context and direction") + if first_delivery_checkpoint and (next_action or usage_codex_session is not None): + raise ValueError("first delivery checkpoint does not support combined next-action or session usage booking; " + "retain the original Turn and use separately owned stages") + if checkpoint_read_context_id and not (checkpoint_supplement or first_delivery_checkpoint): raise ValueError("--checkpoint-read-context applies only to a missing-checkpoint supplement") settlement_workspace_requirement = resolve_settlement_workspace_requirement( delivery_workspace_causality, settlement_binding_kind=settlement_identity.binding_kind.value @@ -1117,13 +1143,21 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): review_pages=((agent_vision or {}).get("stride_decision") or {}).get( "explore_review", {}).get("result_pages")) + from .control_plane.goals.checkpoint_context_io import _checkpoint_effect + replan_stage = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "replan_stage", "heartbeat_receipt": settlement_readback.heartbeat_receipt if settlement_readback else None, + "prior": prior_writeback_run, "requested": autonomous_replan_recorded, + "has_read_context": bool(checkpoint_read_context_id), + }) if default_checkpoint and (autonomous_replan_recorded or + (prior_writeback_run or {}).get("vision_checkpoint", {}).get("replan_stage")) else { + "deferred": False, "qualify": autonomous_replan_recorded} replan_qualification = qualify_refresh_replan_writeback( replan_explore_reader=read_explore_context, replan_source_route={"registry": str(registry_path), "runtime_root": str(runtime_root)}, capability_evidence=capability_evidence, guard_capability=settlement_replan_guard.get("selected_capability_guard"), todo_fields=todo_fields, - autonomous_replan_recorded=autonomous_replan_recorded, + autonomous_replan_recorded=replan_stage["qualify"], requested_delta_kinds=normalized_repair_delta_kinds, active_state_next_action_update=None, agent_vision=agent_vision, @@ -1233,6 +1267,13 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): autonomous_replan_recorded=effective_autonomous_replan_recorded, blocked_retry=blocked_retry, ) + if replan_stage["deferred"]: + vision_checkpoint["replan_stage"] = "awaiting_direction" + if (settlement_readback is not None and checkpoint_continuation_required(settlement_readback) + and vision_checkpoint.get("required") is True + and vision_checkpoint.get("satisfied") is True + and not (checkpoint_supplement or first_delivery_checkpoint)): + raise ValueError("Default checkpoint requires a post-result read and a newly authored decision on the original Turn; old Vision cannot be rebound to a new context.") if checkpoint_supplement and not vision_checkpoint.get("satisfied"): raise ValueError( "checkpoint supplement did not satisfy the missing decision; " @@ -1464,13 +1505,16 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): source_context=recommendation_source_context(current_goal, current_text, source_registry=next_action_source_registry, todo_fields=current_planning.todo_fields), runs=newest_first_runs) - if checkpoint_supplement: + if checkpoint_supplement or first_delivery_checkpoint: assert settlement_identity is not None context = usage_booking_guard.enter_context(checkpoint_commit_guard( runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, read_context_id=checkpoint_read_context_id, goal_ref=goal_ref, + source_admission=source_admission, + purpose="first_delivery" if first_delivery_checkpoint else "supplement_checkpoint", + decision_scope="goal" if default_checkpoint else normalized_progress_scope if first_delivery_checkpoint else None, )) for projection in (record, index_record, payload): projection["vision_checkpoint"] = { @@ -1538,12 +1582,14 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): index_record["markdown_path"] = str(markdown_path) payload["json_path"] = str(json_path) payload["markdown_path"] = str(markdown_path) - if checkpoint_supplement: + if checkpoint_supplement or first_delivery_checkpoint: saved = commit_checkpoint_run(runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, refresh_retry=refresh_retry_request, record=record, index_record=index_record, markdown=render_state_refresh_markdown(payload) + "\n", - goal_ref=goal_ref, source_admission=source_admission) + goal_ref=goal_ref, source_admission=source_admission, + purpose="first_delivery" if first_delivery_checkpoint else "supplement_checkpoint", + decision_scope="goal" if default_checkpoint else normalized_progress_scope if first_delivery_checkpoint else None) for projection in (record, index_record, payload): projection["vision_checkpoint"]["read_context"] = saved["context"] for projection in (index_record, payload): diff --git a/scripts/generate_turn_contract.py b/scripts/generate_turn_contract.py index 046c19e897..53a2debe76 100644 --- a/scripts/generate_turn_contract.py +++ b/scripts/generate_turn_contract.py @@ -216,12 +216,11 @@ def require(condition, message): def build_artifacts(): - contract = validate_contract(read_contract()) - # Git may check the contract out with CRLF on Windows; hash the canonical LF - # bytes so every checkout records the same provenance digest. - digest = hashlib.sha256( - CONTRACT_PATH.read_bytes().replace(b"\r\n", b"\n") - ).hexdigest() + # Universal-newline decoding gives LF and CRLF checkouts the same content + # identity. Parse and hash this one snapshot so provenance matches the data. + source = CONTRACT_PATH.read_text(encoding="utf-8") + contract = validate_contract(json.loads(source, object_pairs_hook=_unique_object)) + digest = hashlib.sha256(source.encode("utf-8")).hexdigest() header = f"Generated by scripts/generate_turn_contract.py; do not edit.\nSource: loopx/control_plane/turn_loop_controller_contract_v0.json\nSHA256: {digest}" py = [ '"""' + header + '"""', diff --git a/tests/architecture/test_turn_contract_generation.py b/tests/architecture/test_turn_contract_generation.py index cfeffd9788..073da3a9d9 100644 --- a/tests/architecture/test_turn_contract_generation.py +++ b/tests/architecture/test_turn_contract_generation.py @@ -1,6 +1,7 @@ """Independent acceptance of Turn generation, rejection and source adoption.""" from copy import deepcopy +import hashlib import json import runpy import subprocess @@ -115,6 +116,19 @@ def test_contract_digest_ignores_checkout_line_endings(tmp_path, monkeypatch): generator.PYTHON_PATH: generator.PYTHON_PATH.read_text(encoding="utf-8"), generator.TYPESCRIPT_PATH: generator.TYPESCRIPT_PATH.read_text(encoding="utf-8"), } +def test_generation_has_one_content_identity_across_checkout_newlines(tmp_path, monkeypatch): + source = generator.CONTRACT_PATH.read_text(encoding="utf-8").encode("utf-8") + contract = tmp_path / "contract.json" + monkeypatch.setattr(generator, "CONTRACT_PATH", contract) + contract.write_bytes(source) + lf = generator.build_artifacts() + contract.write_bytes(source.replace(b"\n", b"\r\n")) + assert generator.build_artifacts() == lf + digest = hashlib.sha256(source).hexdigest() + assert all(f"SHA256: {digest}" in artifact for artifact in lf.values()) + # Newline normalization must not exempt other changes from provenance. + contract.write_bytes(source + b" ") + assert generator.build_artifacts() != lf def test_typescript_settlement_reexports_generated_result_set(): diff --git a/tests/cli_commands/test_quota_closeout_query_failure.py b/tests/cli_commands/test_quota_closeout_query_failure.py index b3992e3af4..f92a4a9da1 100644 --- a/tests/cli_commands/test_quota_closeout_query_failure.py +++ b/tests/cli_commands/test_quota_closeout_query_failure.py @@ -65,7 +65,7 @@ def decision(*a, **k): before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} result = command.handle_quota_command( args, registry_path=registry, runtime_root_arg=str(tmp_path), - print_payload=lambda payload, *_: output.append(json.loads(json.dumps(payload))), + print_payload=lambda payload, *_, **_kwargs: output.append(json.loads(json.dumps(payload))), append_cli_rollout_event=append, ) assert result == 1 diff --git a/tests/control_plane/checkpoint_process.py b/tests/control_plane/checkpoint_process.py index 16ef630268..e19841b5b9 100644 --- a/tests/control_plane/checkpoint_process.py +++ b/tests/control_plane/checkpoint_process.py @@ -32,7 +32,7 @@ def wait_for(path: Path, child=None, timeout=20): time.sleep(0.01) -def refresh(registry, runtime, token, *, goal_ref=None): +def refresh(registry, runtime, token, *, goal_ref=None, first_delivery=False): from loopx.state_refresh import refresh_state_run from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, TURN_ID return refresh_state_run(registry_path=Path(registry), runtime_root_override=str(runtime), @@ -41,8 +41,9 @@ def refresh(registry, runtime, token, *, goal_ref=None): delivery_batch_scale="implementation", delivery_outcome="outcome_progress", vision_unchanged_reason="The current basis remains applicable.", checkpoint_read_context_id=token, dry_run=False, sync_global=False, + **({"delivery_workspace_path": Path(registry).parent.parent} if first_delivery else {}), external_delivery={"suppress": True, "resume_key": None}, - goal_ref=goal_ref) + goal_ref=goal_ref, first_delivery=first_delivery) def main(request): diff --git a/tests/control_plane/host_checkpoint_fixture.py b/tests/control_plane/host_checkpoint_fixture.py new file mode 100644 index 0000000000..5d2e1c20d4 --- /dev/null +++ b/tests/control_plane/host_checkpoint_fixture.py @@ -0,0 +1,48 @@ +"""A deterministic current-Agent response after a real checkpoint tool read.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys + + +def checkpoint_decision(context): + basis = context["basis"] + assert context["read_context_id"] + assert basis["frontier"]["scope"] == "goal" + decision = {"read_context_id": context["read_context_id"], "agent_vision_json": "", + "vision_unchanged_reason": "", "stop_reason": ""} + if basis["agent_vision"]: + decision["vision_unchanged_reason"] = "The observed fixture obligations preserve the scoped route." + else: + decision["agent_vision_json"] = json.dumps({"schema_version": "goal_vision_replan_contract_v0", + "state": "vision_patch_proposed", "vision_patch": {"vision_summary": "Verify the scoped fixture.", + "acceptance_summary": "Fixture evidence has been verified."}, "path_delta": { + "schema_version": "goal_path_delta_v0", "outcome": "no_change", + "prior_assumption": "Scoped fixture route.", "observed_reality": "Fixture evidence verified.", + "retained": ["Scoped route"], "evidence_refs": ["evidence:fixture"]}}) + if decision["agent_vision_json"]: + decision["agent_vision"] = json.loads(decision.pop("agent_vision_json")) + return decision + + +def reply_to_checkpoint(request): + if request.get("phase") != "checkpoint_direction": + return False + json.dump(checkpoint_decision(request["checkpoint_context"]), sys.stdout) + return True + + +def checkpoint_fixture_script(script): + marker = 'from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint' + return script.replace(marker, f'sys.path.insert(0, {str(Path(__file__).resolve().parents[2])!r})\n' + marker) + + +def checkpoint_codex_fixture_response(request, runtime_root): + from loopx.control_plane.turn_driver.codex_sessions import _lineage, _store_codex_cli_session + if request.get("phase") == "checkpoint_direction": + assert request["session"]["action"] == "resume" + return checkpoint_decision(request["checkpoint_context"]) + _store_codex_cli_session(runtime_root, lineage=_lineage(request), session_scope="agent", + session_id="owned-fixture-session") + return None diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index bc29c39b72..5c17bf7fb6 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -92,9 +92,9 @@ def _replace_source_goal(registry: Path, instance_id: str) -> None: transaction.commit(payload) -def fixture(tmp_path, monkeypatch, provider): +def fixture(tmp_path, monkeypatch, provider, *, first_delivery=False): isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry, binding, delivery, original = _missing(tmp_path, provider=provider) + project, runtime, registry, binding, delivery, original = _missing(tmp_path, provider=provider, defer=first_delivery, historical=True) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" todos = [{"schema_version": "todo_item_v0", "todo_id": name, "index": index, "role": "agent", "status": "open", "done": False, "text": f"Synthetic page work {name}", @@ -111,7 +111,7 @@ def fixture(tmp_path, monkeypatch, provider): def read(): return context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, - dependency_todo_ids=["todo_dependency"]) + dependency_todo_ids=["todo_dependency"], purpose="first_delivery" if first_delivery else "supplement_checkpoint") return project, runtime, registry, state, read, original @@ -268,8 +268,9 @@ def test_existing_cli_maintenance_guard_precedes_provider_commit(tmp_path, monke @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, provider): - _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) +@pytest.mark.parametrize("first_delivery", [False, True]) +def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, record_property, provider, first_delivery): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=first_delivery) context = read() barrier = tmp_path / "barrier" barrier.mkdir() @@ -301,7 +302,9 @@ def native(method, params, **kwargs): assert saved["ok"] and not saved["replayed"] assert json.loads((barrier / "runtime-replay").read_text())["replayed"] finish(writer) - assert json.loads((barrier / "provider-result").read_text())["status"] == "applied" + provider_result = json.loads((barrier / "provider-result").read_text()) + assert provider_result["status"] == "applied" + record_property("provider_commit_elapsed_ms", provider_result["elapsed_ms"]) return saved finally: (barrier / "release").touch() @@ -311,7 +314,7 @@ def native(method, params, **kwargs): child.communicate() monkeypatch.setattr(context_io, "effect_runtime_result", native) - result = refresh(registry, runtime, context["read_context_id"]) + result = refresh(registry, runtime, context["read_context_id"], first_delivery=first_delivery) assert result["vision_checkpoint"]["satisfied"] and len(observed) == 1 assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID)["provider_revision"] != observed[0] rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] @@ -320,7 +323,8 @@ def native(method, params, **kwargs): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, monkeypatch, provider): +@pytest.mark.parametrize("first_delivery", [False, True]) +def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, monkeypatch, provider, first_delivery): """Provider contract: a transaction without the CLI's outer M protection. The reviewed implementation accepts this old checkpoint. The native save @@ -328,7 +332,7 @@ def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, mon hold M before committing; this test does not pretend to reproduce that path. """ from loopx import state_refresh - _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=first_delivery) token = read()["read_context_id"] index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" before = index.read_bytes() @@ -349,7 +353,7 @@ def race(*args): monkeypatch.setattr(state_refresh, "reserve_unique_run_paths", race) try: with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: - refresh(registry, runtime, token) + refresh(registry, runtime, token, first_delivery=first_delivery) assert rejected.value.code == "checkpoint_read_context_stale" assert index.read_bytes() == before finally: diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py index 3e21a89eec..06e35db0b6 100644 --- a/tests/control_plane/test_checkpoint_read_context.py +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -14,7 +14,7 @@ ) -def _missing(root: Path, *, provider: str = "sqlite"): +def _missing(root: Path, *, provider: str = "sqlite", defer=False, historical=False): project, runtime, registry = _write_fixture(root, provider=provider) binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) for args in ( @@ -23,11 +23,23 @@ def _missing(root: Path, *, provider: str = "sqlite"): "--no-global-sync", "--suppress-external-sinks"), ("quota", "should-run", "--codex-app", *binding, ), ): + if historical and args[0] == "quota": + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + + # Construct a pre-protection admission; never infer old behavior + # from today's default guard or edit an actual execution receipt. + ensure_turn_heartbeat_settlement_receipt(runtime, + SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID), + semantic_replan_guard_scoped=True, semantic_replan_obligation_id=None) + continue rc, result = _run_cli(registry, runtime, *args, cwd=project) assert rc == 0, result delivery = ("refresh-state", *binding, "--classification", "validated_change", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--no-global-sync", "--suppress-external-sinks") + if defer: + return project, runtime, registry, binding, delivery, None rc, original = _run_cli(registry, runtime, *delivery, cwd=project) assert rc == 0 and original["vision_checkpoint"]["decision"] == "missing_required", original return project, runtime, registry, binding, delivery, original @@ -46,6 +58,119 @@ def test_checkpoint_recovery_is_not_a_fresh_turn_preflight(tmp_path): assert _spend_run_count(runtime) == 0 +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_historical_explicit_first_delivery_cli_rejects_stale_and_replays_success(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, + "text": "Validate and settle the selected delivery.", "claimed_by": AGENT_ID, + "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo"} + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo]) + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + # Pin the original opt-in admission, rather than downgrading a new default Turn. + ensure_turn_heartbeat_settlement_receipt(runtime, + SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID), + semantic_replan_guard_scoped=True, semantic_replan_obligation_id=None) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, + "--scan-path", str(project), cwd=project) + assert rc == 0, json.dumps(guard) + read = ("checkpoint-context", *binding, "--purpose", "first_delivery") + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + delivery = ("refresh-state", *binding, "--first-delivery", "--classification", "validated_change", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--vision-summary", "Continue the scoped route.", "--vision-acceptance", "Checks pass; remaining work stays open.", + "--no-global-sync", "--suppress-external-sinks") + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + before = index.read_bytes() if index.exists() else b"" + rc, no_context = _run_cli(registry, runtime, *delivery, cwd=project) + assert rc == 1 and no_context["error_code"] == "checkpoint_read_context_required", no_context + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nVerify the additional output.\n", encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *delivery, "--checkpoint-read-context", context["read_context_id"], cwd=project) + assert rc == 1 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert "goal" in stale["checkpoint_read_context"]["changed_components"] + assert (index.read_bytes() if index.exists() else b"") == before + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + commit = (*delivery, "--checkpoint-read-context", context["read_context_id"]) + rc, saved = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and saved["vision_checkpoint"]["satisfied"], json.dumps(saved) + after = index.read_bytes() + state.write_text(state.read_text(encoding="utf-8") + "\nAcceptance changed after success.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + assert index.read_bytes() == after + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_delivery_result_basis_precedes_cas_and_replays_before_current_freshness(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, "claimed_by": AGENT_ID, + "text": "Validate and settle the selected delivery.", "task_class": "advancement_task", + "archive_state": "active", "source_section": "Agent Todo"} + peer = {**todo, "todo_id": "todo_unrelated_peer", "index": 2, "text": "Independent output."} + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo, peer], handoff_mode="soft_claim"), state_path=state, provider=provider) + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, + "--scan-path", str(project), cwd=project) + assert rc == 0, guard + read = ("checkpoint-context", *binding, "--purpose", "delivery_result") + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + complete = ("todo", "complete", *binding, "--note", "Validated the candidate; direction review remains.") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + def committed_sources(): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID), index.read_bytes() if index.exists() else None, _spend_run_count(runtime) + before = committed_sources() + rc, missing = _run_cli(registry, runtime, *complete, "--delivery-read-context", "unknown-read", cwd=project) + assert rc == 1 and missing.get("error_code") == "checkpoint_read_context_unknown_or_replaced", missing + assert committed_sources() == before + rc, unknown = _run_cli(registry, runtime, *complete, "--delivery-read-context", "unknown-context", cwd=project) + assert rc == 1 and unknown.get("error_code") == "checkpoint_read_context_unknown_or_replaced", unknown + assert committed_sources() == before + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nAdditional acceptance requirement.\n", encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *complete, "--delivery-read-context", context["read_context_id"], cwd=project) + assert rc == 1 and stale.get("error_code") == "checkpoint_read_context_stale", json.dumps(stale) + assert committed_sources() == before + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + commit = (*complete, "--delivery-read-context", context["read_context_id"]) + rc, peer_update = _run_cli(registry, runtime, "todo", "update", "--goal-id", GOAL_ID, + "--todo-id", "todo_unrelated_peer", "--agent-id", AGENT_ID, "--note", "Peer progress must survive.", cwd=project) + assert rc == 0, peer_update + rc, saved = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and saved["completed"], saved + rc, peer_readback = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID, + "--todo-id", "todo_unrelated_peer", cwd=project) + assert rc == 0 and peer_readback["todo"]["note"] == "Peer progress must survive.", peer_readback + state.write_text(state.read_text(encoding="utf-8") + "\nRequirement after successful result.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + rc, conflict = _run_cli(registry, runtime, *commit, "--note", "Different candidate intent.", cwd=project) + assert rc == 1, conflict + # The next direction reads the committed result, so its own open -> done + # change cannot invalidate the new basis. + rc, direction = _run_cli(registry, runtime, "checkpoint-context", *binding, "--purpose", "first_delivery", cwd=project) + assert rc == 0 and direction["basis"]["todo"]["status"] == "done", direction + + def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(tmp_path): project, runtime, registry, binding, delivery, original = _missing(tmp_path) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" @@ -137,7 +262,7 @@ def test_large_goal_context_keeps_complete_basis_and_checkpoint_only_recovery( "Updated acceptance context."), context.get("error") rc, result = _run_cli(registry, runtime, *supplement, "--checkpoint-read-context", context["read_context_id"], cwd=project) - assert rc == 0 and result["vision_checkpoint"]["satisfied"], result + assert rc == 0 and result["vision_checkpoint"]["satisfied"], json.dumps(result, indent=2) assert result["settlement_identity"] == original["settlement_identity"] assert _spend_run_count(runtime) == 0 @@ -192,7 +317,8 @@ def observe(method, request): registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, ) - assert calls == [("goal.checkpoint_read_context.resolve", "read")] + assert calls == [("goal.checkpoint_read_context.work_binding", "direction_policy"), + ("goal.checkpoint_read_context.resolve", "read")] identity = SettlementIdentity.from_runtime_payload(context["settlement_identity"]) calls.clear() @@ -201,7 +327,8 @@ def observe(method, request): identity=identity, read_context_id=context["read_context_id"], ) as checked: assert checked["ok"] - assert calls == [("goal.checkpoint_read_context.resolve", "check")] + assert calls == [("goal.checkpoint_read_context.work_binding", "direction_policy"), + ("goal.checkpoint_read_context.resolve", "check")] def test_source_writers_remain_excluded_until_checkpoint_append(tmp_path, monkeypatch): diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index dccc946edf..671e2c6018 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -332,6 +332,23 @@ def _quota_payload_without_rollout_receipt(text: str) -> dict[str, object]: return payload +def _admit_quota_comparison(command: list[str]) -> list[str]: + """Compare display selectors in one admitted Turn, not admission vs replay. + + First automatic admission is exercised separately on real providers. Do not + strip identity, receipt or binding fields from the comparisons below. + """ + command = command + ["--turn-instance-id", "detail-parity-turn"] + code, text = _invoke_cli(command) + payload = json.loads(text) + assert code == (0 if payload["ok"] else 1), text + assert payload["heartbeat_receipt"]["status"] == "committed", text + assert payload["heartbeat_receipt"]["turn_instance_id"] == "detail-parity-turn" + if not payload["status_health_ok"]: + assert payload["should_run"] is False and payload["effective_action"] == "quota_skip" + return command + + def _surface_commands( *, project: Path, @@ -449,7 +466,7 @@ def _surface_commands( } -def _measure_scenario(root: Path, scenario: Scenario) -> dict[str, dict[str, dict]]: +def _measure_scenario(root: Path, scenario: Scenario, *, surface_ids: tuple[str, ...] | None = None) -> dict[str, dict[str, dict]]: with _stable_budget_fixture_root(root) as stable_root: project, runtime, registry_path, state_file = _write_fixture( stable_root, @@ -466,6 +483,8 @@ def _measure_scenario(root: Path, scenario: Scenario) -> dict[str, dict[str, dic output_format=output_format, ) for surface_id, command in commands.items(): + if surface_ids is not None and surface_id not in surface_ids: + continue exit_code, text = _invoke_cli(command) assert exit_code == 0, (surface_id, output_format, text) measurement = measure_cli_output( # type: ignore[arg-type] @@ -488,6 +507,16 @@ def _measure_scenario(root: Path, scenario: Scenario) -> dict[str, dict[str, dic return results +def test_default_quota_work_basis_preserves_output_contract(tmp_path: Path) -> None: + # Same full populations and command path as the complete interface matrix. + for scenario in SCENARIOS: + measured = _measure_scenario(tmp_path / scenario.name, scenario, surface_ids=("quota_should_run",)) + payload = measured["quota_should_run"]["json"]["payload"] + assert payload["heartbeat_receipt"]["turn_instance_id"] + if scenario.name == "small": + assert payload["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture_000" + + def _assert_turn_plan_writeback_routes( measurement: dict, registry_path: Path, runtime: Path ) -> None: @@ -1043,6 +1072,9 @@ def test_quota_cli_keeps_full_agent_todo_diagnostics_on_explicit_cold_path( output_format="json", )["quota_should_run_todo_summary_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1147,6 +1179,9 @@ def test_quota_cli_keeps_complete_vision_rules_with_reachable_duplicate_refs( output_format="json", )["quota_should_run_vision_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1258,9 +1293,16 @@ def test_crowded_turn_plan_budget_preserves_executable_vision_authoring( assert writeback["vision_authoring"]["schema_version"] == ( "goal_vision_replan_contract_v0" ) + # The full authoring contract must survive compact JSON transport. Its size + # is not a lower bound on whitespace in the emitted representation. + assert writeback["vision_authoring"]["fields"]["vision_patch"]["acceptance_summary"] > 0 + delta = writeback["vision_authoring"]["path_delta"] + assert {"continue", "replan", "no_change"} <= set(delta["outcomes"]) + assert {"outcome", "prior_assumption", "observed_reality"} <= set(delta["required"]) + assert delta["list_limits"]["evidence_refs"]["max_items"] > 0 # This fixed executable schema legitimately crosses the old 12k/320 # ceiling; retain bounded headroom without relaxing Todo-scale growth. - assert 12_000 < len(text) <= CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"].max_chars["crowded"]["json"] + assert len(text) <= CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"].max_chars["crowded"]["json"] assert len(text.splitlines()) <= CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"].max_lines["crowded"]["json"] @@ -1320,6 +1362,9 @@ def test_quota_cli_keeps_full_user_todo_diagnostics_on_explicit_cold_path( output_format="json", )["quota_should_run_user_todo_summary_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1376,6 +1421,9 @@ def test_quota_cli_keeps_goal_boundary_authority_on_explicit_cold_path( output_format="json", )["quota_should_run_goal_boundary_detail"] + default_command = _admit_quota_comparison(default_command) + detail_command += ["--turn-instance-id", "detail-parity-turn"] + default_exit_code, default_text = _invoke_cli(default_command) detail_exit_code, detail_text = _invoke_cli(detail_command) @@ -1434,6 +1482,7 @@ def test_quota_cli_deprecated_scheduler_detail_flag_matches_canonical_selector( state_file=state_file, output_format="json", )["quota_should_run"] + default_command = _admit_quota_comparison(default_command) canonical_exit_code, canonical_text = _invoke_cli( default_command + ["--include-detail", "scheduler"] ) @@ -1472,6 +1521,7 @@ def test_quota_cli_all_detail_matches_repeated_canonical_selectors( state_file=state_file, output_format="json", )["quota_should_run"] + default_command = _admit_quota_comparison(default_command) repeated_command = list(default_command) for section in ("scheduler", "agent-todos", "user-todos", "goal-boundary"): repeated_command.extend(["--include-detail", section]) @@ -1554,6 +1604,8 @@ def test_status_and_quota_json_ignore_compatibility_reexport_bindings( output_format="json", ) + commands["quota_should_run"] = _admit_quota_comparison(commands["quota_should_run"]) + def semantic_receipts() -> dict[str, dict[str, object]]: receipts: dict[str, dict[str, object]] = {} for surface_id in ("status", "quota_should_run"): diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index 437fb554fb..52b6ac24ce 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -2,12 +2,14 @@ from __future__ import annotations import json +import os import shlex import subprocess import sys from hashlib import sha256 from pathlib import Path from typing import Any +from urllib.parse import quote import pytest import test_quota_settlement_cli as cli @@ -16,10 +18,11 @@ from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes from loopx.control_plane.quota.settlement import read_heartbeat_settlement +from host_checkpoint_fixture import checkpoint_decision from loopx.control_plane.todos.markdown import render_todo_markdown -def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, historical_terminals: int = 0): +def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, historical_terminals: int = 0, renew_during_validation: bool = False): # Scope managed Effect discovery as well as provider data for both arms. isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry_path = cli._write_fixture(tmp_path) @@ -45,6 +48,10 @@ def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, registry = json.loads(registry_path.read_text()) registry["goals"][0]["coordination"]["write_scope"] = ["src/**"] registry_path.write_text(json.dumps(registry)) + if provider == "legacy": + state.write_text(state.read_text().replace( + "status: active-read-only", "handoff_mode: hard_lease\nstatus: active-read-only", 1, + )) cli._initialize_git_checkout(project) # The private registry must not be tracked or made push-allowed just to # admit the synthetic Goal. Only the public-safe state fixture is committed. @@ -60,18 +67,19 @@ def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, def run(*args: str) -> tuple[int, dict[str, Any]]: return cli._run_cli(registry_path, runtime, *args, cwd=workspace) - projection = build_todo_runtime_shadow_projection( - goal_id=cli.GOAL_ID, todos=read_fixture_todos(state), handoff_mode="hard_lease", leases=[], - ) - # Actor audit is canonical-only; legacy Markdown import cannot author it. - historical = [row for row in projection["todos"] if row["todo_id"].startswith("todo_historical_")] - assert len(historical) == historical_terminals - for row in historical: - row["last_actor_agent_id"] = cli.AGENT_ID - projection["todo_read_model"]["records_sha256"] = sha256(canonical_bytes(projection["todos"])).hexdigest() - initialize_canonical_authority( - runtime, cli.GOAL_ID, projection, state_path=state, provider=provider, - ) + if provider != "legacy": + projection = build_todo_runtime_shadow_projection( + goal_id=cli.GOAL_ID, todos=read_fixture_todos(state), handoff_mode="hard_lease", leases=[], + ) + # Actor audit is canonical-only; legacy Markdown import cannot author it. + historical = [row for row in projection["todos"] if row["todo_id"].startswith("todo_historical_")] + assert len(historical) == historical_terminals + for row in historical: + row["last_actor_agent_id"] = cli.AGENT_ID + projection["todo_read_model"]["records_sha256"] = sha256(canonical_bytes(projection["todos"])).hexdigest() + initialize_canonical_authority( + runtime, cli.GOAL_ID, projection, state_path=state, provider=provider, + ) code, acquired = run( "task-lease", "acquire", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, "--owner", cli.AGENT_ID, "--idempotency-key", "declared-terminal-fixture", @@ -81,19 +89,36 @@ def run(*args: str) -> tuple[int, dict[str, Any]]: lease_args = ("--task-lease-idempotency-key", acquired["lease"]["idempotency_key"], "--task-lease-expected-version", str(acquired["lease"]["version"])) marker, fail = tmp_path / "validator-runs", tmp_path / "validator-fails" + renew_script = "" + if renew_during_validation: + renew_argv = [sys.executable, "-m", "loopx.cli", "--format", "json", + "--registry", str(registry_path), "--runtime-root", str(runtime), + "task-lease", "renew", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, + "--owner", cli.AGENT_ID, "--idempotency-key", acquired["lease"]["idempotency_key"], + "--expected-version", str(acquired["lease"]["version"]), "--ttl-seconds", "600"] + renew_script = f"import subprocess; from pathlib import Path; r=subprocess.run({renew_argv!r}, cwd={str(workspace)!r}, capture_output=True, text=True); Path({str(tmp_path / 'renew-output')!r}).write_text(r.stdout+r.stderr); assert r.returncode == 0; " argv = [sys.executable, "-c", ( - "from pathlib import Path; " + renew_script + "from pathlib import Path; " f"p=Path({str(marker)!r}); p.write_text((p.read_text() if p.exists() else '')+'run\\n'); " f"raise SystemExit(4 if Path({str(fail)!r}).exists() else 0)" )] - code, bound = run( - "todo", "update", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, - "--agent-id", cli.AGENT_ID, "--validation-command-json", json.dumps(argv), - "--validation-label", "Original declared deliverable check", *lease_args, - "--update-operation-id", "original-declaration-fixture", - "--update-expected-provider-revision", acquired["provider_revision"], - ) - assert code == 0, bound.get("error") or bound + if provider == "legacy": + # Establish the original declaration before Turn admission. Legacy + # Markdown has no canonical revision transaction to bind afterwards. + encoded = quote(json.dumps(argv, separators=(",", ":")), safe="") + state.write_text(state.read_text().replace( + "required_write_scopes=src/**", + "required_write_scopes=src/** validation_command_argv=" + encoded, + )) + else: + code, bound = run( + "todo", "update", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, + "--agent-id", cli.AGENT_ID, "--validation-command-json", json.dumps(argv), + "--validation-label", "Original declared deliverable check", *lease_args, + "--update-operation-id", "original-declaration-fixture", + "--update-expected-provider-revision", acquired["provider_revision"], + ) + assert code == 0, bound.get("error") or bound code, original = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) assert code == 0, original return project, workspace, runtime, registry_path, run, lease_args, marker, fail, original["todo"] @@ -113,7 +138,10 @@ def test_declared_leased_completion_writeback_spend_terminal_and_replay( *binding, "--available-capability", "shell", "--available-capability", "filesystem_write") code, guard = run(*guard_args) - assert code == 0, guard.get("error") or guard.get("reason") or guard + assert code == 0, guard.get("error") or guard.get("reason") + # Preserve the original pre-default settlement contract and all assertions. + # Default RESULT/direction ordering has its own regression below. + cli._historical_turn(runtime) or guard assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == cli.TODO_ID complete_args = ("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, "--project", str(project), "--state-file", str(project / f".codex/goals/{cli.GOAL_ID}/ACTIVE_GOAL_STATE.md"), @@ -220,7 +248,7 @@ def test_declared_leased_completion_writeback_spend_terminal_and_replay( assert settled["effective_action"] == "heartbeat_settled_skip" -@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) def test_original_lease_and_failed_validator_remain_required_before_accounting( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, ) -> None: @@ -243,7 +271,8 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( code, state = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) assert code == 0, state assert state["todo"]["status"] == "open" - assert state["todo"]["completion_validation_sha256"] == original["completion_validation_sha256"] + assert state["todo"].get("completion_validation_sha256") == original.get("completion_validation_sha256") + assert state["todo"].get("validation_command_argv") == original.get("validation_command_argv") code, no_spend = run("quota", "spend-slot", "--goal-id", cli.GOAL_ID, *binding, "--source", "heartbeat", "--slots", "1", "--execute", ) assert code == 1, no_spend @@ -251,11 +280,406 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( fail.unlink() code, accepted = run(*command, *lease) assert code == 0, accepted - assert accepted["validation_receipt"]["passed"] is True - assert accepted["validation_receipt"]["validation_declaration_sha256"] == original["completion_validation_sha256"] + assert accepted["completed"] is True + if provider != "legacy": + assert accepted["validation_receipt"]["passed"] is True + if provider != "legacy": + assert accepted["validation_receipt"]["validation_declaration_sha256"] == original["completion_validation_sha256"] assert marker.read_text() == "run\nrun\n", "one failed attempt, then one actual successful check" assert cli._spend_run_count(runtime) == 0 + code, replay = run(*command, *lease) + assert code == 0, replay + assert marker.read_text() == "run\nrun\n", "terminal replay must not repeat validation" + assert cli._spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_lease_change_during_validator_still_blocks_completion( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + project, _, runtime, _, run, lease, marker, _, _ = _fixture( + tmp_path, monkeypatch, provider, renew_during_validation=True, + ) + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + code, guard = run("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, + "--scan-path", str(project), *binding) + assert code == 0, guard + code, rejected = run("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, + "--evidence", "Validator renews the lease before final completion.") + assert code == 1, rejected + assert marker.exists(), rejected + assert marker.read_text() == "run\n", "validation ran outside the short lease fence" + code, state = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) + assert code == 0, state + assert state["todo"]["status"] == "open", "commit must reject the changed lease version" + assert cli._spend_run_count(runtime) == 0 + +@pytest.mark.parametrize('provider',['file','sqlite']) +@pytest.mark.parametrize('closeout_source',['turn','lifecycle']) +def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monkeypatch,provider,closeout_source): + project,workspace,runtime,registry,run,lease,marker,_,original=_fixture(tmp_path,monkeypatch,provider) + binding=('--agent-id',cli.AGENT_ID,'--todo-id',cli.TODO_ID,'--turn-instance-id',cli.TURN_ID) + code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,'--scan-path',str(project),*binding, + '--available-capability','shell','--available-capability','filesystem_write') + assert code==0,guard + identity=guard['heartbeat_receipt']['settlement_identity'] + complete=('todo','complete','--goal-id',cli.GOAL_ID,*binding,*lease,'--no-follow-up', + '--evidence','Original declared deliverable, then current post-result direction.') + code,result=run(*complete) + assert code==0,result + assert result['completed'] is True and result['completion_continuation']=='active_goal',result + assert marker.read_text()=='run\n','RESULT must run exactly one original validator' + assert cli._spend_run_count(runtime)==0 + code,listed=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,listed + saved=listed['todo'] + assert saved['status']=='done' and saved['completion_continuation']=='active_goal' + assert saved['completion_requested_continuation']=='no_followup',saved + assert not saved.get('successor_todo_ids') + assert saved.get('completion_validation_sha256')==original.get('completion_validation_sha256') + assert saved['completion_turn_key']==identity['effect_id'],saved + assert result['settlement_identity']==identity,result + assert result['completion_identity_key']==identity['effect_id'] + # A fresh original request must recover RESULT without revalidation. + code,replay=run(*complete) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' + lifecycle=('todo','complete','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID,'--agent-id',cli.AGENT_ID, + '--completion-identity-key',identity['effect_id'],'--no-follow-up','--evidence',complete[-1]) + code,early=run(*lifecycle) + assert code==1,early + code,after=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and after['todo']==saved,after + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 + # New direction is authored only after the CLI returns actual committed RESULT. + code,context=run('checkpoint-context','--goal-id',cli.GOAL_ID,*binding,'--purpose','first_delivery') + assert code==0,context + assert context['settlement_identity']==identity and context['basis']['todo']['status']=='done' + decision=checkpoint_decision(context) + direction=('--checkpoint-read-context',context['read_context_id']) + if decision.get('agent_vision'): + vision=tmp_path/'direction.json' + vision.write_text(json.dumps(decision['agent_vision'])) + direction+=('--agent-vision-json',str(vision)) + else: + direction+=('--vision-unchanged-reason',decision['vision_unchanged_reason']) + refresh=('refresh-state','--goal-id',cli.GOAL_ID,*binding,*direction, + '--classification','declared_deliverable_complete','--delivery-batch-scale','implementation', + '--delivery-outcome','outcome_progress','--no-global-sync','--suppress-external-sinks') + code,refreshed=run(*refresh) + assert code==0,refreshed + spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute', + '--scan-path',str(project)) + code,spent=run(*spend) + assert code==0,spent + assert cli._spend_run_count(runtime)==1 + code,before_reentry=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,before_reentry + for field,replacement in (('--completion-identity-key',identity['effect_id']+'-wrong'), + ('--evidence','Replacement evidence must be rejected.'), + ('--agent-id','unregistered-cold-actor')): + rejected=list(lifecycle) + rejected[rejected.index(field)+1]=replacement + code,failure=run(*rejected) + assert code==1,failure + code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and unchanged['todo']==before_reentry['todo'],unchanged + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + terminal=complete if closeout_source=='turn' else lifecycle + code,closed=run(*terminal) + assert code==0 and closed['completion_continuation']=='no_followup',closed + for args in (complete,refresh,spend,terminal): + code,replay=run(*args) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + code,final=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and final['todo']['completion_continuation']=='no_followup',final + assert final['todo'].get('completion_validation_sha256')==original.get('completion_validation_sha256') + + # A retry with changed accepted work must not borrow the historical RESULT. + for field,replacement in (('--evidence','Changed accepted evidence'), + ('--task-lease-expected-version','999')): + rejected=list(complete) + rejected[rejected.index(field)+1]=replacement + code,failure=run(*rejected) + assert code==1,failure + code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and unchanged['todo']==final['todo'],unchanged + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('fault', ['rename', 'crash']) +def test_declared_validation_survives_pre_result_failure(tmp_path, monkeypatch, provider, fault): + project, workspace, runtime, registry, run, lease, marker, _, original = _fixture(tmp_path, monkeypatch, provider) + binding = ('--agent-id', cli.AGENT_ID, '--todo-id', cli.TODO_ID, '--turn-instance-id', cli.TURN_ID) + code, guard = run('quota', 'should-run', '--codex-app', '--goal-id', cli.GOAL_ID, '--scan-path', str(project), *binding, + '--available-capability', 'shell', '--available-capability', 'filesystem_write') + assert code == 0, guard + complete = ('todo', 'complete', '--goal-id', cli.GOAL_ID, *binding, *lease, '--no-follow-up', '--evidence', 'Original candidate and original validation effect.') + launcher = tmp_path / 'fault_cli.py' + fault_seen = tmp_path / 'fault-seen' + launcher.write_text('import os, sys\nfrom pathlib import Path\n' + + f'marker = Path({str(marker)!r})\nseen = Path({str(fault_seen)!r})\n' + + ('original = os.replace\ndef replace(source, target, *args, **kwargs):\n' + ' if Path(target).name == "index.jsonl.lock.holder.json" and marker.exists() and not seen.exists():\n' + ' seen.write_text("post-validation holder publication rejected")\n' + ' raise PermissionError(13, "Injected original Windows holder publication failure", str(target))\n' + ' return original(source, target, *args, **kwargs)\nos.replace = replace\n' if fault == 'rename' else + 'import loopx.control_plane.todos.completion_validation as validation\n' + 'original = validation.run_declared_completion_validation_effect\n' + 'def run_effect(**kwargs):\n' + ' result = original(**kwargs)\n' + ' if not seen.exists():\n' + ' seen.write_text("process lost after validator effect before outcome publication")\n' + ' os._exit(93)\n' + ' return result\nvalidation.run_declared_completion_validation_effect = run_effect\n') + + 'from loopx.cli import main\nraise SystemExit(main(sys.argv[1:]))\n', encoding='utf-8') + argv = [sys.executable, str(launcher), '--registry', str(registry), '--runtime-root', str(runtime), '--format', 'json', *complete] + lost = subprocess.run(argv, cwd=workspace, capture_output=True, text=True, env={**os.environ, 'PYTHONPATH': str(cli.REPO_ROOT)}, timeout=90) + assert lost.returncode != 0 and fault_seen.exists(), (lost.stdout, lost.stderr) + assert marker.read_text() == 'run\n' + code, before = run('todo', 'list', '--goal-id', cli.GOAL_ID, '--todo-id', cli.TODO_ID) + assert code == 0 and not before['todo']['done'], before + assert before['todo']['completion_validation_sha256'] == original['completion_validation_sha256'] + assert cli._spend_run_count(runtime) == 0 + if fault == 'rename': + for flag, replacement in (('--evidence', 'Replaced candidate.'), ('--agent-id', 'unregistered-agent'), ('--task-lease-expected-version', '999')): + changed = list(complete) + changed[changed.index(flag) + 1] = replacement + code, rejection = run(*changed) + assert code == 1, rejection + assert marker.read_text() == 'run\n', 'Rejected replacement must not execute another validator' + code, unchanged = run('todo', 'list', '--goal-id', cli.GOAL_ID, '--todo-id', cli.TODO_ID) + assert code == 0 and unchanged['todo'] == before['todo'], unchanged + code, restored = run(*complete) + assert marker.read_text() == 'run\n', 'Original validation must never execute twice after lost RESULT' + if fault == 'rename': + assert code == 0 and restored['completed'] is True, restored + assert restored['completion_continuation'] == 'active_goal' + assert restored['validation_receipt']['passed'] is True + assert restored['validation_receipt']['validation_declaration_sha256'] == original['completion_validation_sha256'] + assert restored['completion_identity_key'] == guard['heartbeat_receipt']['settlement_identity']['effect_id'] + code, replay = run(*complete) + assert code == 0 and replay['idempotent_replay'] is True, replay + else: + assert code == 1, restored + assert 'delivery_validation_outcome_unknown' in json.dumps(restored), restored + code, replaced_read = run('checkpoint-context', '--goal-id', cli.GOAL_ID, *binding, '--purpose', 'delivery_result') + assert code == 1 and 'delivery_validation_outcome_unknown' in json.dumps(replaced_read), replaced_read + code, after = run('todo', 'list', '--goal-id', cli.GOAL_ID, '--todo-id', cli.TODO_ID) + assert code == 0 and after['todo'] == before['todo'], after + assert marker.read_text() == 'run\n' and cli._spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider',['legacy']) +@pytest.mark.parametrize('closeout_source',['turn','lifecycle']) +def test_legacy_default_terminal_recovers_original_result(tmp_path,monkeypatch,provider,closeout_source): + project,workspace,runtime,registry,run,lease,marker,_,original=_fixture(tmp_path,monkeypatch,provider) + binding=('--agent-id',cli.AGENT_ID,'--todo-id',cli.TODO_ID,'--turn-instance-id',cli.TURN_ID) + code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,'--scan-path',str(project),*binding, + '--available-capability','shell','--available-capability','filesystem_write') + assert code==0,guard + identity=guard['heartbeat_receipt']['settlement_identity'] + complete=('todo','complete','--goal-id',cli.GOAL_ID,*binding,*lease,'--no-follow-up', + '--evidence','Original declared deliverable, then current post-result direction.') + code,result=run(*complete) + assert code==0,result + code,result_lease=run('task-lease','inspect','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and result_lease['lease']['status']=='released' and result_lease['lease']['version']==int(lease[-1]) and result_lease['lease']['idempotency_key']==lease[1] and result_lease['lease']['lease_epoch']==1,result_lease + assert result['completed'] is True and result['completion_continuation']=='active_goal',result + assert marker.read_text()=='run\n','RESULT must run exactly one original validator' + assert cli._spend_run_count(runtime)==0 + code,listed=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,listed + saved=listed['todo'] + assert saved['status']=='done' and saved['completion_continuation']=='active_goal' + assert saved['completion_requested_continuation']=='no_followup',saved + assert not saved.get('successor_todo_ids') + assert saved.get('completion_validation_sha256')==original.get('completion_validation_sha256') + assert saved['completion_turn_key']==identity['effect_id'],saved + assert result['settlement_identity']==identity,result + if provider!='legacy': + assert result['completion_identity_key']==identity['effect_id'] + # A fresh original request must recover RESULT without revalidation. + code,replay=run(*complete) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' + lifecycle=('todo','complete','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID,'--agent-id',cli.AGENT_ID, + '--completion-identity-key',identity['effect_id'],'--no-follow-up','--evidence',complete[-1]) + code,early=run(*lifecycle) + if provider=='legacy' and code==0: + # A historical RESULT replay is legal; it must neither represent nor + # mutate a terminal closeout. The frozen condition is no early close. + assert early['idempotent_replay'] is True and early['changed'] is False,early + assert early['completion_continuation']=='active_goal',early + else: + assert code==1,early + code,after=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and after['todo']==saved,after + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 + # New direction is authored only after the CLI returns actual committed RESULT. + code,context=run('checkpoint-context','--goal-id',cli.GOAL_ID,*binding,'--purpose','first_delivery') + assert code==0,context + assert context['settlement_identity']==identity and context['basis']['todo']['status']=='done' + decision=checkpoint_decision(context) + direction=('--checkpoint-read-context',context['read_context_id']) + if decision.get('agent_vision'): + vision=tmp_path/'direction.json' + vision.write_text(json.dumps(decision['agent_vision'])) + direction+=('--agent-vision-json',str(vision)) + else: + direction+=('--vision-unchanged-reason',decision['vision_unchanged_reason']) + refresh=('refresh-state','--goal-id',cli.GOAL_ID,*binding,*direction, + '--classification','declared_deliverable_complete','--delivery-batch-scale','implementation', + '--delivery-outcome','outcome_progress','--no-global-sync','--suppress-external-sinks') + code,refreshed=run(*refresh) + assert code==0,refreshed + spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute', + '--scan-path',str(project)) + code,spent=run(*spend) + assert code==0,spent + assert cli._spend_run_count(runtime)==1 + code,before_reentry=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,before_reentry + for field,replacement in (('--completion-identity-key',identity['effect_id']+'-wrong'), + ('--evidence','Replacement evidence must be rejected.'), + ('--agent-id','unregistered-cold-actor')): + rejected=list(lifecycle) + rejected[rejected.index(field)+1]=replacement + code,failure=run(*rejected) + assert code==1,failure + code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and unchanged['todo']==before_reentry['todo'],unchanged + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + terminal=complete if closeout_source=='turn' else lifecycle + code,closed=run(*terminal) + assert code==0 and closed['completion_continuation']=='no_followup',closed + for args in (complete,refresh,spend,terminal): + code,replay=run(*args) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + code,final=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and final['todo']['completion_continuation']=='no_followup',final + code, retired=run('task-lease','inspect','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and retired['lease']['status']=='released' and retired['lease']['version']==int(lease[-1]),retired + assert retired['lease']['idempotency_key']==lease[1] and retired['lease']['lease_epoch']==1,retired + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1,'Original validator and spend stay exactly once' + assert final['todo'].get('completion_validation_sha256')==original.get('completion_validation_sha256') + + + +@pytest.mark.parametrize('provider',['legacy']) +@pytest.mark.parametrize('fault',['before_release','after_release']) +@pytest.mark.parametrize('closeout_source',['turn','lifecycle']) +def test_legacy_committed_result_survives_release_process_loss(tmp_path,monkeypatch,provider,closeout_source,fault): + project,workspace,runtime,registry,run,lease,marker,_,original=_fixture(tmp_path,monkeypatch,provider) + actual_run=run + injected=False + launcher=tmp_path/'lost_legacy_cli.py' + seen=tmp_path/'fault-seen' + launcher.write_text('import os,sys\nfrom pathlib import Path\nimport loopx.control_plane.todos.legacy_mutation as legacy\noriginal=legacy.release_verified_task_lease_fence\ndef release(fence,**kwargs):\n' + + (' original(fence,**kwargs)\n' if fault=='after_release' else '') + + ' if kwargs.get("committed"):\n Path('+repr(str(seen))+').write_text("Todo committed, process lost '+fault+'")\n os._exit(93)\n' + + (' original(fence,**kwargs)\n' if fault=='before_release' else '') + + 'legacy.release_verified_task_lease_fence=release\nfrom loopx.cli import main\nraise SystemExit(main(sys.argv[1:]))\n') + def run(*args): + nonlocal injected + if not injected and args[:2]==('todo','complete'): + injected=True + lost=subprocess.run([sys.executable,str(launcher),'--registry',str(registry),'--runtime-root',str(runtime),'--format','json',*args],cwd=workspace,capture_output=True,text=True,env={**os.environ,'PYTHONPATH':str(cli.REPO_ROOT)},timeout=90) + assert lost.returncode==93 and seen.exists(),(lost.stdout,lost.stderr) + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 + return actual_run(*args) + binding=('--agent-id',cli.AGENT_ID,'--todo-id',cli.TODO_ID,'--turn-instance-id',cli.TURN_ID) + code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,'--scan-path',str(project),*binding, + '--available-capability','shell','--available-capability','filesystem_write') + assert code==0,guard + identity=guard['heartbeat_receipt']['settlement_identity'] + complete=('todo','complete','--goal-id',cli.GOAL_ID,*binding,*lease,'--no-follow-up', + '--evidence','Original declared deliverable, then current post-result direction.') + code,result=run(*complete) + assert code==0,result + assert result['completed'] is True and result['completion_continuation']=='active_goal',result + assert marker.read_text()=='run\n','RESULT must run exactly one original validator' + assert cli._spend_run_count(runtime)==0 + code,listed=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,listed + saved=listed['todo'] + assert saved['status']=='done' and saved['completion_continuation']=='active_goal' + assert saved['completion_requested_continuation']=='no_followup',saved + assert not saved.get('successor_todo_ids') + assert saved.get('completion_validation_sha256')==original.get('completion_validation_sha256') + assert saved['completion_turn_key']==identity['effect_id'],saved + assert result['settlement_identity']==identity,result + if provider!='legacy': + assert result['completion_identity_key']==identity['effect_id'] + # A fresh original request must recover RESULT without revalidation. + code,replay=run(*complete) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' + lifecycle=('todo','complete','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID,'--agent-id',cli.AGENT_ID, + '--completion-identity-key',identity['effect_id'],'--no-follow-up','--evidence',complete[-1]) + code,early=run(*lifecycle) + if provider=='legacy' and code==0: + # A historical RESULT replay is legal; it must neither represent nor + # mutate a terminal closeout. The frozen condition is no early close. + assert early['idempotent_replay'] is True and early['changed'] is False,early + assert early['completion_continuation']=='active_goal',early + else: + assert code==1,early + code,after=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and after['todo']==saved,after + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 + # New direction is authored only after the CLI returns actual committed RESULT. + code,context=run('checkpoint-context','--goal-id',cli.GOAL_ID,*binding,'--purpose','first_delivery') + assert code==0,context + assert context['settlement_identity']==identity and context['basis']['todo']['status']=='done' + decision=checkpoint_decision(context) + direction=('--checkpoint-read-context',context['read_context_id']) + if decision.get('agent_vision'): + vision=tmp_path/'direction.json' + vision.write_text(json.dumps(decision['agent_vision'])) + direction+=('--agent-vision-json',str(vision)) + else: + direction+=('--vision-unchanged-reason',decision['vision_unchanged_reason']) + refresh=('refresh-state','--goal-id',cli.GOAL_ID,*binding,*direction, + '--classification','declared_deliverable_complete','--delivery-batch-scale','implementation', + '--delivery-outcome','outcome_progress','--no-global-sync','--suppress-external-sinks') + code,refreshed=run(*refresh) + assert code==0,refreshed + spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute', + '--scan-path',str(project)) + code,spent=run(*spend) + assert code==0,spent + assert cli._spend_run_count(runtime)==1 + code,before_reentry=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0,before_reentry + for field,replacement in (('--completion-identity-key',identity['effect_id']+'-wrong'), + ('--evidence','Replacement evidence must be rejected.'), + ('--agent-id','unregistered-cold-actor')): + rejected=list(lifecycle) + rejected[rejected.index(field)+1]=replacement + code,failure=run(*rejected) + assert code==1,failure + code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and unchanged['todo']==before_reentry['todo'],unchanged + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + terminal=complete if closeout_source=='turn' else lifecycle + code,closed=run(*terminal) + assert code==0 and closed['completion_continuation']=='no_followup',closed + for args in (complete,refresh,spend,terminal): + code,replay=run(*args) + assert code==0 and replay['idempotent_replay'] is True,replay + assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 + code,final=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and final['todo']['completion_continuation']=='no_followup',final + code,retired=run('task-lease','inspect','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) + assert code==0 and retired['lease']['status']=='released' and retired['lease']['idempotency_key']==lease[1] and retired['lease']['version']==int(lease[-1]) and retired['lease']['lease_epoch']==1,retired + assert final['todo'].get('completion_validation_sha256')==original.get('completion_validation_sha256') + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("historical_terminals", [0, 1, 4]) diff --git a/tests/control_plane/test_default_checkpoint_continuation.py b/tests/control_plane/test_default_checkpoint_continuation.py new file mode 100644 index 0000000000..28095ad404 --- /dev/null +++ b/tests/control_plane/test_default_checkpoint_continuation.py @@ -0,0 +1,301 @@ +"""Default post-result checkpoints through real MCP/CLI and owning stores.""" +from __future__ import annotations + +import json +import sys +import subprocess +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.status import parse_active_state_todos +from loopx.todos import add_goal_todo, update_goal_todo +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.control_plane.turn_driver.checkpoint_continuation import CurrentAgentCheckpoint +from loopx.control_plane.goals.checkpoint_context_io import _receipt_path +from loopx.control_plane.turn_driver.host_failure import BuiltInHostError +from tests.test_goal_mode_mcp_settlement import _write_fixture, _control, GOAL_ID, AGENT_ID, _run_cli + + +def _fixture(provider, tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = _write_fixture(tmp_path) + doc = json.loads(registry.read_text()) + doc['goals'][0]['adapter'] = {'kind': 'fixture_v0', 'status': 'connected-delivery'} + registry.write_text(json.dumps(doc), encoding='utf-8') + rc, baseline = _run_cli(registry, 'refresh-state', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, + '--vision-summary', 'Verify the original route.', '--vision-acceptance', 'Verified candidate.', + '--no-global-sync', '--suppress-external-sinks') + assert rc == 0, baseline + todo = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text='Verify one candidate.', task_class='advancement_task', claimed_by=AGENT_ID, + continuation_policy='same_agent_non_delivery') + if provider != 'legacy': + todos = parse_active_state_todos(state.read_text(encoding='utf-8'))['agent_todos']['items'] + initialize_canonical_authority(tmp_path / 'runtime', GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode='soft_claim'), + state_path=state, provider=provider) + return registry, state, todo['todo_id'] + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('terminal', [False, True, 'stored', 'cold_explicit']) +def test_default_mcp_uses_post_result_basis_and_original_turn(provider, terminal, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + if terminal == 'stored': + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=todo, role='agent', + agent_id=AGENT_ID, no_followup=True) + control = _control(registry) + guard = json.loads(control.should_run()) + assert 'delivery_result_context' in guard, json.dumps(guard, indent=2) + original = guard['delivery_result_context']['settlement_identity'] + options = {} if terminal == 'stored' else {'no_follow_up': True} if terminal else {'next_agent_todo': 'Verify remaining work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first.get('stage') == 'direction_pending', json.dumps(first, indent=2) + assert first['completed'] is True and first['settlement_complete'] is False + assert first['settlement_identity'] == original + context = first['context'] + assert context['basis']['todo']['status'] == 'done' + assert context['basis']['frontier']['scope'] == 'goal' + # The tool caller authors only after seeing the returned post-result facts. + second = json.loads(_control(registry).complete_task(todo, AGENT_ID, 'Candidate verified.', + **({} if terminal == 'cold_explicit' else options), + read_context_id=context['read_context_id'], + vision_unchanged_reason='The committed candidate and current obligations preserve the route.')) + assert second.get('ok') is True and second.get('settlement_complete') is True, json.dumps(second, indent=2) + assert second['settlement_identity'] == original + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +def test_default_mcp_rejects_stale_direction_preserves_result(provider, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + options = {'next_agent_todo': 'Verify remaining work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first.get('stage') == 'direction_pending', first + old = first['context']['read_context_id'] + state.write_text(state.read_text(encoding='utf-8') + '\n## Acceptance\nCheck the revised obligation.\n', encoding='utf-8') + stale = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=old, vision_unchanged_reason='Old acceptance still applies.')) + assert stale.get('ok') is False, stale + reread = json.loads(control.review_task_vision(todo, AGENT_ID)) + assert reread['read_context_id'] != old, reread + assert 'revised obligation' in json.dumps(reread['basis']) + resumed = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=reread['read_context_id'], vision_unchanged_reason='The revised obligation is the next step.')) + assert resumed.get('settlement_complete') is True, resumed + assert resumed['settlement_identity'] == first['settlement_identity'] + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +def test_default_pending_readback_guides_the_original_caller(provider, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + options = {'next_agent_todo': 'Verify remaining work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + identity = first['settlement_identity'] + for command in (['status', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID], + ['quota', 'should-run', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, + '--todo-id', todo, '--turn-instance-id', identity['turn_instance_id']]): + rc, observed = _run_cli(registry, *command) + assert rc == 0, json.dumps(observed) + item = observed['attention_queue']['items'][0] if command[0] == 'status' else observed + progress = item['first_delivery_progress'] + assert progress['settlement_identity'] == identity + assert progress['stage'] == 'direction_pending' and progress['result_committed'] is True + assert progress['quota_spent'] is False and progress['goal_completion_certified'] is False + assert item['recommended_action'] == progress['next_action'] + # A fresh facade consumes the direction operation, retaining the original Turn. + resumed_control = _control(registry) + context = json.loads(resumed_control.review_task_vision(todo, AGENT_ID)) + assert context['settlement_identity'] == identity + resumed = json.loads(resumed_control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], + vision_unchanged_reason='The committed candidate and remaining obligations preserve the route.')) + assert resumed['settlement_complete'] is True and resumed['settlement_identity'] == identity + rc, observed = _run_cli(registry, 'status', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID) + assert rc == 0 and 'first_delivery_progress' not in observed['attention_queue']['items'][0], json.dumps(observed) + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('terminal', [False, True]) +def test_managed_default_reuses_candidate_and_calls_current_agent_for_direction(provider, terminal, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + if terminal: + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=todo, role='agent', + agent_id=AGENT_ID, no_followup=True) + project = state.parent + host = project / 'host.py' + host.write_text('''import json,pathlib,sys +r=json.load(sys.stdin) +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context'] + assert c['basis']['todo']['status'] == 'done' + assert pathlib.Path('candidate.txt').read_text() == 'valid' + with pathlib.Path('direction.txt').open('a') as f: f.write(c['read_context_id']+'\\n') + json.dump({'read_context_id':c['read_context_id'], 'vision_unchanged_reason':'The committed candidate and latest obligations preserve the route.'},sys.stdout) +else: + assert not pathlib.Path('candidate.txt').exists() + pathlib.Path('candidate.txt').write_text('valid') + json.dump({'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':'validated_completion','completed_phases':['host_execute','typed_result'], + 'classification':'candidate_verified','recommended_action':'Read remaining work.', + 'next_action':'Read remaining work.','delivery_batch_scale':'implementation', + 'delivery_outcome':'outcome_progress','summary':'Candidate verified.', + 'vision_unchanged_reason':'Old pre-result judgment.'},sys.stdout) +''', encoding='utf-8') + validator = project / 'verify.py' + validator.write_text("import pathlib; assert pathlib.Path('candidate.txt').read_text()=='valid'", encoding='utf-8') + rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', + json.dumps([sys.executable, str(host)]), '--validation-command-json', json.dumps([sys.executable, str(validator)]), + '--scan-root', str(project), '--no-global-sync', '--execute') + assert rc == 0 and result.get('status') == 'committed', json.dumps(result, indent=2) + assert len((project / 'direction.txt').read_text().splitlines()) == 1 + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text(encoding='utf-8').splitlines()] + committed = [row for row in rows if row.get('classification') == 'candidate_verified'] + assert len(committed) == 1 + assert committed[0]['vision_checkpoint']['unchanged_reason'] != 'Old pre-result judgment.' + + +@pytest.mark.parametrize('iteration_context', ['fresh', 'resume']) +def test_default_dsh_continues_the_original_agent_session(iteration_context, tmp_path, monkeypatch): + registry, state, _ = _fixture('file', tmp_path, monkeypatch) + project = state.parent + runner = project / 'runner.py' + runner.write_text('''import json,pathlib +def run_dsh_turn(**kwargs): + prompt=kwargs['prompt']; direction=prompt.startswith('Continue the current Agent') + with pathlib.Path(kwargs['workspace'],'sessions.jsonl').open('a') as f: + f.write(json.dumps({'session':kwargs['session_id'],'direction':direction})+'\\n') + if direction: + r=json.loads(prompt.split('\\n',1)[1]); c=r['checkpoint_context'] + assert c['basis']['frontier']['scope']=='goal' + assert pathlib.Path(kwargs['workspace'],'candidate.txt').read_text()=='valid' + return json.dumps({'read_context_id':c['read_context_id'], + 'vision_unchanged_reason':'Current evidence preserves this session route.'}) + pathlib.Path(kwargs['workspace'],'candidate.txt').write_text('valid') + return json.dumps({'result_kind':'validated_progress','classification':'dsh_progress', + 'summary':'Verified one scoped candidate.','next_action':'Review remaining work.'}) +''', encoding='utf-8') + rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'dsh', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--dsh-runner', str(runner), + '--iteration-context', iteration_context, '--validation-command-json', json.dumps([sys.executable, '-c', 'pass']), + '--scan-root', str(project), '--no-global-sync', '--execute') + assert rc == 0 and result['status'] == 'committed', result + sessions = [json.loads(line) for line in (project / 'sessions.jsonl').read_text().splitlines()] + assert [row['direction'] for row in sessions] == [False, True] + assert sessions[0]['session'] == sessions[1]['session'] + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('change', ['none', 'related', 'display', 'cached_binding']) +def test_direction_retry_keeps_input_response_binding_and_result(provider, change, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', next_agent_todo='Verify remaining work.')) + assert first['stage'] == 'direction_pending', first + identity = SettlementIdentity.from_runtime_payload(first['settlement_identity']) + seen = [] + def decide(context): + seen.append(context) + assert context['basis']['todo']['status'] == 'done' + return {'read_context_id': context['read_context_id'], 'vision_unchanged_reason': 'Current evidence preserves the route.'} + driver = CurrentAgentCheckpoint(registry, tmp_path / 'runtime', state, identity, decide) + decided = driver.continue_direction({}) + if change == 'related': + state.write_text(state.read_text(encoding='utf-8') + '\n## Acceptance\nRevised obligation.\n', encoding='utf-8') + elif change == 'display': + import re + text = state.read_text(encoding='utf-8') + text = re.sub(r'^updated_at:.*$', 'updated_at: 2026-10-08', text, flags=re.MULTILINE) if 'updated_at:' in text else text.replace('---\n', '---\nupdated_at: 2026-10-08\n', 1) + state.write_text(text, encoding='utf-8') + elif change == 'cached_binding': + path = _receipt_path(tmp_path / 'runtime', identity) + receipt = json.loads(path.read_text()) + receipt['current_agent_decision']['read_context_id'] = 'replacement-key' + path.write_text(json.dumps(receipt), encoding='utf-8') + with pytest.raises(ValueError, match='binding'): + driver.continue_direction({}) + assert len(seen) == 1 + return + cold = CurrentAgentCheckpoint(registry, tmp_path / 'runtime', state, identity, decide) + replay = cold.continue_direction({}) + assert len(seen) == (2 if change == 'related' else 1) + assert (replay['checkpoint_read_context_id'] != decided['checkpoint_read_context_id']) == (change == 'related') + result = json.loads(_control(registry).complete_task(todo, AGENT_ID, 'Candidate verified.', + next_agent_todo='Verify remaining work.', read_context_id=replay['checkpoint_read_context_id'], + vision_unchanged_reason=replay['vision_unchanged_reason'])) + assert result['settlement_complete'] is True, result + assert result['settlement_identity'] == first['settlement_identity'] + + +@pytest.mark.parametrize('failure', ['timeout', 'stop']) +def test_direction_failure_retains_result_and_respects_existing_host_budget(failure, tmp_path, monkeypatch): + registry, state, todo = _fixture('file', tmp_path, monkeypatch) + control = _control(registry) + json.loads(control.should_run()) + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', next_agent_todo='Verify remaining work.')) + identity = SettlementIdentity.from_runtime_payload(first['settlement_identity']) + calls = [] + def fail(context): + calls.append(context['read_context_id']) + if failure == 'timeout': + raise BuiltInHostError('Synthetic timeout', failure_kind='executor_timeout') + return {'result_kind': 'user_action_required'} + driver = CurrentAgentCheckpoint(registry, tmp_path / 'runtime', state, identity, fail) + for _ in range(3): + with pytest.raises(ValueError): + driver.continue_direction({}) + assert len(calls) == (2 if failure == 'timeout' else 1) + receipt = json.loads(_receipt_path(tmp_path / 'runtime', identity).read_text()) + assert receipt['decision_basis']['todo']['status'] == 'done' + assert 'current_agent_decision' not in receipt + + +@pytest.mark.parametrize('kind', ['validated_progress', 'repair_required', 'replan_required', 'wait']) +def test_managed_noncompletion_uses_original_outcome_or_exemption(kind, tmp_path, monkeypatch): + registry, state, todo = _fixture('file', tmp_path, monkeypatch) + project = state.parent + host = project / 'host.py' + host.write_text('''import json,pathlib,sys +r=json.load(sys.stdin) +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context'] + assert c['basis']['todo']['status'] != 'done' + with pathlib.Path('direction.txt').open('a') as f: f.write(c['read_context_id']+'\\n') + decision={'read_context_id':c['read_context_id'],'vision_unchanged_reason':'Current evidence preserves the scoped route.'} + if KIND == 'replan_required': + decision={'read_context_id':c['read_context_id'],'agent_vision':{'schema_version':'goal_vision_replan_contract_v0', + 'state':'vision_patch_proposed','vision_patch':{'vision_summary':'Verify the revised scoped route.', + 'acceptance_summary':'Fixture evidence supports the revised route.'},'path_delta':{'schema_version':'goal_path_delta_v0', + 'outcome':'replan','prior_assumption':'Original scoped route.','observed_reality':'Revised route required.', + 'changed':['Scoped route'], 'evidence_refs':['evidence:fixture']}}} + json.dump(decision,sys.stdout) +else: + assert not pathlib.Path('candidate.txt').exists() + pathlib.Path('candidate.txt').write_text('verified') + value={'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':KIND,'completed_phases':['host_execute','typed_result'], + 'classification':'fixture_outcome','recommended_action':'Check remaining scoped work.', + 'next_action':'Check remaining scoped work.','summary':'Bounded fixture outcome.'} + if KIND != 'wait': value.update(delivery_batch_scale='implementation',delivery_outcome='outcome_progress', + vision_unchanged_reason='Old pre-outcome judgment.') + json.dump(value,sys.stdout) +'''.replace('KIND', repr(kind)), encoding='utf-8') + rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', + json.dumps([sys.executable, str(host)]), '--validation-command-json', json.dumps([sys.executable, '-c', 'pass']), + '--scan-root', str(project), '--no-global-sync', '--execute') + assert rc == 0 and result['status'] in {'committed','stopped'}, json.dumps(result, indent=2) + assert (project / 'direction.txt').exists() == (kind != 'wait') + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text(encoding='utf-8').splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == (0 if kind == 'wait' else 1) + assert not any(row.get('completion_todo_id') == todo for row in rows) diff --git a/tests/control_plane/test_default_guard_admission.py b/tests/control_plane/test_default_guard_admission.py new file mode 100644 index 0000000000..641b3cd14f --- /dev/null +++ b/tests/control_plane/test_default_guard_admission.py @@ -0,0 +1,184 @@ +"""First automatic admission, selection and gates on isolated owning stores.""" +from __future__ import annotations + +import shlex +import json +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +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.todos.active_state_todo_parser import parse_active_state_todos +from tests.control_plane.test_cli_output_budget import ( + AGENT_IDS, GOAL_ID, SCENARIOS, Scenario, _write_fixture, _invoke_cli, +) +from tests.control_plane.test_quota_settlement_cli import _run_cli, _spend_run_count + + +def _fixture(provider, tmp_path, monkeypatch, *, crowded=False, gated=False, peer=False, replan=False): + isolate_sqlite_runtime(tmp_path, monkeypatch) + scenario = Scenario("two-peers", todo_count=2, agent_count=2, run_count=1) if peer else SCENARIOS[1] if replan else ( + Scenario("ordinary-selection", todo_count=6, agent_count=1, run_count=1) + if crowded else SCENARIOS[0]) + project, runtime, registry, state = _write_fixture(tmp_path, scenario) + if gated: + state.write_text(state.read_text(encoding="utf-8").replace("## Agent Todo", + "## User Todo\n\n- [ ] [P0-user] Approve this scoped delivery.\n" + " \n\n## Agent Todo"), encoding="utf-8") + todos = parse_active_state_todos(state.read_text(encoding="utf-8"), item_limit=None) + assert len(todos["agent_todos"]["items"]) == scenario.todo_count + if provider != "legacy": + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, + todos=todos["agent_todos"]["items"] + todos.get("user_todos", {}).get("items", []), handoff_mode="soft_claim"), + state_path=state, provider=provider) + args = ("quota", "should-run", "--goal-id", GOAL_ID, + "--agent-id", AGENT_IDS[0], "--scan-root", str(project)) + return project, runtime, registry, state, args + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("selector", [(), ("--include-detail", "agent-todos"), + ("--include-detail", "user-todos"), ("--include-detail", "goal-boundary"), + ("--include-detail", "scheduler"), ("--include-detail", "vision"), + ("--include-scheduler-detail",), ("--include-detail", "all")]) +def test_first_detail_selector_admits_and_cold_replays_original_work(provider, selector, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch) + code, first = _run_cli(registry, runtime, *args, *selector, cwd=project) + assert code == 0 and first["should_run"] is True, first + context = first["delivery_result_context"] + identity = context["settlement_identity"] + assert identity["goal_id"] == GOAL_ID and identity["agent_id"] == AGENT_IDS[0] + assert identity["todo_id"] == context["basis"]["todo"]["todo_id"] == "todo_fixture_000" + assert first["heartbeat_receipt"]["turn_instance_id"] == identity["turn_instance_id"] + assert first["work_input_restored"] is False + # _run_cli creates a new process: no inherited caller state or copied token. + code, restored = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and restored["delivery_result_context"] == context, restored + assert restored["work_input_restored"] is True + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("replan", [False, True]) +def test_unbound_concurrent_reads_return_selectable_identities_then_bind_exact_turn(provider, replan, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, crowded=True, replan=replan) + if replan: + from loopx.todos import list_goal_todos + owned = list_goal_todos(registry_path=registry, runtime_root_arg=str(runtime), + goal_id=GOAL_ID, role="agent", limit=None) + items = owned["todos"] + assert {item["todo_id"] for item in items} == {f"todo_fixture_{i:03}" for i in range(36)} + assert all(item["status"] == "open" and item["task_class"] == "advancement_task" + and item["claimed_by"] == AGENT_IDS[0] for item in items) + barrier = Barrier(2) + def read(): + barrier.wait() + return _run_cli(registry, runtime, *args, cwd=project) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: read(), range(2))) + ids = [] + for code, payload in results: + assert code == 0, payload + receipt = payload["heartbeat_receipt"] + assert receipt["settlement_binding_owed"] is True + assert "settlement_identity" not in receipt and "delivery_result_context" not in payload + selection = payload["interaction_contract"]["cli_channel"]["selection_command"] + assert receipt["turn_instance_id"] in selection["command_args_template"] + ids.append(receipt["turn_instance_id"]) + assert len(set(ids)) == 2, "Unselected observations are not one admitted Todo work" + assert _spend_run_count(runtime) == 0 + # Use the returned exact selection command, not a test-created replacement ID. + selection = results[0][1]["interaction_contract"]["cli_channel"]["selection_command"] + selected_args = shlex.split(selection["command_args_template"].replace("{todo_id}", "todo_fixture_000")) + code, selected = _run_cli(registry, runtime, *selected_args, "--scan-root", str(project), cwd=project) + assert code == 0, json.dumps(selected, indent=2) + identity = selected["heartbeat_receipt"]["settlement_identity"] + assert identity["turn_instance_id"] == ids[0] and identity["todo_id"] == "todo_fixture_000" + if replan: + assert selected["normal_delivery_allowed"] is False + assert selected["interaction_contract"]["mode"] == "autonomous_replan" + # Read evidence is not normal-delivery permission. A required replan also + # protects any later result on this newly admitted Todo identity. + assert selected["delivery_result_context"]["settlement_identity"] == identity + code, replay = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and replay["heartbeat_receipt"]["settlement_identity"] == identity, replay + assert replay.get("delivery_result_context") == selected.get("delivery_result_context") + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_user_gate_receipt_does_not_authorize_delivery_or_spend(provider, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, gated=True) + for selector in [(), ("--include-detail", "all")]: + code, guarded = _run_cli(registry, runtime, *args, *selector, cwd=project) + assert code == 0 and guarded["should_run"] is False, guarded + channel = guarded["interaction_contract"] + assert channel["agent_channel"]["delivery_allowed"] is False + assert channel["cli_channel"]["spend_allowed_now"] is False + receipt = guarded["heartbeat_receipt"] + code, rejected = _run_cli(registry, runtime, "quota", "spend-slot", "--goal-id", GOAL_ID, + "--agent-id", AGENT_IDS[0], "--todo-id", "todo_fixture_000", + "--turn-instance-id", receipt["turn_instance_id"], "--slots", "1", + "--source", "heartbeat", "--execute", "--scan-root", str(project), cwd=project) + assert code == 1, rejected + assert _spend_run_count(runtime) == 0 + assert not list((runtime / "goals" / GOAL_ID / "turns").glob("*.json")) + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_another_agent_cannot_restore_the_original_work(provider, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, peer=True) + code, first = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0, first + peer_args = tuple(AGENT_IDS[1] if value == AGENT_IDS[0] else value for value in args) + code, peer = _run_cli(registry, runtime, *peer_args, cwd=project) + assert code == 0 and peer["should_run"] is True, peer + assert peer["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture_001" + assert peer.get("delivery_result_context") != first["delivery_result_context"] + if "settlement_identity" in peer.get("heartbeat_receipt", {}): + assert peer["heartbeat_receipt"]["settlement_identity"]["agent_id"] == AGENT_IDS[1] + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_admission_policy_failure_cannot_bind_an_unbound_turn(provider, tmp_path, monkeypatch): + project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, crowded=True) + code, first = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and first["heartbeat_receipt"]["settlement_binding_owed"] is True, first + selection = first["interaction_contract"]["cli_channel"]["selection_command"] + log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + original = log.read_bytes() + def unavailable(*_args, **_kwargs): + raise RuntimeError("Result work admission source unavailable") + monkeypatch.setattr("loopx.control_plane.goals.checkpoint_context_io.default_result_freshness_policy", unavailable) + command = shlex.split(selection["route_prefix"] + " " + + selection["command_args_template"].replace("{todo_id}", "todo_fixture_000"))[1:] + code, text = _invoke_cli(["--format", "json", *command, "--scan-root", str(project)]) + rejected = json.loads(text) + assert code == 1 and rejected["should_run"] is False, rejected + assert rejected["ok"] is False and rejected["recommended_action"] + assert "delivery_result_context" not in rejected + assert "settlement_identity" not in rejected.get("heartbeat_receipt", {}) + assert log.read_bytes() == original and _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy"]) +def test_malformed_first_read_fails_closed_then_repairs_without_moving_tokens(provider, tmp_path, monkeypatch): + project, runtime, registry, state, args = _fixture(provider, tmp_path, monkeypatch) + original = state.read_bytes() + state.write_text(state.read_text(encoding="utf-8").replace("status=open", "status=unsupported"), encoding="utf-8") + code, failed = _run_cli(registry, runtime, *args, cwd=project) + assert code == 1 and failed["status_health_ok"] is False and failed["should_run"] is False, failed + assert failed["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + assert "delivery_result_context" not in failed and _spend_run_count(runtime) == 0 + state.write_bytes(original) + code, admitted = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and admitted["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture_000", admitted + code, restored = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and restored["delivery_result_context"] == admitted["delivery_result_context"], restored + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py new file mode 100644 index 0000000000..38d7163e29 --- /dev/null +++ b/tests/control_plane/test_default_result_freshness.py @@ -0,0 +1,451 @@ +"""Default admission and original-Turn result delivery on real local providers.""" +from __future__ import annotations + +import json +import sys +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 tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _write_fixture, _spend_run_count, +) + + +def _default_fixture(provider, tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, "claimed_by": AGENT_ID, + "text": "Validate and settle the selected delivery.", "task_class": "advancement_task", + "archive_state": "active", "source_section": "Agent Todo"} + if provider != "legacy": + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo], handoff_mode="soft_claim"), + state_path=state, provider=provider) + return project, runtime, registry, state + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_default_guard_restores_actual_basis_and_result_commit(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + guard_args = ("quota", "should-run", "--codex-app", *identity, "--scan-path", str(project)) + rc, first = _run_cli(registry, runtime, *guard_args, cwd=project) + assert rc == 0 and "delivery_result_context" in first, json.dumps(first) + context = first["delivery_result_context"] + assert context["basis"]["todo"]["todo_id"] == TODO_ID + rc, cold = _run_cli(registry, runtime, *guard_args, cwd=project) + assert rc == 0 and cold["delivery_result_context"] == context, cold + assert cold["work_input_restored"] is True + complete = ("todo", "complete", *identity, "--note", "Candidate independently verified.") + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nAdditional requirement.\n", encoding="utf-8") + rc, rejected = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 1 and rejected.get("error_code") == "checkpoint_read_context_stale", json.dumps(rejected) + assert _spend_run_count(runtime) == 0 + rc, refreshed = _run_cli(registry, runtime, "checkpoint-context", *identity, + "--turn-instance-id", context["settlement_identity"]["turn_instance_id"], + "--purpose", "delivery_result", cwd=project) + assert rc == 0, refreshed + rc, saved = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and saved["completed"], saved + # Completing the Todo alone does not close its original refresh/spend. + rc, pending = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--scan-path", str(project), cwd=project) + assert pending["heartbeat_receipt"]["turn_instance_id"] == context["settlement_identity"]["turn_instance_id"], pending + state.write_text(state.read_text(encoding="utf-8") + "\nRequirement after success.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_concurrent_default_reads_share_original_turn_and_basis(provider, tmp_path, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + barrier = Barrier(2) + def read(): + barrier.wait() + return _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--scan-path", str(project), cwd=project) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: read(), range(2))) + assert all(rc == 0 for rc, _ in results), results + assert results[0][1]["delivery_result_context"] == results[1][1]["delivery_result_context"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_todo_only_source_after_work_read_rejects_result_commit(provider, tmp_path, monkeypatch): + from loopx.control_plane.todos.projection_document import recovered_todo_projection_skeleton + from loopx.todos import list_goal_todos + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, cwd=project) + assert rc == 0 and "delivery_result_context" in guard, guard + original_contexts = {path: path.read_bytes() for path in + (runtime / "goals" / GOAL_ID / "checkpoint-contexts").glob("*.json")} + state.write_text(recovered_todo_projection_skeleton(GOAL_ID), encoding="utf-8") + rc, rejected = _run_cli(registry, runtime, "todo", "complete", *identity, + "--note", "Candidate from the original valid work read.", cwd=project) + assert rc != 0 and rejected["error_code"] == "checkpoint_goal_source_incomplete", rejected + assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID, + runtime_root_arg=str(runtime), todo_id=TODO_ID)["todo"]["status"] == "open" + assert {path: path.read_bytes() for path in original_contexts} == original_contexts + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("source_loss", ["missing", "todo_only"]) +def test_successful_result_replay_precedes_later_goal_source_loss(provider, source_loss, tmp_path, monkeypatch): + from loopx.control_plane.todos.projection_document import recovered_todo_projection_skeleton + from loopx.todos import list_goal_todos + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, cwd=project) + assert rc == 0, guard + complete = ("todo", "complete", *identity, "--note", "Verified result committed before source loss.") + rc, committed = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and committed["completed"], committed + original_todo = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, + runtime_root_arg=str(runtime), todo_id=TODO_ID)["todo"] + if source_loss == "missing": + state.unlink() + else: + state.write_text(recovered_todo_projection_skeleton(GOAL_ID), encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID, + runtime_root_arg=str(runtime), todo_id=TODO_ID)["todo"] == original_todo + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_already_bound_historical_receipt_retains_original_policy(provider, tmp_path, monkeypatch): + from tests.control_plane.test_quota_settlement_cli import _historical_turn + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, + "--scan-path", str(project)) + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, first + # Synthetic pre-default persisted admission; never rewrite active state. + _historical_turn(runtime) + log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + original = log.read_bytes() + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, replay + assert replay["heartbeat_receipt"]["settlement_identity"] == first["heartbeat_receipt"]["settlement_identity"] + assert "delivery_result_context" not in replay + assert log.read_bytes() == original + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_default_commit_keeps_basis_after_unrelated_todo_revision(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, work = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--scan-path", str(project), cwd=project) + assert rc == 0, work + rc, peer = _run_cli(registry, runtime, "todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--text", "Validate an independent follow-up.", "--claimed-by", AGENT_ID, cwd=project) + assert rc == 0, peer + rc, saved = _run_cli(registry, runtime, "todo", "complete", *identity, + "--note", "Original candidate verified.", cwd=project) + assert rc == 0 and saved["completed"], saved + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_damaged_work_input_blocks_default_dispatch_and_preserves_admission(provider, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--scan-path", str(project)) + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, first + path = next((runtime / "goals" / GOAL_ID / "checkpoint-contexts").glob("*.json")) + original = path.read_bytes() + path.write_text("{", encoding="utf-8") + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected["should_run"] is False, rejected + assert rejected["status"] == "delivery_work_context_unavailable" + assert rejected["heartbeat_receipt"]["turn_instance_id"] == first["heartbeat_receipt"]["turn_instance_id"] + assert _spend_run_count(runtime) == 0 + path.write_bytes(original) + rc, restored = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and restored["delivery_result_context"] == first["delivery_result_context"], restored + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("recovery_window", ["normal", "receipt_replaced", "input_persisted", "commit_response_lost", "commit_receipt_deleted", "commit_receipt_damaged", "commit_race", "binding_race", "terminal_commit_race", "terminal_host_cold", "terminal_validation_cold", "terminal_host_replaced", "terminal_validation_replaced", "terminal_commit_response_lost", "terminal_receipt_deleted", "terminal_receipt_damaged", "terminal_validation_failed", "terminal_stop"]) +def test_historical_result_only_turn_retains_candidate_and_revalidates(provider, recovery_window, tmp_path, monkeypatch): + from tests.test_loopx_turn_driver import _write_live_fixture + from loopx.status import parse_active_state_todos + + isolate_sqlite_runtime(tmp_path, monkeypatch) + terminal_race = recovery_window.startswith("terminal_") + project, runtime, registry = _write_live_fixture(tmp_path, + **({"todo_metadata_extra": "no_followup=true"} if terminal_race else {})) + goal, agent = "loopx-turn-fixture", "codex-fixture" + state = project / f".codex/goals/{goal}/ACTIVE_GOAL_STATE.md" + todos = parse_active_state_todos(state.read_text(encoding="utf-8"))["agent_todos"]["items"] + if provider != "legacy": + initialize_canonical_authority(runtime, goal, + build_todo_runtime_shadow_projection(goal_id=goal, todos=todos, handoff_mode="soft_claim"), + state_path=state, provider=provider) + host = f''' +import json, pathlib, sys +r = json.load(sys.stdin) +assert r["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture0001" +candidate = pathlib.Path("candidate.txt") +if not r.get("result_revalidation"): + assert not candidate.exists() + candidate.write_text("valid candidate", encoding="utf-8") + state = pathlib.Path({str(state)!r}) + if {not (recovery_window in {'commit_race', 'binding_race'} or terminal_race)!r}: + state.write_text(state.read_text(encoding="utf-8") + "\\n## Acceptance\\nRetain the valid candidate and verify it again.\\n", encoding="utf-8") +else: + assert candidate.read_text(encoding="utf-8") == "valid candidate" + assert r["result_revalidation"]["original_context"]["read_context_id"] != r["delivery_result_context"]["read_context_id"] + pathlib.Path("revalidation_started").touch() +json.dump({{"schema_version":"loopx_turn_result_v0", "turn_key":r["turn_key"], + "result_kind": "user_action_required" if {recovery_window == 'terminal_stop'!r} and r.get("result_revalidation") else "validated_completion", "completed_phases":["host_execute","typed_result"], + "classification":"retained_candidate_verified", "recommended_action":"Read remaining work.", + "next_action":"Read remaining work.", "delivery_batch_scale":"implementation", "delivery_outcome":"outcome_progress", + "vision_unchanged_reason":"The valid candidate satisfies the current requirements.", "summary":"Candidate verified."}}, sys.stdout) +''' + verifier = 'import json,pathlib,sys; r=json.load(sys.stdin); assert "delivery_result_context" in r; assert pathlib.Path("candidate.txt").read_text()=="valid candidate"' + if recovery_window == "terminal_validation_failed": + verifier += '\nif pathlib.Path("revalidation_started").exists() and not pathlib.Path("failed_validation_once").exists():\n pathlib.Path("failed_validation_once").touch()\n sys.exit(1)\n' + host_path, validator_path = project / "host.py", project / "validator.py" + host_path.write_text(host, encoding="utf-8") + validator_path.write_text(verifier, encoding="utf-8") + args = ("turn", "run-once", "--host", "generic-cli", "--goal-id", goal, "--agent-id", agent, + "--project", str(project), "--host-adapter-command-json", json.dumps([sys.executable, str(host_path)]), + "--validation-command-json", json.dumps([sys.executable, str(validator_path)]), + "--scan-root", str(project), "--no-global-sync", "--execute") + # Gap1 admissions already in flight keep result-only semantics after the + # new checkpoint default ships. Seed the real old receipt, never downgrade + # a receipt admitted under current_agent_v1. + from loopx.control_plane.quota.settlement import SettlementIdentity + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + rc, preview = _run_cli(registry, runtime, *args[:-1], cwd=project) + assert rc == 0, preview + ensure_turn_heartbeat_settlement_receipt(runtime, + SettlementIdentity(goal_id=goal, agent_id=agent, todo_id='todo_fixture0001', + turn_instance_id=preview['resume_turn_key']), semantic_replan_guard_scoped=True, + semantic_replan_obligation_id=None, result_freshness='turn_result_freshness_required_v1') + if recovery_window in {"commit_race", "binding_race"} or terminal_race: + import os + import subprocess + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + wrapper = project / "change_before_completion.py" + mutation = f'''import pathlib +import loopx.cli_commands.turn_run_once as turn +original = turn.write_turn_validated_completion +def change_basis(*args, **kwargs): + state = pathlib.Path({str(state)!r}) + state.write_text(state.read_text(encoding="utf-8") + "\\n## Acceptance\\nRevalidate the retained candidate.\\n", encoding="utf-8") + return original(*args, **kwargs) +turn.write_turn_validated_completion = change_basis +''' + if recovery_window == "binding_race": + mutation = '''from loopx.control_plane.goals.checkpoint_context_io import TurnResultWork +original = TurnResultWork.bind +def replace_receipt(self, *args, **kwargs): + self.read(reread=True) + return original(self, *args, **kwargs) +TurnResultWork.bind = replace_receipt +''' + wrapper.write_text(mutation + ''' +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + changed = subprocess.run([sys.executable, str(wrapper), "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args], cwd=project, + capture_output=True, text=True, env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}) + rc, first = changed.returncode, json.loads(changed.stdout) + else: + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and first.get("result_kind") == ("terminal_closeout_failed" if terminal_race else "validation_failed"), first + journal_path = next((runtime / "goals" / goal / "turns").glob("*.json")) + failed = json.loads(journal_path.read_text(encoding="utf-8")) + assert failed["validation_stage"] == "result_freshness" + assert failed["result_revalidation"]["candidate"]["summary"] == "Candidate verified." + assert first["effects"]["state_written"] is terminal_race and first["effects"]["quota_spent"] is terminal_race + assert not failed.get("effect_attempts"), "The rejected commit must release its prepared effect" + if recovery_window in {"receipt_replaced", "input_persisted"}: + from loopx.control_plane.goals.checkpoint_context_io import result_work_context + from loopx.control_plane.quota.settlement import SettlementIdentity + context = result_work_context(registry_path=registry, runtime_root=runtime, + identity=SettlementIdentity.from_runtime_payload(failed["result_work_context"]["settlement_identity"]), reread=True) + if recovery_window == "input_persisted": + failed["result_work_context"] = context + failed["status"] = "in_progress" + for key in ("host_result", "result_kind", "receipt", "reason", "validation_stage"): + failed.pop(key, None) + failed["completed_phases"] = [] + journal_path.write_text(json.dumps(failed), encoding="utf-8") + if recovery_window in {"terminal_host_cold", "terminal_validation_cold", "terminal_host_replaced", "terminal_validation_replaced"}: + import os + import subprocess + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + wrapper = project / "lose_revalidation_checkpoint.py" + phase_count = 2 if recovery_window in {"terminal_host_cold", "terminal_host_replaced"} else 3 + wrapper.write_text(f'''import os +import loopx.control_plane.turn_driver.executor as executor +original = executor._write_journal +def lose_response(path, journal, **kwargs): + result = original(path, journal, **kwargs) + retry = journal.get("result_revalidation", {{}}) + if retry.get("phase") == "terminal_closeout" and len(retry.get("validation", {{}}).get("completed_phases", [])) == {phase_count}: + os._exit(73) + return result +executor._write_journal = lose_response +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + lost = subprocess.run([sys.executable, str(wrapper), "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args, + "--resume-turn-key", failed["turn_key"], "--retry-failed-turn"], cwd=project, + capture_output=True, text=True, env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}) + assert lost.returncode == 73, lost.stdout + lost.stderr + if recovery_window.endswith("_replaced"): + from loopx.control_plane.goals.checkpoint_context_io import result_work_context + from loopx.control_plane.quota.settlement import SettlementIdentity + state.write_text(state.read_text(encoding="utf-8") + "\nLatest acceptance must be adopted by the current Agent.\n", encoding="utf-8") + result_work_context(registry_path=registry, runtime_root=runtime, + identity=SettlementIdentity.from_runtime_payload(failed["result_work_context"]["settlement_identity"]), reread=True) + if recovery_window in {"commit_response_lost", "terminal_commit_response_lost", "commit_receipt_deleted", "commit_receipt_damaged", "terminal_receipt_deleted", "terminal_receipt_damaged"}: + import os + import subprocess + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + wrapper = project / "lose_completion_response.py" + wrapper.write_text('''import os +import loopx.cli_commands.turn_run_once as turn +original = turn.write_turn_validated_completion +def lose_response(*args, **kwargs): + result = original(*args, **kwargs) + assert result["ok"] + os._exit(73) +turn.write_turn_validated_completion = lose_response +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + lost = subprocess.run([sys.executable, str(wrapper), "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args, + "--resume-turn-key", failed["turn_key"], "--retry-failed-turn"], cwd=project, + capture_output=True, text=True, env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}) + assert lost.returncode == 73, lost.stdout + lost.stderr + index = runtime / "goals" / goal / "runs/index.jsonl" + if not terminal_race: + assert not index.exists() or not index.read_bytes(), "Writeback must not precede the lost result response" + if "receipt_" in recovery_window: + context_path = next((runtime / "goals" / goal / "checkpoint-contexts").glob("*.json")) + if recovery_window.endswith("deleted"): + context_path.unlink() + else: + context_path.write_text("{", encoding="utf-8") + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", goal, "--agent-id", agent, "--todo-id", "todo_fixture0001", + "--scan-path", str(project), cwd=project) + assert rc == 0 and guard.get("ok") is True, guard + assert guard["heartbeat_receipt"]["settlement_identity"]["turn_instance_id"] == failed["turn_key"] + assert guard.get("result_committed") is True and "delivery_result_context" not in guard, guard + rc, resumed = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], + "--retry-failed-turn", cwd=project) + if recovery_window in {"terminal_validation_failed", "terminal_stop"}: + assert rc == 1 and resumed["status"] == "failed", resumed + pending = json.loads(journal_path.read_text(encoding="utf-8")) + assert pending["completed_phases"] == failed["completed_phases"] + assert pending["writeback"] == failed["writeback"] and pending["quota_spend"] == failed["quota_spend"] + assert pending["host_attempt_count"] == 2 + if recovery_window == "terminal_stop": + rc, stopped_replay = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], + "--retry-failed-turn", cwd=project) + assert rc == 1 and not stopped_replay["effects"]["host_invoked"], stopped_replay + assert json.loads(journal_path.read_text(encoding="utf-8"))["quota_spend"] == failed["quota_spend"] + return + rc, resumed = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], + "--retry-failed-turn", cwd=project) + assert rc == 0 and resumed["status"] == "committed", resumed + committed = json.loads(journal_path.read_text(encoding="utf-8")) + assert committed["turn_key"] == failed["turn_key"] + assert committed["host_attempt_count"] == (3 if recovery_window in {"terminal_validation_failed", "terminal_host_replaced", "terminal_validation_replaced"} else 2) + if terminal_race: + assert {key: value for key, value in committed["writeback"].items() if key != "completion"} == failed["writeback"] + assert committed["quota_spend"] == failed["quota_spend"] + runs = [json.loads(line) for line in (runtime / "goals" / goal / "runs/index.jsonl").read_text(encoding="utf-8").splitlines()] + assert sum(run.get("classification") == "quota_slot_spent" for run in runs) == 1 + assert committed["result_work_binding"]["read_context_id"] == committed["result_work_context"]["read_context_id"] + rc, replay = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], cwd=project) + assert rc == 0 and replay["replayed"] is True and replay["effects"]["host_invoked"] is False, replay + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("receipt_loss", [None, "deleted", "damaged", "writeback_deleted", "writeback_damaged"]) +def test_default_mcp_reads_before_work_and_rejects_stale_result(provider, receipt_loss, tmp_path, monkeypatch): + from tests.test_goal_mode_mcp_settlement import _write_fixture as mcp_fixture, _control, _complete_default_task, GOAL_ID as goal, AGENT_ID as agent + from loopx.todos import add_goal_todo + from loopx.status import parse_active_state_todos + + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = mcp_fixture(tmp_path) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"] = {"kind": "fixture_v0", "status": "connected-delivery"} + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=goal, role="agent", text="Verify a bounded candidate.", + task_class="advancement_task", claimed_by=agent, continuation_policy="same_agent_non_delivery") + runtime = tmp_path / "runtime" + if provider != "legacy": + todos = parse_active_state_todos(state.read_text(encoding="utf-8"))["agent_todos"]["items"] + initialize_canonical_authority(runtime, goal, + build_todo_runtime_shadow_projection(goal_id=goal, todos=todos, handoff_mode="soft_claim"), + state_path=state, provider=provider) + control = _control(registry) + first = json.loads(control.should_run()) + assert first.get("delivery_result_context"), json.dumps(first) + original = first["delivery_result_context"] + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\nRecheck the additional requirement.\n", encoding="utf-8") + # A new MCP facade/process keeps the same stored work key, without user token copying. + control = _control(registry) + cold = json.loads(control.should_run()) + assert cold["delivery_result_context"] == original + result = json.loads(_complete_default_task(control, added["todo_id"], agent, "Candidate verified.", + next_agent_todo="Verify remaining work.")) + assert result["ok"] is False, result + assert result.get("error_code") == "checkpoint_read_context_stale", result + refreshed = json.loads(control.should_run(revalidate_todo_id=added["todo_id"])) + assert "delivery_result_context" in refreshed, json.dumps(refreshed, indent=2) + assert refreshed["delivery_result_context"]["read_context_id"] != original["read_context_id"] + assert refreshed["delivery_result_context"]["settlement_identity"] == original["settlement_identity"] + if receipt_loss: + run_cli = control.run_cli + def lose_result_response(args, **kwargs): + output = run_cli(args, **kwargs) + step = ["refresh-state"] if receipt_loss.startswith("writeback_") else ["todo", "complete"] + if args[:len(step)] == step and json.loads(output).get("ok"): + return json.dumps({"ok": False, "error": "synthetic result response lost"}) + return output + control.run_cli = lose_result_response + lost = json.loads(_complete_default_task(control, added["todo_id"], agent, "Candidate revalidated.", + next_agent_todo="Verify remaining work.")) + assert lost["ok"] is False, lost + context_path = next((runtime / "goals" / goal / "checkpoint-contexts").glob("*.json")) + if receipt_loss.endswith("deleted"): + context_path.unlink() + else: + context_path.write_text("{", encoding="utf-8") + control = _control(registry) + pending = json.loads(control.should_run()) + assert pending.get("result_committed") is True, json.dumps({key: pending.get(key) for key in + ("error", "reason", "error_code", "status", "result_committed", "heartbeat_receipt", "work_input_restored")}) + assert pending["heartbeat_receipt"]["settlement_identity"] == original["settlement_identity"] + result = json.loads(_complete_default_task(control, added["todo_id"], agent, "Candidate revalidated.", + next_agent_todo="Verify remaining work.")) + assert result["ok"] is True, result + # Ordinary successful settlement releases the next default work identity. + next_work = json.loads(control.should_run()) + assert next_work.get("delivery_result_context"), next_work + assert next_work["delivery_result_context"]["settlement_identity"]["turn_instance_id"] != original["settlement_identity"]["turn_instance_id"] diff --git a/tests/control_plane/test_default_turn_recovery.py b/tests/control_plane/test_default_turn_recovery.py new file mode 100644 index 0000000000..536eec6ece --- /dev/null +++ b/tests/control_plane/test_default_turn_recovery.py @@ -0,0 +1,350 @@ +"""Default managed original-Turn recovery through real isolated authority stores.""" +from __future__ import annotations + +from collections import Counter +import json +import re +import subprocess +import sys + +import pytest + +from loopx.todos import update_goal_todo +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.todos.projection_document import recovered_todo_projection_skeleton +from loopx.control_plane.goals.checkpoint_context_io import ( + CheckpointReadContextRejected, _checkpoint_effect, +) +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID + + +def _managed_recovery_call(provider, fault, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=todo, role='agent', + agent_id=AGENT_ID, no_followup=True) + project = state.parent + host = project / 'host.py' + host.write_text('''import json,pathlib,sys +r=json.load(sys.stdin) +def count(name): + with pathlib.Path(name).open('a') as f: f.write('1\\n') +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context']; assert c['basis']['todo']['status']=='done' + count('direction.txt') + with pathlib.Path('direction-inputs.jsonl').open('a') as f: f.write(json.dumps(c)+'\\n') + if STALE and len(pathlib.Path('direction.txt').read_text().splitlines())==1: + p=pathlib.Path(STATE); p.write_text(p.read_text()+'\\n## Acceptance\\nVerify the revised obligation.\\n') + if STALE and pathlib.Path('direction.txt').read_text().count('1')==2: + assert 'revised obligation' in json.dumps(c['basis']) + adopted='revised obligation' in json.dumps(c['basis']).lower() + json.dump({'read_context_id':c['read_context_id'],'vision_unchanged_reason':'Current obligations preserve the route; revised='+str(adopted)},sys.stdout) +else: + assert not pathlib.Path('candidate.txt').exists() + pathlib.Path('candidate.txt').write_text('valid'); count('implementation.txt') + json.dump({'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':'validated_completion','completed_phases':['host_execute','typed_result'], + 'classification':'candidate_verified','recommended_action':'Read remaining work.', + 'next_action':'Read remaining work.','delivery_batch_scale':'implementation', + 'delivery_outcome':'outcome_progress','summary':'Candidate verified.', + 'vision_unchanged_reason':'Old pre-result judgment.'},sys.stdout) +'''.replace('STALE', repr(fault == 'stale')).replace('STATE', repr(str(state))), encoding='utf-8') + validator = project / 'verify.py' + validator.write_text("import pathlib; assert pathlib.Path('candidate.txt').read_text()=='valid'\n" + "with pathlib.Path('validation.txt').open('a') as f: f.write('1\\n')", encoding='utf-8') + args = ['--registry', str(registry), '--format', 'json', 'turn', 'run-once', '--host', 'generic-cli', + '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--project', str(project), + '--host-adapter-command-json', json.dumps([sys.executable, str(host)]), + '--validation-command-json', json.dumps([sys.executable, str(validator)]), + '--scan-root', str(project), '--no-global-sync', '--execute'] + injection = '''import os,sys +from loopx.cli import main +from loopx.cli_commands import turn_run_once as tr +from loopx.control_plane.turn_driver import checkpoint_continuation as cp +FAULT=SELECTED +if FAULT in {'result','terminal'}: + original=tr.write_turn_validated_completion + def lose(*a,**kw): + value=original(*a,**kw) + if value.get('ok') and bool(kw.get('no_followup'))==(FAULT=='terminal'): os._exit(97) + return value + tr.write_turn_validated_completion=lose +elif FAULT=='read': + original=cp.read_checkpoint_context + def lose(*a,**kw): + original(*a,**kw); os._exit(97) + cp.read_checkpoint_context=lose +elif FAULT=='decision': + original=cp.CurrentAgentCheckpoint._save + def lose(self,path,receipt): + original(self,path,receipt) + if receipt.get('current_agent_decision'): os._exit(97) + cp.CurrentAgentCheckpoint._save=lose +elif FAULT=='dispatch': + original=cp.CurrentAgentCheckpoint._save + def lose(self,path,receipt): + original(self,path,receipt) + if receipt.get('direction_host_attempt_count') and not receipt.get('current_agent_decision'): os._exit(97) + cp.CurrentAgentCheckpoint._save=lose +elif FAULT in {'response','bound_response'}: + original=cp._checkpoint_effect + def lose(method,params): + response=params.get('phase')=='direction_response' + if response and FAULT=='response': os._exit(97) + value=original(method,params) + if response and value.get('ok') and FAULT=='bound_response': os._exit(97) + return value + cp._checkpoint_effect=lose +elif FAULT.startswith('append_'): + from pathlib import Path + from loopx.control_plane.goals import checkpoint_context_io as context_io + from tests.control_plane.checkpoint_process import start_probe + original=context_io.effect_runtime_result + def lose(method,params,**kw): + if method!='goal.checkpoint_read_context.commit': return original(method,params,**kw) + barrier=Path(PROJECT)/'append-fault'; barrier.mkdir(); (barrier/'release').touch() + child=start_probe({'mode':'checkpoint','provider':PROVIDER,'barrier':str(barrier.resolve()), + 'params':params,'fault':FAULT.removeprefix('append_')}) + out,err=child.communicate(timeout=25) + assert child.returncode==86, out+err + os._exit(97) + context_io.effect_runtime_result=lose +elif FAULT=='checkpoint': + original=tr.refresh_state_run + def lose(*a,**kw): + value=original(*a,**kw) + if value.get('ok') and value.get('vision_checkpoint',{}).get('satisfied'): os._exit(97) + return value + tr.refresh_state_run=lose +elif FAULT=='spend': + original=tr.spend_quota_slot + def lose(*a,**kw): + value=original(*a,**kw) + if value.get('ok'): os._exit(97) + return value + tr.spend_quota_slot=lose +raise SystemExit(main(sys.argv[1:])) +'''.replace('SELECTED', repr(fault)).replace('PROVIDER', repr(provider)).replace('PROJECT', repr(str(project))) + return state, project, args, injection + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('fault', ['result', 'read', 'decision', 'checkpoint', 'spend', 'terminal', 'stale']) +def test_default_managed_cold_recovery_resumes_only_unfinished_checkpoint(provider, fault, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == (1 if fault == 'stale' else 97), lost.stdout + lost.stderr + journal_path = next(path for path in (tmp_path / 'runtime/goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + assert journal['host_result']['result_kind'] == 'validated_completion' + before_rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + if fault in {'result','read','decision','stale'}: + assert not any(row.get('classification') == 'quota_slot_spent' for row in before_rows) + recovery = subprocess.run([sys.executable, '-m', 'loopx.cli', *args, '--resume-turn-key', journal['turn_key'], + '--retry-failed-turn'], capture_output=True, text=True) + assert recovery.returncode == 0, recovery.stdout + recovery.stderr + result = json.loads(recovery.stdout) + assert result['status'] == 'committed' and result['resume_turn_key'] == journal['turn_key'], result + assert len((project / 'implementation.txt').read_text().splitlines()) == 1 + assert len((project / 'validation.txt').read_text().splitlines()) == 1 + assert len((project / 'direction.txt').read_text().splitlines()) == (2 if fault == 'stale' else 1) + rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + assert sum(row.get('classification') == 'candidate_verified' for row in rows) == 1 + assert 'no_followup=true' in state.read_text() + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *args, '--resume-turn-key', journal['turn_key']], + capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert len((project / 'direction.txt').read_text().splitlines()) == (2 if fault == 'stale' else 1) + + +def _business_rows(runtime): + rows = [json.loads(line) for line in + (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + return Counter(row['classification'] for row in rows + if row.get('classification') in {'candidate_verified', 'quota_slot_spent'}) + + +def _work_counts(project): + return {name: len((project / name).read_text().splitlines()) + if (project / name).exists() else 0 + for name in ('implementation.txt', 'validation.txt', 'direction.txt')} + + +def _provider_revision(runtime): + snapshot = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID, include_leases=True) + return snapshot['provider_revision'] if snapshot is not None else None + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('fault', [ + 'result', 'read', 'dispatch', 'response', 'bound_response', + 'decision', 'checkpoint', 'spend', 'terminal', +]) +@pytest.mark.parametrize('source_loss', ['missing', 'todo_only']) +def test_original_turn_restores_real_goal_after_response_and_source_loss( + provider, fault, source_loss, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == 97, lost.stdout + lost.stderr + runtime = tmp_path / 'runtime' + journal_path = next(path for path in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + assert journal['host_result']['result_kind'] == 'validated_completion' + original_turn = journal['turn_key'] + actual_source = state.read_bytes() + before_effects, before_work = _business_rows(runtime), _work_counts(project) + before_revision = _provider_revision(runtime) + if source_loss == 'missing': + state.unlink() + else: + state.write_text(recovered_todo_projection_skeleton(GOAL_ID), encoding='utf-8') + + resume_args = [*args, '--resume-turn-key', original_turn, '--retry-failed-turn'] + incomplete = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], + capture_output=True, text=True) + assert incomplete.returncode in {0, 1}, incomplete.stdout + incomplete.stderr + if incomplete.returncode == 0: + observed = json.loads(incomplete.stdout) + # Historical terminal replay may finish its journal. Incomplete intent + # never authorizes completion of a previously unfinished phase. + assert fault == 'terminal' and observed['status'] == 'committed', observed + assert observed['resume_turn_key'] == original_turn + after_effects = _business_rows(runtime) + assert after_effects['candidate_verified'] == before_effects['candidate_verified'] + # The successful indexed checkpoint authorizes its original owed spend, + # not a new judgment or terminal write against incomplete Goal intent. + allowed_spends = {before_effects['quota_slot_spent']} + if fault == 'checkpoint': + assert before_effects['candidate_verified'] == 1 + allowed_spends.add(1) + assert after_effects['quota_slot_spent'] in allowed_spends + assert _provider_revision(runtime) == before_revision + assert _work_counts(project) == before_work + assert (project / 'candidate.txt').read_text() == 'valid' + + # Restore the actual source captured at loss, including its committed Todo + # projection. Never fabricate Goal intent or restore an earlier lifecycle. + state.write_bytes(actual_source) + restored = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], + capture_output=True, text=True) + assert restored.returncode == 0, restored.stdout + restored.stderr + observed = json.loads(restored.stdout) + assert observed['status'] == 'committed' and observed['resume_turn_key'] == original_turn + # A response lost before durable decision persistence requires one new + # judgment. It must never repeat implementation, validation or settlement. + direction_calls = 2 if fault in {'response', 'bound_response'} else 1 + assert _work_counts(project) == { + 'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': direction_calls} + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + assert 'no_followup=true' in state.read_text() + + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], + capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + assert _work_counts(project) == { + 'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': direction_calls} + + +def _original_checkpoint_receipt(runtime): + receipts = [json.loads(path.read_text()) for path in + (runtime / 'goals' / GOAL_ID / 'checkpoint-contexts').glob('*.json')] + return next(receipt for receipt in receipts if receipt.get('purpose') == 'first_delivery') + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize(('fault', 'change'), [ + ('dispatch', 'none'), ('dispatch', 'related'), + ('response', 'none'), ('response', 'related'), + ('bound_response', 'none'), ('bound_response', 'related'), ('bound_response', 'display'), +]) +def test_unpersisted_direction_response_rejudges_without_repeating_work( + provider, fault, change, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == 97, lost.stdout + lost.stderr + runtime = tmp_path / 'runtime' + journal_path = next(path for path in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + receipt = _original_checkpoint_receipt(runtime) + old_key = receipt['read_context_id'] + assert 'current_agent_decision' not in receipt + assert journal['host_result']['result_kind'] == 'validated_completion' + before_calls = 0 if fault == 'dispatch' else 1 + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, + 'direction.txt': before_calls} + assert _business_rows(runtime) == Counter() + if change == 'related': + state.write_text(state.read_text() + '\n## Acceptance\nVerify the revised obligation.\n', encoding='utf-8') + elif change == 'display': + before = state.read_text() + changed = re.sub(r'^updated_at:.*$', 'updated_at: 2026-10-10', before, flags=re.MULTILINE) + if changed == before: + changed = before.replace('---\n', '---\nupdated_at: 2026-10-10\n', 1) + assert changed != before, 'Display-only update must actually occur' + state.write_text(changed, encoding='utf-8') + resume_args = [*args, '--resume-turn-key', journal['turn_key'], '--retry-failed-turn'] + recovered = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + result = json.loads(recovered.stdout) + assert result['status'] == 'committed' and result['resume_turn_key'] == journal['turn_key'] + inputs = [json.loads(line) for line in (project / 'direction-inputs.jsonl').read_text().splitlines()] + assert len(inputs) == before_calls + 1 + assert ('revised obligation' in json.dumps(inputs[-1]['basis']).lower()) == (change == 'related') + assert (inputs[-1]['read_context_id'] != old_key) == (change == 'related') + final_receipt = _original_checkpoint_receipt(runtime) + assert final_receipt['current_agent_binding']['read_context_id'] == inputs[-1]['read_context_id'] + assert final_receipt['current_agent_decision']['vision_unchanged_reason'].endswith(str(change == 'related')) + after_work, after_effects = _work_counts(project), _business_rows(runtime) + assert after_work == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': before_calls + 1} + assert after_effects == Counter({'candidate_verified': 1, 'quota_slot_spent': 1}) + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _work_counts(project) == after_work and _business_rows(runtime) == after_effects + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('fault', ['before_artifacts', 'after_json', 'after_markdown', 'after_index']) +def test_default_managed_native_append_recovery_never_treats_unknown_as_absent( + provider, fault, tmp_path, monkeypatch): + _, project, args, injection = _managed_recovery_call(provider, 'append_' + fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True) + assert lost.returncode == 97, lost.stdout + lost.stderr + runtime = tmp_path / 'runtime' + journal_path = next(path for path in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not path.name.endswith('.holder.json')) + original_turn = json.loads(journal_path.read_text())['turn_key'] + identity = SettlementIdentity.from_runtime_payload(_original_checkpoint_receipt(runtime)['identity']) + original_work = _work_counts(project) + assert original_work == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + resume_args = [*args, '--resume-turn-key', original_turn, '--retry-failed-turn'] + recovered = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + if fault in {'after_json', 'after_markdown'}: + assert recovered.returncode != 0, recovered.stdout + recovered.stderr + for candidate in (identity, SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=identity.todo_id, turn_instance_id='foreign-turn')): + with pytest.raises(CheckpointReadContextRejected) as error: + _checkpoint_effect('goal.checkpoint_read_context.inspect_attempt', { + 'runtime_root': str(runtime.resolve()), 'identity': candidate.as_dict(), + 'check_other_attempts': True, + }) + assert error.value.code == 'checkpoint_commit_unknown' + assert _business_rows(runtime) == Counter() + again = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert again.returncode != 0 + assert _work_counts(project) == original_work and _business_rows(runtime) == Counter() + else: + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + result = json.loads(recovered.stdout) + assert result['status'] == 'committed' and result['resume_turn_key'] == original_turn + assert _work_counts(project) == original_work + after_effects = _business_rows(runtime) + assert after_effects == Counter({'candidate_verified': 1, 'quota_slot_spent': 1}) + replay = subprocess.run([sys.executable, '-m', 'loopx.cli', *resume_args], capture_output=True, text=True) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _work_counts(project) == original_work and _business_rows(runtime) == after_effects diff --git a/tests/control_plane/test_effect_runtime_host_permission.py b/tests/control_plane/test_effect_runtime_host_permission.py index 26c333d4c4..a9c385a490 100644 --- a/tests/control_plane/test_effect_runtime_host_permission.py +++ b/tests/control_plane/test_effect_runtime_host_permission.py @@ -349,6 +349,23 @@ def test_real_locator_missing_or_invalid_still_allows_discovery( assert locator.read_text() == contents +def test_directory_locator_is_unusable_even_when_open_reports_permission_denied( + tmp_path: Path, monkeypatch, +) -> None: + locator = tmp_path / "locator.json" + locator.mkdir() + original_read = Path.read_text + + def read(path, *args, **kwargs): + if path == locator: + raise PermissionError(errno.EACCES, "directory cannot be opened as a file") + return original_read(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", read) + assert effect_runtime._read_info(locator, fingerprint="fixture") is None + assert locator.is_dir(), "discovery must not remove a foreign locator" + + def test_real_locator_live_identity_is_unchanged(tmp_path: Path) -> None: locator = tmp_path / "locator.json" info = {"schema_version": effect_runtime.EFFECT_RUNTIME_INFO_SCHEMA_VERSION, diff --git a/tests/control_plane/test_first_delivery_faults.py b/tests/control_plane/test_first_delivery_faults.py new file mode 100644 index 0000000000..6f936b498f --- /dev/null +++ b/tests/control_plane/test_first_delivery_faults.py @@ -0,0 +1,66 @@ +"""Kill real native appends and inspect original identities on both providers.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.checkpoint_process import refresh, start_probe +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, _spend_run_count + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("fault", ["before_artifacts", "after_json", "after_markdown", "after_index"]) +def test_original_direction_readback_distinguishes_torn_from_committed(tmp_path, monkeypatch, provider, fault): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + token = read()["read_context_id"] + barrier = tmp_path / "fault" + barrier.mkdir() + (barrier / "release").touch() + original = context_io.effect_runtime_result + + def native(method, params, **kwargs): + if method != "goal.checkpoint_read_context.commit": + return original(method, params, **kwargs) + child = start_probe({"mode": "checkpoint", "provider": provider, "barrier": str(barrier), + "params": params, "fault": fault}) + stdout, stderr = child.communicate(timeout=25) + assert child.returncode == 86, stdout + stderr + raise OSError("injected native response loss") + + monkeypatch.setattr(context_io, "effect_runtime_result", native) + with pytest.raises(OSError, match="injected"): + refresh(registry, runtime, token, first_delivery=True) + monkeypatch.setattr(context_io, "effect_runtime_result", original) + if fault == "before_artifacts": + observed = read() + assert observed["read_context_id"] != token + saved = refresh(registry, runtime, observed["read_context_id"], first_delivery=True) + assert saved["appended"] + elif fault == "after_index": + observed = read() + assert observed["status"] == "committed" and observed["replayed"] + replay = refresh(registry, runtime, token, first_delivery=True) + assert replay["appended"] is False + assert replay["json_path"] == observed["json_path"] + else: + for action in (read, lambda: refresh(registry, runtime, token, first_delivery=True)): + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + action() + assert rejected.value.code == "checkpoint_commit_unknown" + # A new Turn identity cannot bypass an unresolved append for the same + # Agent/Todo. This check precedes any new admission or token issuance. + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=TODO_ID, turn_instance_id="different-turn") + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + context_io._checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(runtime.resolve()), "identity": identity.as_dict(), + "check_other_attempts": True, + }) + assert rejected.value.code == "checkpoint_commit_unknown" + rows = [json.loads(line) for line in (runtime / "goals" / GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == (fault in {"after_index", "before_artifacts"}) + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_first_delivery_mcp.py b/tests/control_plane/test_first_delivery_mcp.py new file mode 100644 index 0000000000..d22c9c3900 --- /dev/null +++ b/tests/control_plane/test_first_delivery_mcp.py @@ -0,0 +1,92 @@ +"""Caller-authored direction, real CLI recovery, and one original settlement.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.status import parse_active_state_todos +from loopx.todos import add_goal_todo +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID, _write_fixture, _control, _run_cli + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("new_obligation", [False, True]) +def test_mcp_first_delivery_recovers_stale_direction_without_repeating_result(tmp_path, monkeypatch, provider, new_obligation): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = _write_fixture(tmp_path) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"] = {"kind": "fixture_v0", "status": "connected-delivery"} + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Verify the bounded output.", task_class="advancement_task", + claimed_by=AGENT_ID, continuation_policy="same_agent_non_delivery") + todo_id = added["todo_id"] + runtime = tmp_path / "runtime" + todos = parse_active_state_todos(state.read_text(encoding="utf-8")) + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, handoff_mode="soft_claim", todos=todos["agent_todos"]["items"]), + state_path=state, provider=provider) + rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--vision-summary", "Verify the bounded output.", "--vision-acceptance", "Output verification passes.", + "--no-global-sync", "--suppress-external-sinks") + assert rc == 0, json.dumps(baseline) + control = _control(registry) + intent = dict(todo_id=todo_id, agent_id=AGENT_ID, evidence="The bounded output passed verification.", + next_agent_todo="Verify the remaining bounded output.", first_delivery=True) + first = json.loads(control.complete_task(**intent)) + assert first["ok"] and first["stage"] == "result_review_pending", first + result_id = first["context"]["read_context_id"] + second = json.loads(control.complete_task(**intent, delivery_read_context_id=result_id)) + assert second["ok"] and second["stage"] == "direction_pending", second + assert second["context"]["basis"]["todo"]["status"] == "done" + assert second["settlement_complete"] is False + rc, status = _run_cli(registry, "status", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID) + assert rc == 0, status + observed = status["attention_queue"]["items"][0]["first_delivery_progress"] + assert observed["stage"] == "direction_pending" and observed["result_committed"] is True + assert observed["goal_completion_certified"] is False + assert status["attention_queue"]["items"][0]["recommended_action"] == observed["next_action"] + from loopx.status import collect_status + + # Dashboard and Lark projections call the shared collector, not the CLI's + # Agent decorator. An unscoped read must retain the same recovery message. + shared = collect_status(registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[state.parent], limit=10, goal_id=GOAL_ID, include_public_boundary_scan=False) + shared_item = shared["attention_queue"]["items"][0] + assert shared_item["first_delivery_progress"]["stage"] == "direction_pending" + assert shared_item["recommended_action"] == observed["next_action"] + direction_id = second["context"]["read_context_id"] + run_cli = control.run_cli + injected = [] + + def provider_call(args, **kwargs): + if new_obligation and args[:1] == ["refresh-state"] and not injected: + # Change the relevant basis before the native direction commit. + # The original result is already durable; only direction is retried. + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance update\nVerify an additional bounded output.\n", encoding="utf-8") + injected.append(True) + return run_cli(args, **kwargs) + + control.run_cli = provider_call + decision = dict(delivery_read_context_id=result_id, read_context_id=direction_id, + vision_unchanged_reason="The bounded output passed; the current frontier was reviewed.") + final = json.loads(control.complete_task(**intent, **decision)) + assert final["ok"] is (not new_obligation), json.dumps(final) + if new_obligation: + assert final["settlement"]["failed_stage"] == "durable_writeback", final + assert "checkpoint_read_context_stale" in final["settlement"]["reason"], final + control.run_cli = run_cli + reread = json.loads(control.review_task_vision(todo_id, AGENT_ID, first_delivery=True)) + assert reread["ok"], reread + decision["read_context_id"] = reread["read_context_id"] + decision["vision_unchanged_reason"] = "The updated acceptance was reviewed; the successor retains the additional work." + final = json.loads(control.complete_task(**intent, **decision)) + assert final["ok"], final + replay = json.loads(control.complete_task(**intent, **decision)) + assert replay["ok"] and replay["settlement_identity"] == final["settlement_identity"], replay + rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] + assert sum(row["classification"] == "quota_slot_spent" for row in rows) == 1 + assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == 1 diff --git a/tests/control_plane/test_first_delivery_observation.py b/tests/control_plane/test_first_delivery_observation.py new file mode 100644 index 0000000000..522ddec0fe --- /dev/null +++ b/tests/control_plane/test_first_delivery_observation.py @@ -0,0 +1,92 @@ +"""Historical observation faults cannot become cross-lane admission failures.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli + + +def _status(project, runtime, registry, agent=AGENT_ID): + rc, payload = _run_cli(registry, runtime, "status", "--goal-id", GOAL_ID, + "--agent-id", agent, cwd=project) + assert rc == 0 and payload["ok"], payload + return payload["attention_queue"]["items"][0] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("corruption", ["{", "[]", '{"purpose":"first_delivery","identity":[]}']) +def test_unenrolled_lane_retains_status_and_scoped_unavailable_evidence(tmp_path, monkeypatch, provider, corruption): + project, runtime, registry, state, read, _ = fixture(tmp_path, monkeypatch, provider) + read() # Existing supplement receipts remain ordinary feature-off history. + before = _status(project, runtime, registry) + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + prior = (state.read_bytes(), index.read_bytes()) + directory = runtime / "goals" / GOAL_ID / "checkpoint-contexts" + damaged = directory / (hashlib.sha256(b"historical-context").hexdigest() + ".json") + damaged.write_text(corruption, encoding="utf-8") + after = _status(project, runtime, registry) + assert after["recommended_action"] == before["recommended_action"] + progress = after["first_delivery_progress"] + assert progress["stage"] == "observation_unavailable" + assert progress["observation_errors"][0]["scope"] == "goal" + assert progress["observation_errors"][0]["receipt"] == damaged.stem + assert "settlement_identity" not in progress + assert "result_committed" not in progress # Unavailable does not assert absence. + assert progress["goal_completion_certified"] is False + assert (state.read_bytes(), index.read_bytes()) == prior + assert damaged.read_text(encoding="utf-8") == corruption + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_other_agent_observation_does_not_override_current_lane(tmp_path, monkeypatch, provider): + project, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + read() + peer = "independent-peer" + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["coordination"]["registered_agents"].append(peer) + registry.write_text(json.dumps(registration), encoding="utf-8") + before = _status(project, runtime, registry, peer) + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + receipt = context_io._receipt_path(runtime, identity) + receipt.write_text("{", encoding="utf-8") + after = _status(project, runtime, registry, peer) + assert after["recommended_action"] == before["recommended_action"] + assert after["first_delivery_progress"]["stage"] == "observation_unavailable" + # An intact other-Agent binding can be excluded even if its body is invalid. + receipt.write_text(json.dumps({"purpose": "first_delivery", "identity": identity.as_dict()}), encoding="utf-8") + assert context_io.pending_first_delivery_progress(runtime, GOAL_ID, peer) is None + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_enrolled_corruption_stays_unknown_and_cannot_admit_another_write(tmp_path, monkeypatch, provider): + project, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + read() + result = context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, + purpose="delivery_result") + rc, completed = _run_cli(registry, runtime, "todo", "complete", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, + "--delivery-read-context", result["read_context_id"], + "--task-lease-idempotency-key", f"checkpoint-{TODO_ID}", "--task-lease-expected-version", "1", + "--note", "Validated the selected output.", cwd=project) + assert rc == 0, json.dumps(completed) + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + damaged = context_io._receipt_path(runtime, identity) + damaged.write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = index.read_bytes() + progress = _status(project, runtime, registry)["first_delivery_progress"] + assert progress["stage"] == "operation_unknown" + assert progress["result_committed"] is True + assert progress["settlement_identity"] == identity.as_dict() + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + read() + assert rejected.value.code == "checkpoint_commit_unknown" + assert index.read_bytes() == before + assert damaged.read_text(encoding="utf-8") == "{" diff --git a/tests/control_plane/test_first_delivery_terminal.py b/tests/control_plane/test_first_delivery_terminal.py new file mode 100644 index 0000000000..ea596f7437 --- /dev/null +++ b/tests/control_plane/test_first_delivery_terminal.py @@ -0,0 +1,128 @@ +"""Optional enrollment must not obstruct ordinary terminal commits or replay.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted, LocalCoordinationAuthorityRejection +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.todos import complete_goal_todo +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.checkpoint_process import refresh +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count + + +def _complete(project, runtime, registry, *extra): + return _run_cli(registry, runtime, "todo", "complete", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, + "--task-lease-idempotency-key", f"checkpoint-{TODO_ID}", "--task-lease-expected-version", "1", + "--note", "Validated the selected output.", *extra, cwd=project) + + +def _authority(runtime): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + + +def _identity(): + return SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_protected_terminal_is_rejected_before_new_effects(tmp_path, monkeypatch, provider): + project, runtime, registry, state, _, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + context = context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, purpose="delivery_result") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry, "--no-follow-up", + "--delivery-read-context", context["read_context_id"]) + # CLI keeps the existing settlement prerequisite before reaching its owner. + assert rc == 1 and rejected["settlement_blocked_completion"] is True, rejected + with pytest.raises(LocalCoordinationAuthorityRejection) as failure: + complete_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), + goal_id=GOAL_ID, todo_id=TODO_ID, agent_id=AGENT_ID, + completion_turn_key=_identity().effect_id, completion_identity_source="turn_settlement", + delivery_read_context_id=context["read_context_id"], delivery_settlement_identity=_identity().as_dict(), + task_lease_idempotency_key=f"checkpoint-{TODO_ID}", task_lease_expected_version=1, + note="Validated the selected output.", no_followup=True) + assert failure.value.code == "delivery_no_followup_unsupported" + assert failure.value.payload["delivery_read_context"]["reread_required"] is False + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("history", ["none", "supplement", "damaged", "damaged_after_commit", "damaged_after_commit_missing_writeback"]) +def test_ordinary_completion_and_cold_replay_survive_optional_history(tmp_path, monkeypatch, provider, history): + project, runtime, registry, _, read, original = fixture(tmp_path, monkeypatch, provider) + receipt = context_io._receipt_path(runtime, _identity()) + if history != "none": + read() + if history == "damaged": + receipt.write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before_index = index.read_bytes() + rc, saved = _complete(project, runtime, registry) + assert rc == 0 and saved["completed"], json.dumps(saved) + committed = _authority(runtime) + assert next(todo for todo in committed["todos"] if todo["todo_id"] == TODO_ID)["status"] == "done" + if history.startswith("damaged_after_commit"): + receipt.write_text("{", encoding="utf-8") + if history == "damaged_after_commit_missing_writeback": + Path(original["json_path"]).unlink() + # Every CLI call is a fresh process against the same real provider/files. + rc, replay = _complete(project, runtime, registry) + assert rc == 0 and replay["idempotent_replay"], replay + assert _authority(runtime) == committed + assert index.read_bytes() == before_index + assert _spend_run_count(runtime) == 0 + if history.startswith("damaged"): + assert receipt.read_text(encoding="utf-8") == "{" + rc, conflict = _complete(project, runtime, registry, "--task-lease-idempotency-key", "different-request") + assert rc == 1, conflict + assert _authority(runtime) == committed + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("enrollment", ["direction", "result", "both"]) +def test_unreadable_enrollment_without_ordinary_writeback_stays_closed(tmp_path, monkeypatch, provider, enrollment): + project, runtime, registry, state, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + if enrollment != "result": + read() + context_io._receipt_path(runtime, _identity()).write_text("{", encoding="utf-8") + if enrollment != "direction": + context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, purpose="delivery_result") + context_io._receipt_path(runtime, _identity(), "delivery_result").write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry) + assert rc == 1, rejected + assert rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("uncertainty", ["writeback_damaged", "result_empty", "result_damaged", "enrolled_writeback"]) +def test_unreadable_enrollment_requires_verified_ordinary_history(tmp_path, monkeypatch, provider, uncertainty): + enrolled = uncertainty == "enrolled_writeback" + project, runtime, registry, state, read, original = fixture(tmp_path, monkeypatch, provider, first_delivery=enrolled) + token = read()["read_context_id"] + if enrolled: + refresh(registry, runtime, token, first_delivery=True) + context_io._receipt_path(runtime, _identity()).write_text("{", encoding="utf-8") + if uncertainty == "writeback_damaged": + Path(original["json_path"]).write_text("{", encoding="utf-8") + elif uncertainty.startswith("result_"): + context_io._receipt_path(runtime, _identity(), "delivery_result").write_text( + "" if uncertainty == "result_empty" else "{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry) + assert rc == 1 and rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_heartbeat_agent_input.py b/tests/control_plane/test_heartbeat_agent_input.py index feda21e0bd..e1ebec20ef 100644 --- a/tests/control_plane/test_heartbeat_agent_input.py +++ b/tests/control_plane/test_heartbeat_agent_input.py @@ -112,6 +112,9 @@ def test_thin_agent_input_excludes_generator_and_embedded_command_duplicates() - } assert projected["schema_version"] == HEARTBEAT_AGENT_INPUT_SCHEMA_VERSION assert projected["task_body"] == generated["task_body"] + assert "loopx-project" in projected["task_body"] + assert "loopx-self-repair" in projected["task_body"] + assert "Failed preflight/guard: no work/spend" in projected["task_body"] assert projected["interface_budget"] == { "mode": "thin", "budget_char_count": generated["interface_budget"]["budget_char_count"], diff --git a/tests/control_plane/test_heartbeat_notification_rule.py b/tests/control_plane/test_heartbeat_notification_rule.py index e3e47ecca2..7ae140baef 100644 --- a/tests/control_plane/test_heartbeat_notification_rule.py +++ b/tests/control_plane/test_heartbeat_notification_rule.py @@ -49,14 +49,14 @@ def test_short_rule_qualifies_dont_notify_as_output_only() -> None: assert "OUTPUT only" in rule # The old ambiguous mapping must be gone. assert "DONT_NOTIFY=quiet." not in rule - assert "heartbeat_recommendation.agent_must_attempt" in rule assert "execution_obligation.must_attempt_work" in rule + # Guidance mirrors the obligation in quota but is not another authority. + assert "heartbeat_recommendation.agent_must_attempt" not in rule def test_shared_rule_keeps_projection_repair_and_quiet_boundary() -> None: rule = HEARTBEAT_NOTIFICATION_RULE_SHORT - assert "Missing NOTIFY action: user Todo unprojected" in rule - assert "repair LoopX state projection" in rule + assert "Missing NOTIFY action: repair unprojected user Todo" in rule assert "under DONT_NOTIFY repair internally" in rule assert "true=work+writeback" in rule assert "only false permits no-op" in rule @@ -83,14 +83,13 @@ def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: ) for renderer in (render_thin_heartbeat_task_body, render_brief_heartbeat_task_body): body = renderer(**kwargs) - assert "heartbeat_recommendation.agent_must_attempt" in body assert "execution_obligation.must_attempt_work" in body assert "OUTPUT only" in body - assert "repair LoopX state projection" in body + assert "repair unprojected user Todo" in body assert "under DONT_NOTIFY repair internally" in body # A bare "DONT_NOTIFY=quiet" no-op mapping must never appear in the prompt. assert "DONT_NOTIFY=quiet." not in body - assert "After committed writeback: same-turn checkpoint-context" in body + assert "Committed writeback lacks vision: same-turn checkpoint-context+evidence" in body assert "Missing vision: same-turn checkpoint-context" not in body @@ -104,10 +103,10 @@ def test_all_heartbeat_modes_scope_no_spend_to_observation() -> None: ) body = payload["task_body"] assert "Exact monitor settlement=no refresh/spend; auxiliary poll: continue work." in body - assert "Admitted work: settlement_plan" in body - assert "settlement_plan even if artifacts unchanged" in body + assert "Admitted work, even if artifacts unchanged" in body + assert "cli_channel.settlement_plan.ordered_steps" in body assert "no-change=surface_only/no spend" not in body - assert "After committed writeback: same-turn checkpoint-context" in body + assert "Committed writeback lacks vision: same-turn checkpoint-context+evidence" in body def test_todo_closeout_does_not_invent_successors_or_skip_turn_settlement(tmp_path) -> None: diff --git a/tests/control_plane/test_heartbeat_registry_route.py b/tests/control_plane/test_heartbeat_registry_route.py index 2ae287f457..35ece6e94f 100644 --- a/tests/control_plane/test_heartbeat_registry_route.py +++ b/tests/control_plane/test_heartbeat_registry_route.py @@ -225,6 +225,26 @@ def test_cli_generated_guard_selection_and_settlement_ignore_conflicting_cwd_reg _route(step["command_template"], registry, runtime) assert cli._spend_run_count(runtime) == 0 + # New default admissions validate the result before a post-result direction. + # Both returned read and write routes must survive the conflicting cwd too. + code, completed = _execute([ + "--registry", str(registry), "--runtime-root", str(runtime), + "todo", "complete", "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID, + "--note", "Selected candidate independently verified.", + "--next-agent-todo", "Verify the remaining selected-authority work.", + ], project) + assert code == 0 and completed["completed"], json.dumps(completed) + code, direction = _execute([ + "--registry", str(registry), "--runtime-root", str(runtime), + "checkpoint-context", "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID, + "--purpose", "first_delivery", + ], project) + assert code == 0 and direction["basis"]["todo"]["status"] == "done", direction + assert direction["settlement_identity"] == identity + assert cli._spend_run_count(runtime) == 0 + refresh = next(c for c in channel["next_cli_actions"] if "refresh-state" in c) replacements = { "": "validated_progress", "": "implementation", @@ -236,9 +256,10 @@ def test_cli_generated_guard_selection_and_settlement_ignore_conflicting_cwd_reg "--vision-state", "vision_on_track", "--vision-summary", "Keep validating the selected work.", "--vision-acceptance", "The same Turn settles once against the selected authority.", + "--checkpoint-read-context", direction["read_context_id"], "--no-global-sync", "--suppress-external-sinks", ], project) - assert code == 0 and refreshed["settlement_result"]["ok"], refreshed + assert code == 0 and refreshed["settlement_result"]["ok"], json.dumps(refreshed) spend = next(c for c in channel["next_cli_actions"] if "spend-slot" in c) for replay in (False, True): code, spent = _execute(shlex.split(spend)[1:], project) diff --git a/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py b/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py index 5edb7bb934..bebc181794 100644 --- a/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py +++ b/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py @@ -1,20 +1,4 @@ -"""The host-adapter settlement plan step order has one registered vocabulary. - -`loopx/control_plane/turn_driver/host_todo_completion.ts` authors the ordered -provider plan and `loopx/control_plane/host_adapter_settlement.py` validates the -plan it receives against the same sequence. Until now the two sequences were -private, hand-kept copies: the TypeScript one was a module-local `const`, the -Python one a module-local `_STEP_KINDS`. Neither was exported, so the -repository's semantic inventory could not see either carrier, and neither -`loopx/semantics/vocabulary_v0.json` nor the drift smoke could tie them -together. A divergence between them would have surfaced at runtime as -`TypeScript host provider step order mismatch`, after the plan had already been -accepted and partially executed. - -Both sequences are now registered owners of `host_adapter_settlement_step_kind`, -so the drift smoke checks each of them against the registry on every run, and -the tests below check the two against each other directly. -""" +"""Registered Host adapter step kinds and legal phase-specific plan orders.""" from __future__ import annotations @@ -25,7 +9,7 @@ import pytest from loopx.control_plane.host_adapter_settlement import ( - HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE, + HOST_ADAPTER_SETTLEMENT_STEP_KINDS, _decode_provider_steps, ) @@ -46,9 +30,9 @@ def _typescript_step_kinds() -> tuple[str, ...]: return tuple(re.findall(r'"([^"]+)"', match.group(1))) -def test_both_runtimes_carry_the_same_step_sequence() -> None: +def test_both_runtimes_carry_the_same_step_kinds() -> None: """The plan the TypeScript owner writes must satisfy the Python transport.""" - assert _typescript_step_kinds() == tuple(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) + assert _typescript_step_kinds() == tuple(HOST_ADAPTER_SETTLEMENT_STEP_KINDS) def test_the_registry_entry_names_both_owners_and_the_same_values() -> None: @@ -56,12 +40,12 @@ def test_the_registry_entry_names_both_owners_and_the_same_values() -> None: registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) entry = registry["vocabularies"][VOCABULARY] assert entry["owners"]["python"] == ( - "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE" + "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" ) assert entry["owners"]["typescript"] == ( "loopx/control_plane/turn_driver/host_todo_completion.ts::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" ) - assert entry["values"] == list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) + assert entry["values"] == list(HOST_ADAPTER_SETTLEMENT_STEP_KINDS) def _plan(step_kinds: list[str]) -> dict: @@ -77,14 +61,24 @@ def _plan(step_kinds: list[str]) -> dict: return {"provider_id": "loopx_cli", "kind": "ordered_cli_sequence", "steps": steps} -def test_the_transport_accepts_the_registered_order() -> None: - steps = _decode_provider_steps(_plan(list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE))) - assert [step.step_kind for step in steps] == list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) - - -def test_the_transport_rejects_a_plan_whose_step_order_drifts() -> None: +@pytest.mark.parametrize("sequence", [ + ["guard", "delivery_context"], + ["guard", "lifecycle_completion", "direction_context"], + ["guard", "lifecycle_completion", "durable_writeback", "quota_spend"], + ["guard", "lifecycle_completion", "durable_writeback", "quota_spend", "terminal_closeout"], +]) +def test_the_transport_accepts_each_phase_order(sequence) -> None: + steps = _decode_provider_steps(_plan(sequence)) + assert [step.step_kind for step in steps] == sequence + + +@pytest.mark.parametrize("sequence", [ + ["delivery_context", "guard"], + ["guard", "direction_context", "lifecycle_completion"], + ["lifecycle_completion", "guard", "durable_writeback", "quota_spend"], + ["guard", "lifecycle_completion", "quota_spend", "durable_writeback", "terminal_closeout"], +]) +def test_the_transport_rejects_a_plan_whose_step_order_drifts(sequence) -> None: """The tripwire the registry now backs: a reordered plan is refused, not run.""" - drifted = list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) - drifted[0], drifted[1] = drifted[1], drifted[0] with pytest.raises(RuntimeError, match="step order mismatch"): - _decode_provider_steps(_plan(drifted)) + _decode_provider_steps(_plan(sequence)) diff --git a/tests/control_plane/test_quota_authority_settlement_journey.py b/tests/control_plane/test_quota_authority_settlement_journey.py index 244dfb9da1..242eb11e3f 100644 --- a/tests/control_plane/test_quota_authority_settlement_journey.py +++ b/tests/control_plane/test_quota_authority_settlement_journey.py @@ -63,6 +63,22 @@ def _guard(project: Path, runtime: Path, registry: Path, *, turn_id: str = cli.T ) +def _assert_incomplete_goal_guard(project, runtime, registry, *, code_name): + code, guard = _guard(project, runtime, registry) + assert code != 0 and guard["should_run"] is False, guard + assert guard["error_code"] == code_name, guard + assert "loopx-project" in guard["recommended_action"], guard + assert cli._spend_run_count(runtime) == 0 + assert cli._heartbeat_receipt_count(runtime, cli.TURN_ID) == 1 + assert not list((runtime / "goals" / cli.GOAL_ID / "checkpoint-contexts").glob("*.json")) + readback = read_heartbeat_settlement( + runtime, goal_id=cli.GOAL_ID, agent_id=cli.AGENT_ID, + todo_id=cli.TODO_ID, turn_instance_id=cli.TURN_ID, + ) + assert readback is not None and readback.identity.value is not None + return readback.identity.value.as_dict() + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("status", ["done", "blocked"]) def test_stale_markdown_cannot_admit_terminal_or_blocked_work(tmp_path, provider, status): @@ -81,14 +97,28 @@ def test_stale_markdown_cannot_admit_terminal_or_blocked_work(tmp_path, provider @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_exact_selection_reaches_work_beyond_display_limits(tmp_path, provider): project, runtime, registry, path, prefix = _source(tmp_path, provider=provider) - # Goal intent remains readable, while its bounded Todo display cannot - # supply the exact candidate from the canonical provider. - path.write_text(prefix) + original = None + if provider != "legacy": + path.unlink() + # Native Todo readback survives display loss. It cannot supply the + # separately owned Goal narrative that disappeared with the whole file. + listed = list_goal_todos(registry_path=registry, goal_id=cli.GOAL_ID, + runtime_root_arg=str(runtime), todo_id=cli.TODO_ID) + assert listed["todo"]["todo_id"] == cli.TODO_ID + original = _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_unavailable", + ) + # The source owner restores the exact saved non-Todo document, without + # repopulating its Todo display or rewriting canonical authority. + path.write_text(prefix) code, guard = _guard(project, runtime, registry) assert code == 0, guard assert guard["decision"] == "run" assert guard["selected_todo"]["todo_id"] == cli.TODO_ID assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == cli.TODO_ID + if original is not None: + assert guard["heartbeat_receipt"]["settlement_identity"] == original + assert cli._heartbeat_receipt_count(runtime, cli.TURN_ID) == 1 def test_unmigrated_goal_cannot_select_work_from_markdown(tmp_path): @@ -233,39 +263,65 @@ def test_semantic_progress_replay_keeps_original_turn_after_todo_wait( "--status", waiting_status, "--resume-when", resume_when, cwd=project, ) assert code == 0 and updated["ok"] is True, json.dumps(updated) - code, recovery = cli._run_cli( - registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, - "--turn-instance-id", cli.TURN_ID, cwd=project, + protected_paths = [ + runtime / "goals" / cli.GOAL_ID / "runs/index.jsonl", + *list((runtime / "goals" / cli.GOAL_ID / "checkpoint-contexts").glob("*.json")), + ] + before_rejection = {path: path.read_bytes() if path.exists() else None + for path in protected_paths} + log_path = rollout_event_log_path(runtime, cli.GOAL_ID) + original_log = log_path.read_bytes() + code, refreshed = _refresh( + project, runtime, registry, + "--vision-state", "vision_on_track", "--vision-summary", "Continue current eligible work.", + "--vision-acceptance", "The waiting Todo remains open; the next Turn selects fresh work.", ) - assert code == 0, recovery - channel = recovery["interaction_contract"]["cli_channel"] - plan = channel.get("settlement_plan") - assert plan is not None, recovery - assert plan["identity"] == original - assert not recovery["normal_delivery_allowed"] - assert not recovery.get("selected_todo") - assert channel["spend_allowed_now"] is False - commands = {step["kind"]: step["command_template"] for step in plan["ordered_steps"]} - from loopx.control_plane.quota.turn_envelope import build_turn_envelope - - envelope = build_turn_envelope(recovery) - assert envelope["writeback"]["settlement_plan"] == plan - code, refused = cli._run_cli( - registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, - "--todo-id", "todo_ready_0", "--turn-instance-id", cli.TURN_ID, cwd=project, + assert code != 0, refreshed + assert cli._spend_run_count(runtime) == 0 + assert {path: path.read_bytes() if path.exists() else None + for path in protected_paths} == before_rejection + # Explicitly requested sink suppression is a separate conservative pause; + # the existing contract retains it even if the later direction is rejected. + # No work, Vision, refresh or spend event may be appended with that pause. + after_log = log_path.read_bytes() + assert after_log.startswith(original_log) + appended = [json.loads(line) for line in after_log[len(original_log):].splitlines()] + assert len(appended) == 1 + pause = appended[0] + assert (pause["event_kind"], pause["status"]) == ("refresh_external_delivery", "paused") + assert (pause["goal_id"], pause["agent_id"], pause["todo_id"], pause["run_id"]) == ( + cli.GOAL_ID, cli.AGENT_ID, cli.TODO_ID, cli.TURN_ID, ) - assert code == 1 and refused["error_code"] == "heartbeat_receipt_identity_conflict", refused - refresh = commands["durable_writeback"].replace( - "", "validated_progress", - ).replace("", "implementation").replace("", "outcome_progress") - refresh += ( - " --vision-state vision_on_track --vision-summary 'Continue current eligible work.'" - " --vision-acceptance 'The waiting Todo remains open; the next Turn selects fresh work.'" - " --no-global-sync --suppress-external-sinks" + assert pause.get("goal_ref") is None + import hashlib + expected_key = hashlib.sha256(json.dumps([ + "refresh_external_delivery_v0", original["effect_id"], None, + ], separators=(",", ":")).encode()).hexdigest() + assert pause["details"] == { + "schema_version": "refresh_external_delivery_v0", + "settlement_effect_id": original["effect_id"], + "replan_obligation_id": None, "state": "paused", "resume_key": expected_key, + } + code, retry_rejected = _refresh( + project, runtime, registry, + "--vision-state", "vision_on_track", "--vision-summary", "Continue current eligible work.", + "--vision-acceptance", "The waiting Todo remains open; the next Turn selects fresh work.", ) - code, refreshed = _execute(refresh, project, runtime, registry) + assert code != 0, retry_rejected + assert log_path.read_bytes() == after_log + assert {path: path.read_bytes() if path.exists() else None + for path in protected_paths} == before_rejection + # Preserve the validated nonterminal outcome, then judge against the new + # native wait/frontier using this original Turn's post-result read. + code, refreshed = _refresh(project, runtime, registry) + assert code == 0, refreshed + assert refreshed["vision_checkpoint"]["satisfied"] is False + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + checkpoint_args = cli._default_checkpoint_args( + registry, runtime, binding, tmp_path, purpose="supplement_checkpoint", + ) + code, refreshed = _refresh(project, runtime, registry, *checkpoint_args) assert code == 0, refreshed assert refreshed["vision_checkpoint"]["delivery_boundary"] == "semantic_closeout" assert refreshed["vision_checkpoint"]["satisfied"] is True @@ -490,12 +546,10 @@ def run(*args): assert code == 0 and migrated["status"] == "applied", migrated assert Path(migrated["backup_path"]).is_file() assert migrated["execution_authority_granted"] is False - goal_context = path.read_text().split("## Agent Todo")[0] - path.write_text(goal_context + "## Agent Todo\n\n") - - code, guard = _guard(project, runtime, registry) - assert code == 0 and guard["decision"] == "run", guard - assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == cli.TODO_ID + path.unlink() # Native Todo writes remain independent of the old display. + original = _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_unavailable", + ) update = ("todo", "update", "--todo-id", cli.TODO_ID, "--agent-id", cli.AGENT_ID, "--text", "Continue after policy migration") # A migrated owner may edit copy, but migration supplies no execution proof. @@ -512,9 +566,40 @@ def run(*args): code, updated = run(*update, "--task-lease-idempotency-key", lease_key, "--task-lease-expected-version", version) assert code == 0 and updated["ok"] is True, updated + assert path.exists() # Native projection repairs only its own Todo sections. + assert 'goal_source_completeness: "todo_projection_only"' in path.read_text() + assert _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_incomplete", + ) == original + # The exact historical codec has the same incomplete meaning, even though + # earlier releases did not persist its new typed marker. + path.write_text(path.read_text().replace('goal_source_completeness: "todo_projection_only"\n', "")) + assert _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_incomplete", + ) == original + # A skeleton for another Goal is still incomplete, rather than becoming a + # complete source merely because its ID differs from the current caller. + path.write_text(path.read_text().replace( + f'goal_id: "{cli.GOAL_ID}"', 'goal_id: "another-goal"', + )) + assert _assert_incomplete_goal_guard( + project, runtime, registry, code_name="checkpoint_goal_source_incomplete", + ) == original + path.write_text(prefix) # Restore actual owner prose; no inferred Goal data. + code, guard = _guard(project, runtime, registry) + assert code == 0 and guard["decision"] == "run", guard + assert guard["heartbeat_receipt"]["settlement_identity"] == original + assert cli._heartbeat_receipt_count(runtime, cli.TURN_ID) == 1 code, refreshed = _refresh(project, runtime, registry) assert code == 0, refreshed + if refreshed["vision_checkpoint"]["satisfied"] is False: + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + code, refreshed = _refresh(project, runtime, registry, *cli._default_checkpoint_args( + registry, runtime, binding, tmp_path, purpose="supplement_checkpoint", + )) + assert code == 0 and refreshed["vision_checkpoint"]["satisfied"] is True, refreshed command = refreshed["settlement_owed"]["command"] code, spent = _execute(command, project, runtime, registry) assert code == 0 and spent["appended"] is True, spent diff --git a/tests/control_plane/test_quota_cli_projection.py b/tests/control_plane/test_quota_cli_projection.py index fdf7fd5937..0301185f4a 100644 --- a/tests/control_plane/test_quota_cli_projection.py +++ b/tests/control_plane/test_quota_cli_projection.py @@ -305,6 +305,23 @@ def _items(count: int, *, prefix: str) -> list[dict[str, object]]: ] +def test_disabled_recall_compacts_configuration_only_and_keeps_authority_flags() -> None: + recall = {"status": "disabled", "context": None, "provider_call_count": 0, + "grants_new_action_authority": False, "fail_open": True, + "experiment": {"enabled": False, "automatic_ingest": False, + "config_runtime_route": {"runtime_scope": "project_runtime"}, "host_coverage": ["fixture"]}} + payload = {"goal_id": "fixture", "reward_memory_recall": recall} + compact = compact_quota_should_run_cli_payload(payload)["reward_memory_recall"] + assert compact["provider_call_count"] == 0 and compact["grants_new_action_authority"] is False + assert compact["experiment"]["automatic_ingest"] is False + assert compact["experiment"]["detail_ref"] == "reward-memory experiment-status --goal-id fixture" + assert "host_coverage" not in compact["experiment"] + assert compact_quota_should_run_cli_payload(payload, include_todo_summary_detail=True)["reward_memory_recall"] == recall + for status, enabled in (("provider_unavailable", False), ("disabled", True), ("recalled", True)): + complete = {**recall, "status": status, "experiment": {**recall["experiment"], "enabled": enabled}} + assert compact_quota_should_run_cli_payload({**payload, "reward_memory_recall": complete})["reward_memory_recall"] == complete + + def test_markdown_distinguishes_peer_blockers_from_native_admission() -> None: peer = {"mode": "task_scoped_peer", "execution_scope": "peer_agent_activation", "execution_state": "blocked", "task_selection": "canonical_claimed_candidates", diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index e768e659ab..c14f23ddf8 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -745,6 +745,33 @@ def _initialize_git_checkout(project: Path) -> None: ) +def _default_checkpoint_args(registry: Path, runtime: Path, binding: tuple[str, ...], root: Path, + *, purpose: str = "first_delivery") -> tuple[str, ...]: + """The fixture Agent consumes a real post-result read before authoring.""" + from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + rc, context = _run_cli(registry, runtime, "checkpoint-context", "--goal-id", GOAL_ID, + *binding, "--purpose", purpose) + assert rc == 0, context + decision = checkpoint_decision(context) + args = ("--checkpoint-read-context", context["read_context_id"]) + if decision.get("agent_vision"): + path = root / "checkpoint-vision.json" + path.write_text(json.dumps(decision["agent_vision"]), encoding="utf-8") + return (*args, "--agent-vision-json", str(path)) + return (*args, "--vision-unchanged-reason", decision["vision_unchanged_reason"]) + + +def _historical_turn(runtime: Path, turn_id: str = TURN_ID) -> None: + """Seed the persisted pre-default contract for historical recovery cases.""" + path = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + for row in rows: + if row.get("event_kind") == "quota_should_run" and row.get("run_id") == turn_id: + for field in ("result_freshness", "checkpoint_continuation"): + row.get("details", {}).pop(field, None) + path.write_text("\n".join(json.dumps(row, sort_keys=True) for row in rows) + "\n", encoding="utf-8") + + def _strip_heartbeat_workspace_causality(runtime: Path) -> None: log_path = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" events = [ @@ -799,6 +826,7 @@ def await_cycle(*, finished: bool) -> dict[str, Any]: encoding="utf-8", ) monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) monkeypatch.setenv("LOOPX_USAGE_PING_ENDPOINT", "http://127.0.0.1:1/v1/ping") for key in ("CI", "DO_NOT_TRACK", "LOOPX_USAGE_PING", "LOOPX_USAGE_POLICY"): @@ -1023,6 +1051,7 @@ def test_codex_app_refresh_stages_validated_memory_and_spend_finalizes_hook( ) assert complete_rc == 0, complete + checkpoint_args = _default_checkpoint_args(registry_path, runtime, binding, tmp_path) refresh_rc, refresh = _run_cli( registry_path, runtime, @@ -1031,6 +1060,7 @@ def test_codex_app_refresh_stages_validated_memory_and_spend_finalizes_hook( GOAL_ID, "--classification", "reward_memory_app_progress", + *checkpoint_args, "--delivery-batch-scale", "single_surface", "--delivery-outcome", @@ -1262,7 +1292,7 @@ def test_typed_outcome_gap_settles_exact_turn_without_becoming_progress( "turn-after-typed-blocker-settlement", cwd=project, ) - assert next_rc == 0, next_turn + assert next_rc == 0, json.dumps(next_turn, indent=2) assert next_turn["effective_action"] != "unsettled_host_turn_recovery" assert next_turn["selected_todo"]["todo_id"] == ALTERNATIVE_TODO_ID @@ -3049,6 +3079,7 @@ def test_prior_turn_with_several_receipts_recovers_once(tmp_path: Path) -> None: ) assert prior_rc == 0, prior assert prior["heartbeat_receipt"]["closeout_required"] is True + _historical_turn(runtime, prior_turn_id) # A real goal persists more than one guard per Turn: an identity upgrade # append and an older guard that never carried a binding. The newest bound @@ -3324,6 +3355,7 @@ def test_prior_host_closeout_reads_archived_exact_todo( ) assert rc == 0, prior assert prior["heartbeat_receipt"]["closeout_required"] is True + _historical_turn(runtime, prior_turn_id) rc, complete = _run_cli( @@ -3472,12 +3504,14 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( assert complete_replay["idempotent_replay"] is True assert complete_replay["settlement_result"]["ok"] is True + checkpoint_args = _default_checkpoint_args(registry_path, runtime, binding, tmp_path) refresh_args = ( "refresh-state", "--goal-id", GOAL_ID, "--classification", "validated_progress", + *checkpoint_args, "--delivery-batch-scale", "single_surface", "--delivery-outcome", @@ -3520,8 +3554,9 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( "turn-settlement-cli-successor", ) assert fresh_guard_rc == 0, fresh_guard - assert fresh_guard["selected_todo"]["todo_id"] == successor_id - assert fresh_guard["heartbeat_receipt"]["turn_instance_id"] == "turn-settlement-cli-successor" + assert fresh_guard["first_delivery_progress"]["stage"] == "settlement_pending" + assert fresh_guard["first_delivery_progress"]["settlement_identity"] == identity + assert fresh_guard["recommended_action"] == fresh_guard["first_delivery_progress"]["next_action"] spend_args = ( "quota", @@ -3592,19 +3627,16 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( runtime, *original_ack_hint["cli_args"], ) - # The intervening fresh_guard superseded this Turn for host writeback, - # even though its original delivery settlement still replays correctly. - assert ack_rc == 1, ack - assert ack["error_code"] == "SCHEDULER_FOLLOWUP_HEARTBEAT_RECEIPT_STALE" - assert ack["scheduler_state_mutated"] is False - assert ack["write_performed"] is False + # A pending original Turn was recovered rather than superseded by successor selection. + assert ack_rc == 0, ack + assert ack["scheduler_state_mutated"] is True + assert ack["scheduler_commit"]["written"] is True assert ack["appended"] is False - assert load_scheduler_state( - runtime, - goal_id=GOAL_ID, - agent_id=AGENT_ID, - state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, - ) is None + scheduler_path = Path(ack["scheduler_state_path"]) + saved = scheduler_path.read_bytes() + rc, replay_ack = _run_cli(registry_path, runtime, *original_ack_hint["cli_args"]) + assert rc == 0 and replay_ack["scheduler_commit"]["replayed"] is True, replay_ack + assert scheduler_path.read_bytes() == saved assert _spend_run_count(runtime) == 1 # Use the admitted successor Turn's hint. Another new Turn would compete @@ -3920,7 +3952,23 @@ def test_same_turn_identityless_guard_upgrades_and_settles_full_chain( "network", ) assert successor_guard_rc == 0, successor_guard - assert successor_guard["selected_todo"]["todo_id"] == successor_id + # Completing the Todo does not discharge the original Turn's checkpoint or + # spend. An identityless wake resumes that Turn before selecting new work. + assert successor_guard["heartbeat_receipt"]["settlement_identity"] == identity + assert not successor_guard.get("selected_todo") + assert _spend_run_count(runtime) == 0 + checkpoint_args = _default_checkpoint_args( + registry_path, runtime, binding, tmp_path, purpose="supplement_checkpoint", + ) + checkpoint_rc, checkpoint = _run_cli( + registry_path, runtime, "refresh-state", "--goal-id", GOAL_ID, + "--classification", "identity_upgrade_validated", + "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", *binding, *checkpoint_args, + "--no-global-sync", "--suppress-external-sinks", + ) + assert checkpoint_rc == 0, checkpoint + assert checkpoint["vision_checkpoint"]["satisfied"] is True spend_args = ( "quota", @@ -3944,6 +3992,13 @@ def test_same_turn_identityless_guard_upgrades_and_settles_full_chain( assert spend_replay["appended"] is False assert _spend_run_count(runtime) == 1 assert _heartbeat_receipt_count(runtime, TURN_ID) == 2 + successor_guard_rc, successor_guard = _run_cli( + registry_path, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--available-capability", "network", "--scan-path", str(project), + ) + assert successor_guard_rc == 0, successor_guard + assert successor_guard["selected_todo"]["todo_id"] == successor_id def test_agent_selects_one_bounded_action_before_delivery_receipt_binding( @@ -4690,7 +4745,7 @@ def test_visible_goal_unbound_spend_recovers_delivery_after_capability_replan( @pytest.mark.parametrize("profile", ["codex_app_ssh_goal", "codex_cli", "ark_managed_agent_goal"]) -def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( +def test_default_visible_goal_admission_still_rejects_unbound_manual_spend( tmp_path: Path, profile: str, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -4710,13 +4765,11 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( ) assert guard_rc == 0, guard actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] - assert len(actions) == 1 - if profile == "codex_app_ssh_goal": - assert actions[0].endswith("--begin-turn") - else: - assert "--turn-instance-id" in actions[0] - assert "--begin-turn" not in actions[0] - assert all("spend-slot" not in action for action in actions) + assert len(actions) == 2 + identity = guard["heartbeat_receipt"]["settlement_identity"] + assert "refresh-state" in actions[0] and "quota spend-slot" in actions[1] + assert all(f"--turn-instance-id {identity['turn_instance_id']}" in action for action in actions) + # Default admission does not authorize an unbound manual spend. spend_rc, spend = _run_cli( registry_path, @@ -4740,17 +4793,15 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert spend["delivery_workspace_causality"] is None assert _spend_run_count(runtime) == 0 - # Execute the projected re-entry, filling only the host-owned identity. - command = actions[0].replace( - "", TURN_ID, - ) - bound_rc, bound = _run_generated_cli(command, registry_path=registry_path) + # Re-observe the automatically admitted binding; do not execute placeholder + # work or mint a different Turn during identity qualification. + bound_rc, bound = _run_cli(registry_path, runtime, "quota", "should-run", + "--runtime-profile", profile, "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", identity["turn_instance_id"], + "--scan-path", str(project)) assert bound_rc == 0, bound plan = bound["interaction_contract"]["cli_channel"]["settlement_plan"] - assert plan["identity"]["todo_id"] == TODO_ID - assert plan["identity"]["agent_id"] == AGENT_ID - if profile != "codex_app_ssh_goal": - assert plan["identity"]["turn_instance_id"] == TURN_ID + assert plan["identity"] == identity assert [step["kind"] for step in plan["ordered_steps"]] == [ "validation", "durable_writeback", "quota_spend", "terminal_closeout", ] @@ -6859,7 +6910,7 @@ def test_todoless_blocked_replan_settles_read_only_external_evidence_without_wor assert replay.get("unsettled_host_turn_recovery") is None -def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( +def test_default_visible_goal_todoless_replan_replays_original_admission( tmp_path: Path, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -6883,15 +6934,14 @@ def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( assert unbound["decision"] == "autonomous_replan_required", unbound assert unbound.get("selected_todo") is None, unbound actions = unbound["interaction_contract"]["cli_channel"]["next_cli_actions"] - assert len(actions) == 1 - assert actions[0].endswith("--begin-turn") - assert "refresh-state" not in actions[0] - assert "spend-slot" not in actions[0] - - bound_rc, bound = _run_generated_cli( - actions[0], - registry_path=registry_path, - ) + assert len(actions) == 2 + original = unbound["heartbeat_receipt"]["settlement_identity"] + assert "refresh-state" in actions[0] and "quota spend-slot" in actions[1] + assert all(f"--turn-instance-id {original['turn_instance_id']}" in action for action in actions) + bound_rc, bound = _run_cli(registry_path, runtime, "quota", "should-run", + "--runtime-profile", "codex_app_ssh_goal", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--scan-path", str(project), + "--turn-instance-id", original["turn_instance_id"]) assert bound_rc == 0, bound assert bound["decision"] == "autonomous_replan_required", bound @@ -6900,7 +6950,8 @@ def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( identity = bound["heartbeat_receipt"]["settlement_identity"] assert identity["binding_kind"] == "autonomous_replan" assert identity["replan_obligation_id"] == obligation_id - assert identity["turn_instance_id"].startswith("guided-start:") + assert identity == original + assert bound["heartbeat_receipt"]["status"] == "replayed" cli_channel = bound["interaction_contract"]["cli_channel"] assert cli_channel["settlement_plan"]["identity"] == identity assert len(cli_channel["next_cli_actions"]) == 2 @@ -7900,6 +7951,7 @@ def frozen_completion_clock(argv, *args, **kwargs): TURN_ID, ) assert guard_rc == 0, guard + _historical_turn(runtime) assert guard["heartbeat_receipt"]["delivery_workspace_causality"] == { "schema_version": "delivery_workspace_causality_v0", "todo_id": TODO_ID, @@ -8082,7 +8134,7 @@ def frozen_completion_clock(argv, *args, **kwargs): sidecars = runtime / "goals" / GOAL_ID / "post_writeback_hooks" terminal_sidecars = [ path for path in sidecars.glob("*.json") - if json.loads(path.read_text())["source_receipt_id"] + if not path.name.endswith(".lock.holder.json") and json.loads(path.read_text())["source_receipt_id"] == trigger_intent["source_receipt_id"] ] assert len(terminal_sidecars) == 1 diff --git a/tests/control_plane/test_scheduler_execution_context.py b/tests/control_plane/test_scheduler_execution_context.py index 21063780c0..36c76649f3 100644 --- a/tests/control_plane/test_scheduler_execution_context.py +++ b/tests/control_plane/test_scheduler_execution_context.py @@ -12,6 +12,7 @@ ExecutionMode, HostSurface, SchedulerOwner, + SchedulerExecutionContextResolution, SchedulerRuntimeProfile, build_goal_runtime_continuation, render_scheduler_execution_args, @@ -1190,6 +1191,31 @@ def test_first_class_runtime_profiles_round_trip_to_compact_args( ) +def test_absent_scheduler_context_renders_no_host_even_after_resolution(): + absent = resolve_scheduler_execution_context(None) + assert not absent.ok and absent.context is None + assert render_scheduler_execution_args(scheduler_execution_context=None) == '' + assert render_scheduler_execution_args(scheduler_execution_context=absent) == '' + + +@pytest.mark.parametrize('context', [{}, {'host_surface': 'codex_app'}, + {'host_surface': 'codex_app', 'scheduler_owner': 'none', 'execution_mode': 'interactive'}]) +def test_invalid_explicit_scheduler_context_is_not_rendered_as_absent(context): + for value in [context, resolve_scheduler_execution_context(context)]: + with pytest.raises(ValueError, match='invalid scheduler_execution_context'): + render_scheduler_execution_args(scheduler_execution_context=value) + + +def test_typed_scheduler_resolution_without_supplied_metadata_retains_context(): + resolved = scheduler_execution_context_for_runtime_profile(SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT) + typed = SchedulerExecutionContextResolution(context=resolved.context) + assert typed.supplied is None and typed.ok + assert render_scheduler_execution_args(scheduler_execution_context=typed) == " --codex-app" + rejected = SchedulerExecutionContextResolution(context=None, errors=("invalid owner",)) + with pytest.raises(ValueError, match="invalid scheduler_execution_context"): + render_scheduler_execution_args(scheduler_execution_context=rejected) + + def test_codex_profile_does_not_admit_children_without_observed_spawn() -> None: context = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.CODEX_CLI_VISIBLE diff --git a/tests/control_plane/test_todo_completion_validation.py b/tests/control_plane/test_todo_completion_validation.py index eb9b685b45..c2d97779a4 100644 --- a/tests/control_plane/test_todo_completion_validation.py +++ b/tests/control_plane/test_todo_completion_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re import shlex import subprocess @@ -901,15 +902,16 @@ def test_private_validation_store_is_owner_only_and_detects_tampering( todo_id="todo_private_validation", ) - assert path.stat().st_mode & 0o777 == 0o600 - assert ( - read_completion_validation_declaration( - runtime_root=tmp_path, - goal_id=GOAL_ID, - todo_id="todo_private_validation", - ) - == declaration - ) + if os.name == "posix": + assert path.stat().st_mode & 0o777 == 0o600 + else: + from tests.control_plane.windows_private_acl import assert_private_acl + assert_private_acl(path) + assert read_completion_validation_declaration( + runtime_root=tmp_path, + goal_id=GOAL_ID, + todo_id="todo_private_validation", + ) == declaration assert digest == completion_validation_declaration_sha256(declaration) stored = json.loads(path.read_text(encoding="utf-8")) diff --git a/tests/control_plane/test_todo_projection_recovery.py b/tests/control_plane/test_todo_projection_recovery.py index 91f96c1e18..8c36e4a5ea 100644 --- a/tests/control_plane/test_todo_projection_recovery.py +++ b/tests/control_plane/test_todo_projection_recovery.py @@ -448,3 +448,16 @@ def test_objective_display_never_changes_canonical_authority(canonical_display, code, result = _cli(registry, "list", "--goal-id", "goal-a") assert code == 0 and next(row for row in result["todos"] if row["role"] == "agent")["todo_id"] == "todo_active", result assert _read(runtime) == before +def test_legacy_projection_codec_does_not_classify_owner_goal_prose(): + from loopx.control_plane.todos.projection_document import ( + is_legacy_todo_projection_skeleton, recovered_todo_projection_skeleton, + ) + document = recovered_todo_projection_skeleton("codec-goal") + legacy = document.replace('goal_source_completeness: "todo_projection_only"\n', "") + assert is_legacy_todo_projection_skeleton(legacy, "codec-goal") + assert not is_legacy_todo_projection_skeleton(legacy, "different-goal") + # Exact old codec adaptation does not use a phrase/heading denylist. + for narrative in ("## Objective\nReal owner intent.\n", + "## Objective\n" + "\n".join("> " + line for line in legacy.splitlines()) + "\n", + "## Objective\n```markdown\n" + legacy + "\n```\n"): + assert not is_legacy_todo_projection_skeleton(legacy + narrative, "codec-goal") diff --git a/tests/control_plane/test_windows_snapshot_privacy.py b/tests/control_plane/test_windows_snapshot_privacy.py new file mode 100644 index 0000000000..3e3e5b193d --- /dev/null +++ b/tests/control_plane/test_windows_snapshot_privacy.py @@ -0,0 +1,127 @@ +"""Native Windows private IO behavior; all state is disposable.""" +from pathlib import Path +import hashlib,json,os,subprocess,sys,tempfile +import pytest +from loopx.control_plane import effect_runtime as er +from loopx.control_plane.runtime import private_permissions as permissions +from loopx.control_plane.turn_driver import codex_sessions as sessions +from tests.control_plane.windows_private_acl import assert_private_acl + +pytestmark=pytest.mark.skipif(os.name!='nt',reason='Windows native DACL behavior') +R=Path.cwd() +NODE='''import {readPrivateJsonSnapshot,openPrivateResponseSink} from "MODULE"; +const a=JSON.parse(process.argv[1]);let effects=0; +try{if(a.op==='read') await readPrivateJsonSnapshot(a.ref); +else {const h=await openPrivateResponseSink(a.ref.path);await h.close();} +effects++;process.stdout.write(JSON.stringify({accepted:true,effects}));} +catch(e){process.stdout.write(JSON.stringify({accepted:false,effects,error:e.message}));} +'''.replace('MODULE',(R/'loopx/control_plane/effect_runtime_snapshot.ts').as_uri()) + +def broaden(path): + rights='*S-1-1-0:(OI)(CI)F' if path.is_dir() else '*S-1-1-0:F' + r=subprocess.run(['icacls.exe',str(path),'/grant',rights],capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stdout+r.stderr + +def typed(path,*,op='read',bad_digest=False): + raw=path.read_bytes();ref={'path':str(path),'byte_count':len(raw),'sha256':'0'*64 if bad_digest else hashlib.sha256(raw).hexdigest()} + r=subprocess.run([er._node_executable(),'--no-warnings','--experimental-strip-types','--input-type=module','-e',NODE,json.dumps({'op':op,'ref':ref})],env=dict(os.environ,LOOPX_EFFECT_RUNTIME_PYTHON_BIN=sys.executable),capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stdout+r.stderr + return json.loads(r.stdout) + +@pytest.fixture +def snapshot(tmp_path): + parent=Path(tempfile.mkdtemp(prefix='loopx-effect-',dir=tmp_path)) + permissions.protect_private_path(parent,directory=True) + path=parent/'response.json' + payload={'schema_version':er.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,'request_id':'owned-request','ok':True,'result':{'blob':'x'*2200000}} + path.write_text(json.dumps(payload,separators=(',',':')),encoding='utf8') + permissions.protect_private_path(path) + return path,payload + +def envelope(path): + raw=path.read_bytes() + return {'schema_version':er.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,'request_id':'owned-request','ok':True,'result_ref':{'byte_count':len(raw),'sha256':hashlib.sha256(raw).hexdigest()}} + +def pyread(path,env=None): + return er._read_local_snapshot_response(env or envelope(path),path,method='goal.checkpoint_read_context.commit',request_id='owned-request',timeout=10) + +def test_actual_producer_protects_before_socket_dispatch(monkeypatch): + seen=[] + class Connection: + def __enter__(self):return self + def __exit__(self,*args):return None + def settimeout(self,value):pass + def sendall(self,raw): + request=json.loads(raw);sink=Path(request['response_sink']);params=Path(request['params_ref']['path']) + for p in (sink.parent,sink,params):assert_private_acl(p) + assert len(params.read_bytes())>er.MAX_REQUEST_BYTES + seen.append(request) + def recv(self,size):return json.dumps({'schema_version':er.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,'request_id':'producer','ok':True,'result':{}}).encode()+b'\n' + monkeypatch.setattr(er.socket,'create_connection',lambda *a,**k:Connection()) + er._request_with_info({'token':'synthetic','host':'127.0.0.1','port':1},request_id='producer',method='goal.checkpoint_read_context.resolve',params={'large':'x'*2200000},timeout=2,large_local_snapshot=True) + assert len(seen)==1 and not Path(seen[0]['response_sink']).exists() + +def test_large_protected_bytes_and_digest(snapshot): + path,payload=snapshot;assert_private_acl(path.parent);assert_private_acl(path) + assert typed(path)=={'accepted':True,'effects':1} + assert pyread(path)==payload + assert typed(path,bad_digest=True)['accepted'] is False + bad=envelope(path);bad['result_ref']['sha256']='0'*64 + with pytest.raises(er.EffectRuntimeResponseAmbiguous):pyread(path,bad) + +@pytest.mark.parametrize('op',['read','sink']) +@pytest.mark.parametrize('target',['file','parent']) +def test_typed_rejects_broad_acl_before_effect(snapshot,op,target): + path,_=snapshot;broaden(path if target=='file' else path.parent) + result=typed(path,op=op) + assert result['accepted'] is False and result['effects']==0 + +@pytest.mark.parametrize('target',['file','parent']) +def test_python_postdispatch_acl_failure_is_ambiguous(snapshot,target): + path,_=snapshot;broaden(path if target=='file' else path.parent) + with pytest.raises(er.EffectRuntimeResponseAmbiguous) as error:pyread(path) + assert error.value.diagnostic_code=='runtime_response_ambiguous' + +def test_session_writer_protects_and_keeps_original_identity(tmp_path): + lineage={'goal_id':'isolated-privacy','agent_id':'fixture','todo_id':'original'} + path=sessions._session_path(tmp_path,lineage,'agent');path.parent.mkdir(parents=True) + permissions.protect_private_path(path.parent,directory=True);broaden(path.parent) + sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='original-session-id') + assert_private_acl(path.parent);assert_private_acl(path) + value=sessions.select_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent') + assert value['session_id']=='original-session-id' and value['agent_id']=='fixture' + assert value['todo_id']=='original' and value['session_scope']=='agent' + +@pytest.mark.parametrize('failure',['protect','replace']) +def test_session_publication_failure_keeps_prior_session(tmp_path,monkeypatch,failure): + lineage={'goal_id':'isolated-privacy','agent_id':'fixture','todo_id':'original'} + sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='original-session-id') + path=sessions._session_path(tmp_path,lineage,'agent');old=path.read_bytes() + def deny(*a,**k):raise PermissionError('injected native IO refusal') + if failure=='protect': + if not hasattr(sessions,'protect_private_path'):pytest.fail('Original session writer has no protection admission') + monkeypatch.setattr(sessions,'protect_private_path',deny) + else:monkeypatch.setattr(sessions.os,'replace',deny) + with pytest.raises(PermissionError):sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='replacement') + assert path.read_bytes()==old + assert not [x for x in path.parent.iterdir() if x.name.startswith('.'+path.name+'.')] + +def test_python_rejects_junction_parent(snapshot,tmp_path): + path,_=snapshot;link=tmp_path/'loopx-effect-junction' + cmd=['pwsh','-NoLogo','-NoProfile','-Command',"New-Item -ItemType Junction -Path $env:LX_LINK -Target $env:LX_TARGET | Out-Null"] + r=subprocess.run(cmd,env=dict(os.environ,LX_LINK=str(link),LX_TARGET=str(path.parent)),capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stderr + try: + other=link/path.name + assert typed(other)['accepted'] is False + with pytest.raises(er.EffectRuntimeResponseAmbiguous):pyread(other) + finally:os.rmdir(link) + +@pytest.mark.parametrize('helper',['missing','invalid']) +def test_typed_missing_os_fact_helper_rejects(snapshot,helper): + path,_=snapshot;raw=path.read_bytes();ref={'path':str(path),'byte_count':len(raw),'sha256':hashlib.sha256(raw).hexdigest()} + env=dict(os.environ);env.pop('LOOPX_EFFECT_RUNTIME_PYTHON_BIN',None) + if helper=='invalid':env['LOOPX_EFFECT_RUNTIME_PYTHON_BIN']=str(path.parent/'absent-python.exe') + r=subprocess.run([er._node_executable(),'--no-warnings','--experimental-strip-types','--input-type=module','-e',NODE,json.dumps({'op':'read','ref':ref})],env=env,capture_output=True,text=True,timeout=30) + assert r.returncode==0,r.stderr + result=json.loads(r.stdout);assert result['accepted'] is False and result['effects']==0 diff --git a/tests/control_plane/windows_private_acl.py b/tests/control_plane/windows_private_acl.py new file mode 100644 index 0000000000..43596f2a36 --- /dev/null +++ b/tests/control_plane/windows_private_acl.py @@ -0,0 +1,26 @@ +"""Independent OS readback for the Windows private IO tests.""" +from pathlib import Path +import subprocess +import os + + +def assert_private_acl(path: Path) -> None: + # PowerShell uses .NET's independent ACL reader; product IO uses Win32. + script = '''$ErrorActionPreference='Stop' + $p=$env:LOOPX_PRIVATE_IO_TEST_PATH + $acl=Get-Acl -LiteralPath $p + if(-not $acl.AreAccessRulesProtected){throw 'DACL is inherited'} + $owner=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $allowed=@($owner,'S-1-5-18','S-1-5-32-544') + foreach($ace in $acl.Access){ + $sid=$ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value + if($sid -notin $allowed -or $ace.IsInherited -or $ace.AccessControlType -ne 'Allow'){throw 'Unexpected private ACE'} + } + if($acl.Access.Count -ne 3){throw 'Private ACE set incomplete'} + ''' + result = subprocess.run( + ["pwsh", "-NoLogo", "-NoProfile", "-Command", script], + env=dict(os.environ, LOOPX_PRIVATE_IO_TEST_PATH=str(path)), + capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/control_plane_ts/automation_cadence.test.ts b/tests/control_plane_ts/automation_cadence.test.ts index 9629fe60dc..9a60ee98ca 100644 --- a/tests/control_plane_ts/automation_cadence.test.ts +++ b/tests/control_plane_ts/automation_cadence.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import test from "node:test"; +import {syncBuiltinESMExports} from "node:module"; import {mkdtemp, mkdir, rm, readFile, writeFile, readdir} from "node:fs/promises"; import {syncBuiltinESMExports} from "node:module"; import {tmpdir} from "node:os"; diff --git a/tests/control_plane_ts/checkpoint_commit_probe.ts b/tests/control_plane_ts/checkpoint_commit_probe.ts index 6c033f669f..de8222bfc6 100644 --- a/tests/control_plane_ts/checkpoint_commit_probe.ts +++ b/tests/control_plane_ts/checkpoint_commit_probe.ts @@ -1,6 +1,8 @@ /** Isolated process barriers around the production persistence methods. No * runtime test flag or alternate store implementation enters product code. */ import {existsSync, readFileSync, writeFileSync} from "node:fs"; +import fs from "node:fs"; +import {syncBuiltinESMExports} from "node:module"; import {join} from "node:path"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; @@ -13,6 +15,32 @@ let raw = ""; for await (const chunk of process.stdin) raw += chunk; const input = JSON.parse(raw) as {mode: string; barrier: string; provider: string; method: string; params: JsonObject; repeat?: boolean; fault?: string; provider_direct?: boolean}; +if (input.fault?.startsWith("after_")) { + const descriptors = new Map(); + const open = fs.openSync; + const flush = fs.fsyncSync; + fs.openSync = ((path, ...args) => { + const fd = open(path, ...args); + descriptors.set(fd, String(path)); + return fd; + }) as typeof fs.openSync; + fs.fsyncSync = fd => { + flush(fd); + const path = descriptors.get(fd) ?? ""; + const suffix = input.fault === "after_json" ? ".json" : input.fault === "after_markdown" ? ".md" : "index.jsonl"; + if (path.includes("runs") && path.endsWith(suffix)) process.exit(86); + }; + syncBuiltinESMExports(); +} +if (input.fault === "before_artifacts") { + const rename = fs.renameSync; + fs.renameSync = (from, to) => { + rename(from, to); + if (String(to).includes("checkpoint-contexts") && + JSON.parse(readFileSync(to, "utf8")).commit_attempt != null) process.exit(86); + }; + syncBuiltinESMExports(); +} const signal = (name: string, value: unknown = true) => writeFileSync(join(input.barrier, name), JSON.stringify(value)); const pause = () => { const deadline = Date.now() + 20000; @@ -40,9 +68,10 @@ if (input.mode === "checkpoint") { } else if (input.mode === "writer") { const original = prototype.commitAuthority; prototype.commitAuthority = async function(commit) { + const started = performance.now(); signal("writer-entered"); const result = await original.call(this as SqliteAuthorityStore & FileAuthorityStore, commit); - signal("provider-result", result); + signal("provider-result", {...result, elapsed_ms: performance.now() - started}); return result; }; } diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index da80428fd5..4f993288ac 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -1,8 +1,12 @@ +import {randomUUID} from "node:crypto"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; import assert from "node:assert/strict"; import test from "node:test"; -import {evaluateCheckpointReadContext as evaluate} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; +import {evaluateCheckpointReadContext as evaluate, evaluateResultWorkBinding as work, executeResultWorkBinding} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {settlementIdentity} from "../../loopx/control_plane/effect_program.ts"; +import {selectEffectiveHeartbeatReceipt, heartbeatReceiptFactFromEvent} from "../../loopx/control_plane/quota/heartbeat_receipt_identity.ts"; const identity = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; const facts = { @@ -23,6 +27,154 @@ const check = (receipt: unknown, overrides: JsonObject = {}) => evaluate({ phase: "check", identity, facts, read_context_id: "read-A", receipt, ...overrides, }); +test("Todo-only recovery cannot supply a complete Goal checkpoint basis", () => { + const incomplete = {...facts, frontmatter: {...facts.frontmatter, + goal_source_completeness: "todo_projection_only"}}; + for (const purpose of ["delivery_result", "first_delivery", "supplement_checkpoint"]) { + assert.equal(read({purpose, facts: incomplete, admitted_turn: true, + ...(purpose !== "supplement_checkpoint" ? {prior: null} : {}), + full_frontier: true, decision_scope: "goal"}).error_code, "checkpoint_goal_source_incomplete"); + } + assert.throws(() => read({facts: {...facts, frontmatter: {...facts.frontmatter, + goal_source_completeness: "unknown"}}}), /unsupported Goal source completeness/); + assert.equal(read().ok, true); // Existing complete documents need no new marker. + assert.equal(check(read().receipt, {facts: incomplete}).error_code, "checkpoint_goal_source_incomplete"); +}); + +test("new direction admission is explicit, historical absence stays historical", () => { + assert.equal(work({phase: "direction_policy", heartbeat_receipt: {details: {}}}).required, false); + assert.equal(work({phase: "direction_policy", heartbeat_receipt: {details: { + checkpoint_continuation: "current_agent_v1"}}}).required, true); + for (const invalid of [null, false, "future"]) assert.throws(() => work({phase: "direction_policy", + heartbeat_receipt: {details: {checkpoint_continuation: invalid}}}), /incomplete or unsupported/); +}); + +test("direction admission cannot be downgraded and replan ACK waits for a fresh read", () => { + const receipt = {event_kind: "quota_should_run", run_id: "turn", details: { + todo_id: "todo_fixture", checkpoint_continuation: "current_agent_v1"}}; + const entries = [receipt, {...receipt, details: {todo_id: "todo_fixture"}}] + .map(value => ({value, fact: heartbeatReceiptFactFromEvent(value)})); + assert.throws(() => selectEffectiveHeartbeatReceipt("goal", "agent", entries), /cannot be downgraded/); + const heartbeat_receipt = receipt; + const prior = {vision_checkpoint: {replan_stage: "awaiting_direction"}}; + assert.deepEqual(work({phase: "replan_stage", heartbeat_receipt, requested: true}), {deferred: true, qualify: false}); + assert.deepEqual(work({phase: "replan_stage", heartbeat_receipt, prior, has_read_context: true}), {deferred: false, qualify: true}); + assert.deepEqual(work({phase: "replan_stage", prior, has_read_context: true}), {deferred: false, qualify: false}); +}); + +test("pending closeout intent belongs to the original completed Turn", () => { + const todo = {status: "done", no_followup: false, completion_continuation: "active_goal", + completion_requested_continuation: "no_followup", completion_turn_key: "turn"}; + const request = {phase: "direction_terminal_intent", identity, todo}; + assert.equal(work(request).no_followup, true); + assert.equal(work({...request, identity: {...identity, effect_id: "other", turn_instance_id: "other"}}).no_followup, false); + assert.equal(work({...request, todo: {...todo, status: "open"}}).no_followup, false); + assert.equal(work({...request, todo: {...todo, completion_requested_continuation: "active_goal"}}).no_followup, false); + assert.throws(() => work({...request, todo: {...todo, completion_requested_continuation: "invalid"}}), /completion_continuation/); +}); + +test("direction retry uses the existing Host policy and a replaced read retains attempts", () => { + for (const count of [0, 1]) assert.equal(work({phase: "direction_dispatch", receipt: { + direction_host_attempt_count: count}}).attempt, count + 1); + assert.equal(work({phase: "direction_dispatch", receipt: {direction_stopped: true}}).error_code, "checkpoint_direction_stopped"); + assert.equal(work({phase: "direction_dispatch", receipt: {direction_host_attempt_count: -1}}).ok, false); + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", completion_turn_key: "turn"}); + const request = {purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal", facts: current}; + const first = read(request).receipt as JsonObject; + const replacement = read({...request, receipt: {...first, direction_host_attempt_count: 2, + direction_host_failure: {schema_version: "loopx_turn_host_failure_v0", kind: "executor_timeout", attempt: 2, + retryable: true, retry: {strategy: "same_configuration", max_attempts: 2, backoff_seconds: 10}}}, + read_context_id: "read-B"}).receipt as JsonObject; + assert.equal(replacement.direction_host_attempt_count, 2); + assert.equal(work({phase: "direction_dispatch", receipt: replacement}).ok, false); +}); + +test("default direction binds the post-result input and response without relabelling", () => { + assert.equal(read({purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal"}).error_code, "checkpoint_result_not_committed"); + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", completion_turn_key: "turn"}); + const direction = read({purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal", facts: current}); + assert.equal(direction.ok, true); + const decision = {read_context_id: "read-A", vision_unchanged_reason: "Current obligations preserve the route."}; + const request = {phase: "direction_response", identity, receipt: direction.receipt, decision}; + assert.equal(work(request).ok, true); + assert.equal(work({...request, decision: {...decision, read_context_id: "read-B"}}).error_code, + "checkpoint_direction_binding_mismatch"); + assert.equal(work({...request, identity: {...identity, agent_id: "another"}}).error_code, + "checkpoint_direction_binding_mismatch"); + assert.equal(work({...request, receipt: {...direction.receipt as JsonObject, decision_basis: {}}}).error_code, + "checkpoint_direction_basis_corrupt"); + const missing = read({purpose: "first_delivery", admitted_turn: true, prior: null, checkpoint_continuation: true, + full_frontier: true, decision_scope: "goal", facts: {...current, agent_vision: null}}); + assert.equal(work({...request, receipt: missing.receipt}).error_code, "checkpoint_direction_baseline_required"); + assert.equal(work({...request, receipt: missing.receipt, + decision: {read_context_id: "read-A", agent_vision: {vision_patch: {vision_summary: "Use current evidence."}}}}).ok, true); + const display = structuredClone(current); + Object.assign(display.todos[3], {index: 90, source_section: "Display"}); + display.frontmatter.updated_at = "later"; + assert.equal(check(direction.receipt, {purpose: "first_delivery", facts: display}).ok, true); + display.todos[3].status = "done"; + assert.equal(check(direction.receipt, {purpose: "first_delivery", facts: display}).error_code, "checkpoint_read_context_stale"); +}); + +test("default work input survives cold restore and validation cannot be relabelled", () => { + const result = read({purpose: "delivery_result", admitted_turn: true, prior: null, result_only: true}); + const receipt = result.receipt as JsonObject; + assert.deepEqual(work({phase: "restore", identity, receipt}).basis, result.basis); + const candidate = {artifact: "sha256:candidate-A"}; + const validation = {ok: true, contract: "verifier-A"}; + const binding = work({phase: "bind_validation", identity, receipt, candidate, validation}).binding; + const request = {phase: "check_validation", identity, receipt, candidate, validation, binding}; + assert.equal(work(request).ok, true); + for (const altered of [ + {receipt: {...receipt, read_context_id: "read-B"}}, + {candidate: {artifact: "sha256:candidate-B"}}, + {validation: {...validation, contract: "verifier-B"}}, + ]) assert.equal(work({...request, ...altered}).error_code, "delivery_validation_binding_mismatch"); + assert.equal(work({...request, identity: {...identity, agent_id: "another"}}).error_code, + "delivery_work_identity_mismatch"); + assert.equal(work({phase: "restore", identity, receipt: {...receipt, + work_basis: {...result.basis as JsonObject, todo: {text: "corrupt"}}}}).error_code, + "delivery_work_basis_corrupt"); +}); + +test("admission policy belongs to the owner and malformed history cannot become historical absence", () => { + const policy = "turn_result_freshness_required_v1"; + for (const authority of ["legacy_markdown", "file_v0", "sqlite_v0", "postgresql_v0"]) { + assert.deepEqual(work({phase: "admission", todo_id: "task", source_authority: authority}).policy, policy); + } + assert.equal(work({phase: "policy", heartbeat_receipt: {details: {result_freshness: policy}}}).required, true); + assert.equal(work({phase: "policy", heartbeat_receipt: {details: {}}}).required, false); + for (const malformed of [null, false, {required: false}, "future"]) { + assert.throws(() => work({phase: "policy", heartbeat_receipt: {details: {result_freshness: malformed}}}), + /incomplete or unsupported/); + } +}); + +test("original historical Turn and Goal incarnation do not inherit later admissions", () => { + const old = {event_kind: "quota_should_run", agent_id: "agent", run_id: "old", details: {todo_id: "task"}}; + const current = {...old, run_id: "new", details: {todo_id: "task", result_freshness: "turn_result_freshness_required_v1"}}; + const request = {phase: "legacy_admission", events: [old, current], todo_id: "task", agent_id: "agent", + completion_turn_key: "old", identity: {...identity, turn_instance_id: "old"}, goal_ref: null}; + assert.equal(work(request).required, false); + assert.equal(work({...request, completion_turn_key: null, identity: null}).error_code, "delivery_work_context_required"); + assert.equal(work({...request, events: [{...current, goal_ref: {goal_id: "goal", goal_instance_id: "another"}}]}).required, false); +}); + +test("successful result precedes current freshness while stop/execution ownership stays separate", () => { + const result = read({purpose: "delivery_result", admitted_turn: true, prior: null, result_only: true}); + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", completion_turn_key: "turn"}); + current.goal_prose = "Changed after successful result"; + assert.equal(check(result.receipt, {purpose: "delivery_result", facts: current}).result_committed, true); + Object.assign(current.todos[0], {completion_turn_key: "another-turn"}); + assert.equal(check(result.receipt, {purpose: "delivery_result", facts: current}).error_code, "checkpoint_read_context_stale"); +}); + test("read captures the exact task, transitive upstream results and goal acceptance", () => { const result = read(); const basis = result.basis as JsonObject; @@ -131,3 +283,155 @@ test("obligations cover the full frontier and a completed checkpoint cannot acqu assert.equal(read({prior: {vision_checkpoint: {decision: "patched", satisfied: true}}}).error_code, "checkpoint_context_not_missing"); }); + +test("first delivery has independent admission and cannot reuse a supplement receipt", () => { + const first = {purpose: "first_delivery", decision_scope: "agent_lane", prior: null, admitted_turn: true}; + const receipt = read(first).receipt; + assert.equal(check(receipt, first).ok, true); + assert.equal(read({...first, admitted_turn: false}).error_code, "checkpoint_first_delivery_not_admitted"); + assert.equal(read({...first, prior: {vision_checkpoint: {decision: "missing_required", satisfied: false}}}).ok, false); + assert.equal(check(receipt).ok, false); + assert.equal(check(read().receipt, first).ok, false); +}); + +test("first direction detects frontier membership while ignoring independent peer work", () => { + const first = {purpose: "first_delivery", decision_scope: "agent_lane", prior: null, admitted_turn: true}; + const source = {...facts, todos: [...facts.todos, {todo_id: "peer", claimed_by: "other", status: "open"}] as JsonObject[]}; + const receipt = read({...first, facts: source}).receipt; + const changed = structuredClone(source); + changed.todos[4].status = "done"; + assert.equal(check(receipt, {...first, facts: changed}).ok, true); + changed.todos.push({todo_id: "new", claimed_by: "agent", status: "open"}); + assert.deepEqual(check(receipt, {...first, facts: changed}).changed_components, ["frontier"]); + changed.todos.pop(); + changed.todos[4].claimed_by = "agent"; + assert.deepEqual(check(receipt, {...first, facts: changed}).changed_components, ["frontier"]); + const whole = read({...first, decision_scope: "goal", facts: source}).receipt; + assert.equal(check(whole, {...first, decision_scope: "agent_lane", facts: source}).error_code, + "checkpoint_read_context_scope_mismatch"); + assert.equal(check(whole, {...first, decision_scope: "goal", facts: changed}).ok, false); +}); + +test("first result and direction bind authority, lease expiry, purpose and unknown fields", () => { + for (const purpose of ["first_delivery", "delivery_result"]) { + const request = {purpose, decision_scope: "goal", prior: null, admitted_turn: true}; + const baseline = {...facts, execution_lease: {owner: "agent", active: true}, + source: {authority: "file_v0", store_identity: "store-1", registry_goal: {registered_agents: ["agent"]}}}; + const receipt = read({...request, facts: baseline}).receipt; + for (const change of [ + {...baseline, execution_lease: {owner: "agent", active: false}}, + {...baseline, execution_lease: {owner: "other", active: true}}, + {...baseline, source: {...baseline.source, store_identity: "store-2"}}, + {...baseline, source: {...baseline.source, registry_goal: {registered_agents: []}}}, + {...baseline, todos: baseline.todos.map((row, i) => i === 0 ? {...row, future_business_rule: "changed"} : row)}, + ]) assert.equal(check(receipt, {...request, facts: change}).error_code, "checkpoint_read_context_stale"); + assert.equal(check(receipt, {...request, purpose: purpose === "first_delivery" ? "delivery_result" : "first_delivery"}).ok, false); + } +}); + +test("an uncertain direction cannot acquire a replacement read or reexecute", () => { + const request = {purpose: "first_delivery", decision_scope: "goal", prior: null, admitted_turn: true}; + const receipt = {...read(request).receipt as JsonObject, commit_attempt: {json_path: "unindexed"}}; + for (const result of [read({...request, receipt}), check(receipt, request)]) { + assert.equal(result.error_code, "checkpoint_commit_unknown"); + assert.equal(result.reread_required, false); + } +}); + +test("explicit quota pause rejects new direction reads and cached checks while preserving result history", () => { + const current: JsonObject = structuredClone(facts); + Object.assign((current.todos as JsonObject[])[0], {status: "done", completion_turn_key: "turn"}); + current.source = {...facts.source, registry_goal: {quota: {compute: 1}}}; + const request = {purpose: "first_delivery", admitted_turn: true, prior: null, + checkpoint_continuation: true, full_frontier: true, decision_scope: "goal", facts: current}; + const admitted = read(request); + assert.equal(admitted.ok, true); + for (const compute of [0, -1]) { + const paused = {...current, source: {...facts.source, registry_goal: {quota: {compute}}}}; + for (const rejected of [read({...request, facts: paused}), + check(admitted.receipt, {...request, facts: paused})]) { + assert.equal(rejected.ok, false); + assert.equal(rejected.error_code, "checkpoint_goal_paused"); + assert.equal(rejected.reread_required, false); + } + } + assert.equal(check(admitted.receipt, request).ok, true); + assert.equal(read({...request, facts: {...current, source: facts.source}}).ok, true); + // This guard belongs to pending direction; historical result recovery remains + // with its existing result/receipt owner rather than being erased by a pause. + assert.equal(read({purpose: "delivery_result", admitted_turn: true, prior: null, + facts: {...facts, source: {...facts.source, registry_goal: {quota: {compute: 0}}}}}).ok, true); +}); + + +test("admitted native validation recovers its passed outcome and holds uncertain execution", () => { + const receipt = read({purpose: "delivery_result", admitted_turn: true, prior: null}).receipt as JsonObject; + const terminal = {actor_agent_id: "agent", lease_expected_version: 1, + requested_completion_turn_key: "effect", evidence: "candidate", validation_declaration: {validation_command_argv: ["verify"]}}; + const issued = {status: "execute_validation", validation_effect: {kind: "caller_validation", validation_argv: ["verify"]}}; + const begin = (saved: JsonObject, input = terminal, effect = issued) => work({phase: "begin_terminal_validation", + identity, receipt: saved, terminal_input: input, execution_result: effect}); + const pending = begin(receipt); + assert.equal(pending.effect, "run"); + assert.equal(begin(pending.receipt as JsonObject).error_code, "delivery_validation_outcome_unknown"); + const replacement = read({purpose: "delivery_result", admitted_turn: true, prior: null, receipt: pending.receipt}); + assert.equal(replacement.error_code, "delivery_validation_outcome_unknown"); + assert.equal(replacement.reread_required, false); + const updates = {validation_receipt: {passed: true, exit_code: 0, validation_declaration_sha256: "original"}}; + const finish = work({phase: "finish_terminal_validation", identity, receipt: pending.receipt, + terminal_input: terminal, execution_result: issued, updates}); + const passed = finish.receipt as JsonObject; + assert.deepEqual(begin(passed).updates, updates); + assert.equal(begin(passed).effect, "replay"); + for (const changed of [{...terminal, evidence: "replacement"}, {...terminal, actor_agent_id: "other"}, + {...terminal, lease_expected_version: 2}, {...terminal, validation_declaration: {validation_command_argv: ["replacement"]}}, + {...terminal, requested_completion_turn_key: "new-turn"}]) { + assert.equal(begin(passed, changed).error_code, "delivery_validation_binding_mismatch"); + } + assert.equal(begin({...passed, read_context_id: "replacement"}).error_code, "delivery_validation_binding_mismatch"); + assert.equal(begin(passed, terminal, {...issued, validation_effect: {...issued.validation_effect, validation_argv: ["replacement"]}}).error_code, + "delivery_validation_binding_mismatch"); + const corrupt = structuredClone(passed); + ((corrupt.terminal_validation_effect as JsonObject).updates as JsonObject).validation_receipt = {passed: true, exit_code: 5}; + assert.equal(begin(corrupt).error_code, "delivery_validation_receipt_corrupt"); + const failed = work({phase: "finish_terminal_validation", identity, receipt: pending.receipt, + terminal_input: terminal, execution_result: issued, updates: {validation_receipt: {passed: false, exit_code: 4}}}); + assert.equal(begin(failed.receipt as JsonObject).effect, "run", "known failed validation remains legitimately retryable"); + assert.throws(() => work({phase: "finish_terminal_validation", identity, receipt: pending.receipt, + terminal_input: terminal, execution_result: issued, updates: {...updates, todo_done: true}}), /non-effect fields/); +}); + +test("Legacy commit policy distinguishes original terminal upgrade from new RESULT", () => { + const transaction = {schema_version: "loopx_todo_completion_transaction_result_v0", decision: "commit", + completion_identity_key: "original", fence: {status: "done", reason: "same_turn_terminal_upgrade"}, + completion_state: {continuation: "no_followup"}}; + assert.deepEqual(work({phase: "legacy_commit_policy", transaction, todo: {evidence: "original"}, candidate: {evidence: "original"}}), {required: false, recover_terminal_lease: true}); + for (const changed of [{...transaction, decision: "execute_validation"}, + {...transaction, fence: {status: "open", reason: "not_terminal"}}, + {...transaction, completion_identity_key: null}, + {...transaction, completion_state: {continuation: "active_goal"}}]) { + assert.deepEqual(work({phase: "legacy_commit_policy", transaction: changed}), {required: true, recover_terminal_lease: false}); + } +}); + +test("Legacy runtime closeout rejects a missing original quota settlement", async () => { + await assert.rejects(executeResultWorkBinding({phase: "legacy_commit_policy", runtime_root: resolve(tmpdir(), "loopx-missing-settlement-" + randomUUID()), + todo: {evidence: "original"}, candidate: {evidence: "original"}, + goal_id: "goal", todo_id: "task", agent_id: "agent", transaction: { + schema_version: "loopx_todo_completion_transaction_result_v0", decision: "commit", + completion_identity_key: "original", fence: {status: "done", reason: "lifecycle_reentry_terminal_upgrade"}, + completion_state: {continuation: "no_followup"}}}), /original quota identity and settled receipts/); +}); + +test("Legacy terminal closeout preserves committed result fields", () => { + const transaction = {schema_version: "loopx_todo_completion_transaction_result_v0", decision: "commit", + completion_identity_key: "original", fence: {status: "done", reason: "lifecycle_reentry_terminal_upgrade"}, + completion_state: {continuation: "no_followup"}}; + const todo = {evidence: "original", note: "original note", reason: "original reason"}; + for (const field of ["evidence", "note", "reason"]) { + assert.equal(work({phase: "legacy_commit_policy", transaction, todo, + candidate: {[field]: "replacement"}}).error_code, "delivery_validation_binding_mismatch"); + } + assert.deepEqual(work({phase: "legacy_commit_policy", transaction, todo, candidate: {}}), + {required: false, recover_terminal_lease: true}); +}); diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index b5e096e78a..a6d5634ff4 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -142,6 +142,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/goals/direction_authority.ts", "control_plane/goals/first_party_host_runtime.ts", "control_plane/goals/goal_amendment_authority.ts", + "control_plane/goals/checkpoint_commit.ts", "control_plane/goals/goal_amendment_proposal.ts", "control_plane/goals/goal_objective.ts", "control_plane/goals/operator_actions.ts", diff --git a/tests/control_plane_ts/effect_runtime_snapshot.test.ts b/tests/control_plane_ts/effect_runtime_snapshot.test.ts index 75aeb75fcf..8f67523c05 100644 --- a/tests/control_plane_ts/effect_runtime_snapshot.test.ts +++ b/tests/control_plane_ts/effect_runtime_snapshot.test.ts @@ -4,6 +4,24 @@ import {mkdtemp, writeFile, chmod, symlink, rm} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; import {test} from "node:test"; +import {execFile, spawnSync} from "node:child_process"; +import {fileURLToPath} from "node:url"; + +async function windowsFixture(directory: string, path: string): Promise { + if (process.platform !== "win32") return; + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN ?? + spawnSync("python", ["-c", "import sys;print(sys.executable)"], + {encoding: "utf8", windowsHide: true, timeout: 5000}).stdout?.trim(); + assert.ok(python, "Windows fixture needs its selected Python interpreter"); + process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN = python; + const helper = fileURLToPath(new URL("../../loopx/control_plane/runtime/private_permissions.py", import.meta.url)); + await command(python, ["-c", "import runpy,sys;from pathlib import Path;m=runpy.run_path(sys.argv[1]);m['protect_private_path'](Path(sys.argv[2]),directory=True);m['protect_private_path'](Path(sys.argv[3]))", helper, directory, path]); +} + +function command(exe: string, args: string[], env = process.env): Promise { + return new Promise((resolve, reject) => execFile(exe, args, + {env, windowsHide: true, timeout: 30000}, error => error ? reject(error) : resolve())); +} import { openPrivateResponseSink, readPrivateJsonSnapshot, writePrivateResponse, MAX_LOCAL_SNAPSHOT_BYTES, @@ -15,6 +33,7 @@ test("local snapshot accepts exact private bytes and rejects altered or unsafe r const path = join(directory, "params.json"); const bytes = Buffer.from(JSON.stringify({context: "x".repeat(2_200_000)})); await writeFile(path, bytes, {mode: 0o600}); + await windowsFixture(directory, path); const ref = {path, byte_count: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex")}; assert.deepEqual(await readPrivateJsonSnapshot(ref), JSON.parse(bytes.toString())); @@ -24,14 +43,19 @@ test("local snapshot accepts exact private bytes and rejects altered or unsafe r /snapshot unavailable or invalid/); await assert.rejects(readPrivateJsonSnapshot({...ref, byte_count: MAX_LOCAL_SNAPSHOT_BYTES + 1}), /snapshot unavailable or invalid/); - // Windows has no POSIX mode denial: the private-mode rejection is only - // observable where process.getuid exists. - if (process.getuid) { - await chmod(path, 0o644); - await assert.rejects(readPrivateJsonSnapshot(ref), /snapshot unavailable or invalid/); - } - // File symlink creation needs Developer Mode or admin on Windows. - if (process.platform !== "win32") { + if (process.platform === "win32") { + await command("icacls.exe", [path, "/grant", "*S-1-1-0:F"]); + } else await chmod(path, 0o644); + await assert.rejects(readPrivateJsonSnapshot(ref), /snapshot unavailable or invalid/); + if (process.platform === "win32") { + const link = join(directory, "loopx-effect-junction"); + await command("pwsh", ["-NoLogo", "-NoProfile", "-Command", + "New-Item -ItemType Junction -Path $env:LX_LINK -Target $env:LX_TARGET | Out-Null"], + {...process.env, LX_LINK: link, LX_TARGET: directory}); + // A real Windows junction is the reparse-path analogue; no symlink privilege needed. + await assert.rejects(readPrivateJsonSnapshot({...ref, path: join(link, "params.json")}), + /snapshot unavailable or invalid/); + } else { const link = join(directory, "link.json"); await symlink(path, link); await assert.rejects(readPrivateJsonSnapshot({...ref, path: link}), @@ -47,6 +71,7 @@ test("large response sink is private and digest-bound", async () => { try { const path = join(directory, "response.json"); await writeFile(path, "", {mode: 0o600}); + await windowsFixture(directory, path); const file = await openPrivateResponseSink(path); const bytes = Buffer.from(JSON.stringify({result: "a".repeat(2_200_000)})); try { diff --git a/tests/control_plane_ts/first_delivery_projection.test.ts b/tests/control_plane_ts/first_delivery_projection.test.ts new file mode 100644 index 0000000000..a1398851b5 --- /dev/null +++ b/tests/control_plane_ts/first_delivery_projection.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {evaluateFirstDelivery} from "../../loopx/control_plane/turn_driver/first_delivery.ts"; + +test("an owner-confirmed exemption continues settlement without claiming a direction commit", () => { + const writeback_run = {vision_checkpoint: {required: false, satisfied: true, decision: "not_required"}}; + const pending = evaluateFirstDelivery({phase: "project", writeback_run, result_committed: true}); + assert.equal(pending.stage, "settlement_pending"); + assert.equal(pending.direction_required, false); + assert.equal(pending.direction_committed, false); + assert.equal(evaluateFirstDelivery({phase: "project", writeback_run, settlement_complete: true}).stage, "settled"); + for (const checkpoint of [undefined, {decision: "not_required"}, + {...writeback_run.vision_checkpoint, required: true}]) { + assert.equal(evaluateFirstDelivery({phase: "project", result_committed: true, + writeback_run: {vision_checkpoint: checkpoint}}).stage, "direction_pending"); + } +}); + +test("an uncommitted result asks for result validation without claiming retained output", () => { + const projected = evaluateFirstDelivery({phase: "project", result_committed: false}); + assert.equal(projected.stage, "result_review_pending"); + assert.equal(projected.result_committed, false); + assert.equal(projected.direction_committed, false); +}); + +test("default pending, failure and stop preserve observed results and noncompletion outcomes", () => { + const base = {phase: "project", checkpoint_continuation: true, completion_event: {status: "completed"}}; + assert.equal(evaluateFirstDelivery(base).stage, "direction_pending"); + assert.equal(evaluateFirstDelivery({...base, direction_receipt: {direction_stopped: true}}).stage, "direction_stopped"); + assert.equal(evaluateFirstDelivery({...base, direction_receipt: {direction_host_failure: {}}}).stage, "direction_failed"); + const progress = evaluateFirstDelivery({phase: "project", writeback_run: { + vision_checkpoint: {required: true, satisfied: false, decision: "missing_required"}}}); + assert.equal(progress.stage, "direction_pending"); + assert.equal(progress.result_committed, false); + assert.equal(progress.outcome_retained, true); +}); + +test("native committed direction wins over a missing projection flag, but unresolved attempts remain unknown", () => { + const writeback_run = {vision_checkpoint: {satisfied: true, required: true, + read_context: {purpose: "first_delivery"}}}; + const direction_receipt = {commit_attempt: {}}; + const projected = evaluateFirstDelivery({phase: "project", writeback_run, direction_receipt}); + assert.equal(projected.stage, "settlement_pending"); + assert.equal(projected.direction_committed, true); + assert.equal(evaluateFirstDelivery({phase: "project", direction_receipt}).stage, "operation_unknown"); + assert.equal(evaluateFirstDelivery({phase: "project", writeback_run, unknown: true}).stage, "operation_unknown"); +}); + +test("ordinary settlement clears pending while original terminal intent waits for closeout", () => { + const base = {phase: "project", result_committed: true, settlement_complete: true, + identity: {effect_id: "original", turn_instance_id: "turn-original"}, + writeback_run: {vision_checkpoint: {satisfied: true, required: true, + read_context: {purpose: "first_delivery"}}}}; + assert.equal(evaluateFirstDelivery(base).stage, "settled"); + const terminal = {...base, completion_todo: {status: "done", no_followup: false, + completion_continuation: "active_goal", completion_requested_continuation: "no_followup", + completion_turn_key: "original"}}; + assert.equal(evaluateFirstDelivery(terminal).stage, "settlement_pending"); + assert.equal(evaluateFirstDelivery({...terminal, terminal_settlement_complete: true}).stage, "settled"); + assert.equal(evaluateFirstDelivery({...terminal, + completion_todo: {...terminal.completion_todo, completion_turn_key: "another-turn"}}).stage, "settled"); +}); diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 9016695958..89a15de45b 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -33,9 +33,11 @@ const goalA = {goal_id: goal, goal_instance_id: "ginst_aaaaaaaaaaaaaaaaaaaaaaaaa const goalB = {goal_id: goal, goal_instance_id: "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}; test("documented owner configuration satisfies the canonical acceptance contract", async () => { const reference = await readFile(new URL("../../docs/reference/goal-acceptance-observations.md", import.meta.url), "utf8"); - const example = reference.match(/```json\r?\n([\s\S]*?)\r?\n```/); - assert.ok(example, "the operation guide must include a runnable configuration"); - assert.doesNotThrow(() => normalizeGoalAcceptanceDocument(JSON.parse(example[1]))); + for (const newline of ["\n", "\r\n"]) { + const example = reference.replace(/\r?\n/g, newline).match(/```json\r?\n([\s\S]*?)\r?\n```/); + assert.ok(example, "the operation guide must include a runnable configuration"); + assert.doesNotThrow(() => normalizeGoalAcceptanceDocument(JSON.parse(example[1]))); + } }); function todo(todo_id: string, extra: JsonObject = {}): JsonObject { diff --git a/tests/control_plane_ts/host_todo_completion.test.ts b/tests/control_plane_ts/host_todo_completion.test.ts index 02e55b8a09..52b647cef2 100644 --- a/tests/control_plane_ts/host_todo_completion.test.ts +++ b/tests/control_plane_ts/host_todo_completion.test.ts @@ -13,6 +13,13 @@ import { const todoId = "todo_abc123"; +test("PR1 rejects protected terminal intent before preparing provider effects", () => { + assert.throws(() => prepare({schema_version: "loopx_host_todo_completion_transaction_v2", + first_delivery: true}), /no effects were admitted/); + const ordinary = prepare(); + assert.ok(ordinary.provider_effect); +}); + test("vision refresh shares the original delivery command and identity without a spend", () => { const authored = {schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, vision_path: "fixture-vision.json"}; @@ -228,6 +235,39 @@ function finalize( ); } +test("committed original result can recover settlement without claiming a restored work input", () => { + const identity = identityFrom(prepare()); + const guard = {ok: true, heartbeat_receipt: {settlement_identity: identity, + result_freshness: "turn_result_freshness_required_v1"}, work_input_restored: false}; + assert.equal(finalize(providerOutcomes(identity, {guard})).decision, "blocked"); + assert.equal(finalize(providerOutcomes(identity, {guard: {...guard, result_committed: true}})).decision, "complete"); + const other = {...identity, turn_instance_id: "another-turn"}; + assert.equal(finalize(providerOutcomes(identity, {guard: {...guard, result_committed: true, + heartbeat_receipt: {...guard.heartbeat_receipt, settlement_identity: other}}})).decision, "blocked"); +}); + +test("default current-Agent completion reads post-result facts and resumes a proven direction append", () => { + const enabled = {schema_version: "loopx_host_todo_completion_transaction_v2", first_delivery: true, + current_agent_checkpoint: true, vision_unchanged_reason: "Old pre-result decision."}; + const prepared = prepare(enabled); + const identity = identityFrom(prepared); + const steps = (prepared.provider_effect as {steps: {step_kind: string}[]}).steps; + assert.deepEqual(steps.map(s => s.step_kind), ["guard", "lifecycle_completion", "direction_context"]); + const outcomes = providerOutcomes(identity).slice(0, 2); + const context = {ok: true, settlement_identity: identity, read_context_id: "read-A", basis: {}}; + const pending = finalize([...outcomes, {step_kind: "direction_context", output: JSON.stringify(context)}], enabled); + assert.equal((pending.result as Record).stage, "direction_pending"); + assert.equal((pending.result as Record).settlement_complete, false); + const committed = {...context, status: "committed", context: {purpose: "first_delivery", read_context_id: "original-A"}}; + const resume = finalize([...outcomes, {step_kind: "direction_context", output: JSON.stringify(committed)}], enabled); + assert.equal(resume.decision, "execute"); + assert.equal(resume.resume_checkpoint_read_context_id, "original-A"); + const resumed = (resume.provider_effect as {steps: {args: string[]}[]}).steps; + assert.ok(resumed[2].args.includes("original-A")); + assert.equal(resumed[2].args.includes("Old pre-result decision."), false); + assert.equal(resumed.at(-1)!.args.includes("--no-follow-up"), true); +}); + test("prepare owns the retry-stable identity and one ordered host CLI plan", () => { const original = request("prepare"); const snapshot = structuredClone(original); diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index eeabe3dc20..b11c0fcdee 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -1,3 +1,4 @@ +import { verifyReentrySettlement } from "../../loopx/control_plane/coordination/todo_terminal_reentry.ts"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { @@ -447,6 +448,26 @@ async function appendHistory(targetRoot: string, sourceRoot: string): Promise { + const root = await fixture({writeback: true, spend: true, completion: true}); + try { + const ordinary = await readQuotaSettlement(request(root)); + const terminal = requireJsonObject(ordinary.terminal_closeout, "terminal result"); + assert.equal(requireJsonObject(requireJsonObject(terminal.result, "terminal result payload").failure, + "terminal failure").kind, "receipt_missing"); + const path = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const rows = (await readFile(path, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const completion = rows.find(row => row.event_kind === "todo_complete"); + completion.status = "terminal_no_followup"; + delete completion.details.no_followup; + await writeFile(path, rows.map(row => JSON.stringify(row)).join("\n") + "\n"); + const conflicting = await readQuotaSettlement(request(root)); + const rejected = requireJsonObject(conflicting.terminal_closeout, "terminal result"); + assert.equal(requireJsonObject(requireJsonObject(rejected.result, "terminal result payload").failure, + "terminal failure").kind, "effect_outcome_unknown"); + } finally { await rm(root, {recursive: true, force: true}); } +}); + function request(runtimeRoot: string, overrides: Record = {}) { return { schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, @@ -2145,3 +2166,25 @@ test("history retry qualification preserves its original aware clock and exact e } await assert.rejects(read([run], 123), /observation clock/); }); + +test("terminal reentry resolves original opaque Turn and full effect identities", async () => { + const root = await fixture({writeback: true, spend: true}); + const request = {goal_id: goalId, todo_id: todoId, actor_agent_id: agentId}; + for (const key of [turnId, identity.effect_id]) { + await verifyReentrySettlement(root, {...request, requested_completion_turn_key: key}); + } + for (const changed of [{todo_id: "todo_other"}, {actor_agent_id: "other"}, {goal_id: "other"}]) { + await assert.rejects(verifyReentrySettlement(root, {...request, ...changed, + requested_completion_turn_key: turnId})); + } + await assert.rejects(verifyReentrySettlement(root, {...request, + requested_completion_turn_key: "unrecorded-opaque-turn"})); +}); + +test("opaque Turn reentry still requires original writeback and spend", async () => { + for (const options of [{writeback: true}, {spend: true}, {}]) { + const root = await fixture(options); + await assert.rejects(verifyReentrySettlement(root, {goal_id: goalId, + todo_id: todoId, actor_agent_id: agentId, requested_completion_turn_key: turnId})); + } +}); diff --git a/tests/control_plane_ts/terminal_source_conformance.ts b/tests/control_plane_ts/terminal_source_conformance.ts index f24e85dd01..29320dc981 100644 --- a/tests/control_plane_ts/terminal_source_conformance.ts +++ b/tests/control_plane_ts/terminal_source_conformance.ts @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; import test from "node:test"; -import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import type {AuthorityStore, AuthorityStoreHead} from "../../loopx/control_plane/coordination/authority_store.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {executeCoordinationTodoTerminalLifecycle as execute, type CoordinationTodoTerminalLifecycleInput} from "../../loopx/control_plane/coordination/todo_terminal_lifecycle.ts"; @@ -14,6 +14,9 @@ import type {AuthorityStoreConformanceFactory} from "./authority_store_conforman import {authorityProjectionFixture, projectionFixtureAsSchema} from "./authority_projection_fixture.ts"; import {productionScaleCoordinationFixture, PRODUCTION_SCALE_VALIDATION_DECLARATION as declaration} from "./production_scale_coordination_fixture.ts"; +import {checkpointProviderFacts} from "../../loopx/control_plane/goals/checkpoint_authority.ts"; +import {evaluateCheckpointReadContext as evaluate} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; +import {settlementIdentity, settlementIdentityPayload} from "../../loopx/control_plane/effect_program.ts"; async function loaded(store: AuthorityStore) { const head = await store.loadAuthority(); @@ -27,6 +30,210 @@ const validation = {schema_version: "issue_fix_validation_command_v0", command_l stdout_captured: false, stderr_captured: false, local_path_captured: false}; export function registerTerminalSourceConformance(provider: string, factory: AuthorityStoreConformanceFactory): void { + test(`${provider}: default result freshness rejects related head changes and replays original receipt`, async t => { + const {store, contender} = await factory(t); + const goal = "default-result-freshness"; + const fixture = productionScaleCoordinationFixture(goal, "native"); + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture.projection, events: [], receipts: []}); + const before = await loaded(store); + const identity = settlementIdentityPayload(settlementIdentity({goal_id: goal, agent_id: "agent-a", + todo_id: fixture.completion_todo_id, turn_instance_id: "original-turn"})); + const sourceId = await store.storeIdentity(); + assert.equal(sourceId.status, "available"); + if (sourceId.status !== "available") return; + const facts = (head: AuthorityStoreHead) => checkpointProviderFacts(goal, + {checkpoint_identity: identity, frontmatter: {objective: "Verify the selected work"}, + goal_prose: "Acceptance applies", agent_vision: null, source: {state_file: "synthetic-state"}}, + head, sourceId.store_identity, provider, true); + const read = (head: AuthorityStoreHead, key: string) => evaluate({phase: "read", purpose: "delivery_result", + admitted_turn: true, prior: null, result_only: true, identity, facts: facts(head), read_context_id: key}); + const original = read(before, "original-read"); + assert.equal(original.ok, true); + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: fixture.completion_todo_id, + expected_role: "agent", command: "complete", actor_agent_id: "agent-a", registered_agents: fixture.registered_agents, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, operation_identity: {kind: "completion_turn"}, + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, allow_user_gate_auto_acquire: false, + requested_no_followup: false, requested_completion_turn_key: "original-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: "Verified retained candidate", evidence: "Validation passed", + reason: null, clear_claim: false, validation_declaration: declaration, validation_receipt: validation, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), completion_policy_request: null, + dry_run: false, now: new Date(String(fixture.semantic_cases.terminal_source!.observed_at)), + delivery_read_context_id: "original-read", validation_source_provider_revision: before.provider_revision}; + const selected = (before.head.todos as JsonObject[]).find(todo => todo.todo_id === request.todo_id)!; + await contender.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, + operation_id: "related-change", expected_provider_revision: before.provider_revision, projection: before.head, + mutations: [{kind: "todo_upsert", todo: {...selected, text: "Verify the revised requirement"}}]})); + const current = await loaded(store); + const check = (receipt: unknown, key: string) => async (head: AuthorityStoreHead) => evaluate({phase: "check", + purpose: "delivery_result", identity, facts: facts(head), receipt, read_context_id: key}); + const rejected = await execute(store, request, async () => true, undefined, check(original.receipt, "original-read")); + assert.equal(rejected.reason_code, "checkpoint_read_context_stale", JSON.stringify(rejected)); + assert.deepEqual(await loaded(store), current); + const fresh = read(current, "revalidated-read"); + const revalidated = {...request, delivery_read_context_id: "revalidated-read", validation_source_provider_revision: current.provider_revision}; + assert.equal((await execute(store, revalidated, async () => true, undefined, check(fresh.receipt, "revalidated-read"))).status, "applied"); + const saved = await loaded(store); + assert.equal((await execute(contender, revalidated, async () => {throw new Error("receipt before authority");}, + async () => {throw new Error("receipt before lifecycle settlement");}, + async () => {throw new Error("receipt before freshness");})).status, "replayed"); + const withoutReadKey = {...revalidated, delivery_read_context_id: undefined}; + assert.equal((await execute(contender, withoutReadKey, async () => {throw new Error("receipt before authority");}, + async () => {throw new Error("receipt before lifecycle settlement");}, + async () => {throw new Error("receipt before freshness");})).status, "replayed"); + assert.equal((await execute(contender, {...withoutReadKey, evidence: "Different intended result"}, + async () => true, undefined, check(fresh.receipt, "revalidated-read"))).reason_code, + "coordination_operation_identity_mismatch"); + assert.deepEqual(await loaded(store), saved); + }); + for (const closeoutSource of ["turn", "lifecycle"] as const) { + test(`${provider}: deferred original no-followup upgrades once without new validation (${closeoutSource})`, async t => { + const {store, contender} = await factory(t); + const goal = "deferred-terminal"; + const fixture = productionScaleCoordinationFixture(goal, "native"); + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture.projection, events: [], receipts: []}); + const before = await loaded(store); + const source = await store.storeIdentity(); + assert.equal(source.status, "available"); + if (source.status !== "available") return; + const identity = settlementIdentityPayload(settlementIdentity({goal_id: goal, + agent_id: "agent-a", todo_id: fixture.completion_todo_id, turn_instance_id: "original-turn"})); + const facts = (head: AuthorityStoreHead) => checkpointProviderFacts(goal, + {checkpoint_identity: identity, frontmatter: {objective: "Verify scoped work"}, + goal_prose: "Acceptance applies", agent_vision: null, source: {state_file: "synthetic-state"}}, + head, source.store_identity, provider, true); + const basis = evaluate({phase: "read", purpose: "delivery_result", admitted_turn: true, + prior: null, result_only: true, identity, facts: facts(before), read_context_id: "result-read"}); + const check = async (head: AuthorityStoreHead) => evaluate({phase: "check", purpose: "delivery_result", + identity, facts: facts(head), receipt: basis.receipt, read_context_id: "result-read"}); + + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: fixture.completion_todo_id, + expected_role: "agent", command: "complete", actor_agent_id: "agent-a", registered_agents: fixture.registered_agents, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, operation_identity: {kind: "completion_turn"}, + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, allow_user_gate_auto_acquire: false, + requested_no_followup: true, defer_no_followup: true, + requested_completion_turn_key: "original-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: "Verified candidate", evidence: "Validation passed", + reason: null, clear_claim: false, validation_declaration: declaration, validation_receipt: validation, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), completion_policy_request: null, + dry_run: false, now: new Date(String(fixture.semantic_cases.terminal_source!.observed_at)), + delivery_read_context_id: "result-read", validation_source_provider_revision: before.provider_revision}; + const result = await execute(store, request, async () => true, undefined, check); + assert.equal(result.status, "applied", JSON.stringify(result)); + const committed = await loaded(store); + const todo = (committed.head.todos as JsonObject[]).find(row => row.todo_id === request.todo_id)!; + assert.equal(todo.status, "done"); + assert.equal(todo.completion_continuation, "active_goal"); + assert.equal(todo.completion_recovery ?? null, null); + assert.equal(todo.completion_requested_continuation, "no_followup"); + assert.equal(todo.no_followup, false); + assert.equal((await execute(contender, request, async () => true, undefined, check)).status, "replayed"); + const closeout = {...request, defer_no_followup: false, validation_receipt: null, + validation_source_provider_revision: committed.provider_revision}; + const changed = await execute(store, {...closeout, evidence: "Changed result"}, async () => true, undefined, check); + assert.equal(changed.reason_code, "coordination_operation_identity_mismatch", JSON.stringify(changed)); + assert.deepEqual(await loaded(store), committed); + const lifecycle: CoordinationTodoTerminalLifecycleInput = {...closeout, + operation_identity: {kind: "explicit", operation_id: "cold-lifecycle"}, + requested_completion_identity_source: "lifecycle_reentry", + lease_idempotency_key: null, lease_expected_version: null, + validation_declaration: null, delivery_read_context_id: undefined}; + const closedRequest = closeoutSource === "turn" ? closeout : lifecycle; + let settlementChecks = 0; + const settlement = async () => {settlementChecks++;}; + const lifecycleBasis = async () => {throw new Error("Reentry must not admit new RESULT work");}; + if (closeoutSource === "lifecycle") { + const rejected = await execute(store, lifecycle, async () => true, + async () => {throw new Error("Original writeback/spend not settled");}, lifecycleBasis); + assert.equal(rejected.status, "failed"); + assert.deepEqual(await loaded(store), committed); + } + const closed = await execute(contender, closedRequest, async () => true, settlement, + closeoutSource === "turn" ? check : lifecycleBasis); + assert.equal(closed.status, "applied", JSON.stringify(closed)); + assert.deepEqual(closed.validation_receipt, closeoutSource === "turn" ? validation : null); + assert.equal(settlementChecks, closeoutSource === "turn" ? 0 : 1); + const after = await loaded(store); + const terminal = (after.head.todos as JsonObject[]).find(row => row.todo_id === request.todo_id)!; + assert.equal(terminal.completion_continuation, "no_followup"); + assert.equal(terminal.completion_recovery, closeoutSource === "turn" + ? "same_turn_terminal_closeout" : "lifecycle_reentry_terminal_closeout"); + assert.equal(terminal.no_followup, true); + assert.equal((await execute(store, closedRequest, async () => {throw new Error("Historical closeout");}, + async () => {throw new Error("No repeated settlement verification");}, check)).status, "replayed"); + // After lifecycle closeout the exact accepted Turn RESULT remains historical; + // changing its actor, evidence or lease cannot borrow that receipt. + assert.equal((await execute(store, closeout, async () => {throw new Error("Historical RESULT");}, + undefined, check)).status, "replayed"); + for (const patch of [{evidence: "Changed result"}, {actor_agent_id: "agent-b"}, + {lease_expected_version: 999}]) { + assert.equal((await execute(store, {...closeout, ...patch}, async () => true, undefined, check)).status, "failed"); + assert.deepEqual(await loaded(store), after); + } + assert.deepEqual(await loaded(store), after); + }); + } + test(`${provider}: protected completion final CAS preserves a racing peer and historical recovery`, async t => { + const {store, contender} = await factory(t); + const goal = "protected-terminal-cas"; + const fixture = productionScaleCoordinationFixture(goal, "native"); + const scenario = fixture.semantic_cases.terminal_source!; + assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture.projection, events: [], receipts: []})).status, "applied"); + const before = await loaded(store); + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: fixture.completion_todo_id, + expected_role: "agent", command: "complete", actor_agent_id: "agent-a", registered_agents: fixture.registered_agents, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, + operation_identity: {kind: "completion_turn"}, + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, + allow_user_gate_auto_acquire: false, requested_no_followup: false, + requested_completion_turn_key: "protected-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: "Reviewed result", evidence: "Verified result", reason: null, + clear_claim: false, validation_declaration: declaration, validation_receipt: validation, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), + completion_policy_request: null, dry_run: false, now: new Date(String(scenario.observed_at)), + delivery_read_context_id: "result-read", validation_source_provider_revision: before.provider_revision}; + const missingCheck = await execute(store, request); + assert.equal(missingCheck.reason_code, "delivery_basis_check_required", JSON.stringify(missingCheck)); + const checkedHeads: string[] = []; + const checkBasis = async (head: AuthorityStoreHead) => { + checkedHeads.push(head.provider_revision); + return {ok: true}; // The peer is unrelated; basis construction has separate integration coverage. + }; + const peer = (before.head.todos as JsonObject[]).find(todo => todo.todo_id !== request.todo_id)!; + let attemptedId = ""; + const racing = new Proxy(store, {get(target, key) { + if (key === "commitAuthority") return async (...args: Parameters) => { + attemptedId = args[0].operation_id; + assert.equal((await contender.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, + operation_id: "racing-peer", expected_provider_revision: before.provider_revision, projection: before.head, + mutations: [{kind: "todo_upsert", todo: {...peer, note: "Peer update survives"}}]}))).status, "applied"); + return target.commitAuthority(...args); + }; + const member = Reflect.get(target, key); + return typeof member === "function" ? member.bind(target) : member; + }}); + const rejected = await execute(racing, request, async () => true, undefined, checkBasis); + assert.equal(rejected.conflict_kind, "provider_revision_mismatch", JSON.stringify(rejected)); + assert.ok(attemptedId); + assert.equal((await store.readReceipt(attemptedId)).status, "missing"); + const current = await loaded(store); + assert.equal((current.head.todos as JsonObject[]).find(todo => todo.todo_id === request.todo_id)!.status, "open"); + const completed = await execute(store, request, async () => true, undefined, checkBasis); + assert.equal(completed.status, "applied", JSON.stringify(completed)); + assert.deepEqual([...new Set(checkedHeads)], [before.provider_revision, current.provider_revision]); + const after = await loaded(store); + assert.equal((after.head.todos as JsonObject[]).find(todo => todo.todo_id === peer.todo_id)!.note, "Peer update survives"); + const replay = await execute(contender, request, async () => {throw new Error("history before authority");}, + async () => {throw new Error("history before lifecycle settlement");}, + async () => {throw new Error("history before freshness");}); + assert.equal(replay.status, "replayed"); + assert.deepEqual(await loaded(store), after); + }); for (const schema of ["legacy", "native"] as const) { test(`${provider}: Turn completion binds exact task text through CAS and historical recovery (${schema})`, async t => { const {store, contender} = await factory(t); diff --git a/tests/control_plane_ts/todo_completion_state.test.ts b/tests/control_plane_ts/todo_completion_state.test.ts index 0f722fb7ce..2a4d2275e4 100644 --- a/tests/control_plane_ts/todo_completion_state.test.ts +++ b/tests/control_plane_ts/todo_completion_state.test.ts @@ -96,6 +96,15 @@ test("whole completion owner selects continuation and rejects contradictory inte }), /cannot record both no_followup and a successor/); }); +test("deferred terminal result stays active and is not a completed recovery", () => { + const request = {schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, + todo: {status: "open", no_followup: true}, requested_no_followup: false, + defer_no_followup: true, has_successor: false, completion_identity_source: "turn_settlement"}; + assert.deepEqual(selectTodoCompletionState(request), { + schema_version: "loopx_todo_completion_state_result_v0", continuation: "active_goal", recovery: null}); + assert.throws(() => selectTodoCompletionState({...request, completion_identity_source: "unscoped_completion"}), /original Turn/); +}); + test("retired scalar and summary RPCs fail closed", async () => { const handlers = createEffectRuntimeHandlers({fingerprint: "completion-retirement-test", requestShutdown: () => undefined}); for (const method of ["todo.completion_state.continuation_for_write", "todo.completion_state.normalize", @@ -172,3 +181,15 @@ test("lifecycle reentry emits a distinct terminal recovery", () => { }, ); }); + + +test("original Turn defers explicit terminal intent without reporting terminal recovery", () => { + const request = {schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, + todo: {status: "done", no_followup: false, completion_continuation: "active_goal"}, + requested_no_followup: true, defer_no_followup: true, has_successor: false, + completion_identity_source: "turn_settlement"}; + assert.deepEqual(selectTodoCompletionState(request), { + schema_version: "loopx_todo_completion_state_result_v0", continuation: "active_goal", recovery: null}); + assert.deepEqual(selectTodoCompletionState({...request, defer_no_followup: false}), { + schema_version: "loopx_todo_completion_state_result_v0", continuation: "no_followup", recovery: "same_turn_terminal_closeout"}); +}); diff --git a/tests/control_plane_ts/todo_completion_transaction.test.ts b/tests/control_plane_ts/todo_completion_transaction.test.ts index ec83df607a..1ae8b2b845 100644 --- a/tests/control_plane_ts/todo_completion_transaction.test.ts +++ b/tests/control_plane_ts/todo_completion_transaction.test.ts @@ -404,3 +404,15 @@ test("runtime input and validation receipts fail closed", () => { /cannot record both no_followup and a successor/, ); }); + +test("bound terminal upgrade reuses original RESULT validation", () => { + for (const source of ["turn_settlement", "lifecycle_reentry"]) { + const result = reduceTodoCompletionTransaction(request({todo: {...baseTodo, status: "done", + completion_continuation: "active_goal", completion_turn_key: "original-turn", + validation_command: "must-never-run-again"}, requested_no_followup: true, + requested_completion_turn_key: "original-turn", requested_completion_identity_source: source})); + assert.equal(result.decision, "commit"); + assert.equal(result.validation_receipt, null); + assert.equal(result.completion_state.continuation, "no_followup"); + } +}); diff --git a/tests/control_plane_ts/turn_journal_effects.test.ts b/tests/control_plane_ts/turn_journal_effects.test.ts index 9830852805..db9e227e1f 100644 --- a/tests/control_plane_ts/turn_journal_effects.test.ts +++ b/tests/control_plane_ts/turn_journal_effects.test.ts @@ -536,6 +536,45 @@ test("failed validation may explicitly rewind for host reinvocation", async () = }); }); +test("only a retained, uncommitted stale result may invalidate completed validation", async () => { + await withJournalPath(async (path) => { + await commit(path, journal()); + const worked = journal("in_progress", phases.slice(0, 2)); + worked.host_result = {summary: "Retained candidate"}; + worked.result_work_context = {read_context_id: "original-work-key"}; + await commit(path, worked); + const validated = {...worked, completed_phases: [...phases.slice(0, 3)], + task_validation: {ok: true}}; + await commit(path, validated); + const retry = {...worked, status: "failed", validation_stage: "result_freshness", + receipt: {turn_key: turnKey, failed_phase: "validation"}, result_revalidation: { + candidate: worked.host_result, original_context: worked.result_work_context, + rejection: {ok: false, error_code: "checkpoint_read_context_stale"}, + }}; + const variants = [ + {...retry, result_revalidation: {...retry.result_revalidation, rejection: {ok: false, error_code: "unknown"}}}, + {...retry, result_revalidation: {...retry.result_revalidation, candidate: {summary: "Other candidate"}}}, + {...retry, result_work_context: {read_context_id: "replacement-key"}}, + {...retry, task_validation: {ok: true}}, + {...retry, writeback: {ok: true}}, + {...retry, quota_spend: {ok: true}}, + {...retry, effect_attempts: {durable_writeback: {status: "prepared", effect_ref: `${effectId()}#durable_writeback`}}}, + ]; + const before = await readFile(path, "utf8"); + for (const invalid of variants) { + await assert.rejects(commit(path, invalid), /regress|prepared effect/); + assert.equal(await readFile(path, "utf8"), before); + } + await commit(path, retry); + await commit(path, {...worked, completed_phases: []}); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")).completed_phases, []); + await commit(path, worked); + await commit(path, validated); + await commit(path, {...retry, result_revalidation: {...retry.result_revalidation, + rejection: {ok: false, error_code: "delivery_validation_binding_mismatch"}}}); + }); +}); + test("terminal journal tombstones are immutable", async () => { await withJournalPath(async (path) => { await commit(path, journal()); @@ -550,6 +589,47 @@ test("terminal journal tombstones are immutable", async () => { }); }); +test("terminal result revalidation preserves settled receipts and cannot execute settlement", async () => { + await withJournalPath(async (path) => { + await commit(path, journal()); + const worked = {...journal("in_progress", phases.slice(0, 2)), + host_result: {summary: "Original candidate"}, result_work_context: {read_context_id: "original-key"}}; + await commit(path, worked); + await commit(path, {...worked, completed_phases: [...phases.slice(0, 3)]}); + const written = {...worked, completed_phases: [...phases.slice(0, 4)], writeback: {ok: true, run_id: "original-writeback"}}; + await commit(path, written); + const settled = {...written, completed_phases: [...phases.slice(0, 5)], quota_spend: {ok: true, slots: 1}}; + await commit(path, settled); + const retry = {...settled, status: "failed", receipt: {turn_key: turnKey, failed_phase: "terminal_closeout"}, + result_revalidation: {phase: "terminal_closeout", candidate: worked.host_result, + original_context: worked.result_work_context, rejection: {ok: false, error_code: "checkpoint_read_context_stale"}, validation: {}}}; + for (const invalid of [ + {...retry, quota_spend: {ok: true, slots: 2}}, + {...retry, writeback: {ok: true, run_id: "replacement"}}, + {...retry, result_revalidation: {...retry.result_revalidation, candidate: {summary: "Other candidate"}}}, + {...retry, result_revalidation: {...retry.result_revalidation, validation: {status: "in_progress", completed_phases: [...phases.slice(0, 4)]}}}, + {...retry, effect_attempts: {terminal_closeout: {status: "prepared", effect_ref: `${effectId()}#terminal_closeout`}}}, + ]) await assert.rejects(commit(path, invalid), /Terminal result revalidation/); + await commit(path, retry); + const progress = {...retry, status: "in_progress", result_revalidation: {...retry.result_revalidation, + validation: {status: "in_progress", completed_phases: [...phases.slice(0, 3)], + work_context: worked.result_work_context, + host_result: {summary: "Revalidated candidate"}, task_validation: {ok: true}}}}; + await commit(path, progress); + const ready: Record = {...progress, host_result: progress.result_revalidation.validation.host_result, + task_validation: {ok: true}, result_work_binding: {read_context_id: "original-key"}}; + delete ready.result_revalidation; + const rebound = {...ready, result_work_context: {read_context_id: "replacement-key"}, + result_work_binding: {read_context_id: "replacement-key"}}; + await assert.rejects(commit(path, rebound), /Terminal result revalidation/); + await commit(path, ready); + const saved = JSON.parse(await readFile(path, "utf8")); + assert.deepEqual(saved.completed_phases, phases.slice(0, 5)); + assert.deepEqual(saved.writeback, settled.writeback); + assert.deepEqual(saved.quota_spend, settled.quota_spend); + }); +}); + test("transaction plans are immutable within one effect", async () => { await withJournalPath(async (path) => { await commit(path, journal()); diff --git a/tests/test_codex_operation_host.py b/tests/test_codex_operation_host.py index 535ffc4dff..792e0e2e77 100644 --- a/tests/test_codex_operation_host.py +++ b/tests/test_codex_operation_host.py @@ -63,6 +63,17 @@ def emit(value): text = row["params"]["input"][0]["text"] if os.environ.get("FAKE_OPERATION_PROMPT_FILE"): pathlib.Path(os.environ["FAKE_OPERATION_PROMPT_FILE"]).write_text(text) + if '"phase":"checkpoint_direction"' in text: + context = json.loads(text[text.index('{"'):])['checkpoint_context'] + emit({"id": row["id"], "result": {"turn": {"id": turn}}}) + if os.environ.get('FAKE_OPERATION_DIRECTION_TOOL'): + emit({"id": 52, "method": "item/tool/call", "params": {"threadId": thread, "turnId": turn, "tool": "loopx_operation", "arguments": {"action": "context"}}}) + else: + answer = {'read_context_id': context['read_context_id'], 'agent_vision_json': '', + 'vision_unchanged_reason': 'The committed result and current obligations preserve the route.', 'stop_reason': ''} + emit({"method": "item/agentMessage/delta", "params": {"threadId": thread, "turnId": turn, "delta": json.dumps(answer)}}) + emit({"method": "turn/completed", "params": {"threadId": thread, "turn": {"id": turn, "status": "completed"}}}) + break # The actual native prompt must separate proposal preparation from # domain execution; otherwise approval can never get a proposal. assert "context/pending/inspect do not require consumption" in text @@ -87,6 +98,7 @@ def emit(value): answer.update({"schema_version": "loopx_turn_result_v0", "turn_key": key, "result_kind": "wait", "completed_phases": ["host_execute", "typed_result"], "classification": "awaiting_operation_confirmation", "recommended_action": "Wait for actual human approval", "summary": "Owned native context retrieved; no approval or effect"}) emit({"method": "item/agentMessage/delta", "params": {"threadId": thread, "turnId": turn, "delta": json.dumps(answer)}}) emit({"method": "turn/completed", "params": {"threadId": thread, "turn": {"id": turn, "status": "completed"}}}) + break """ @@ -107,9 +119,7 @@ def _claimed_native_fixture(tmp_path: Path): from loopx.control_plane.turn_driver.managed_operation_host import operation_tool_handler service, store = fixtures.service(tmp_path, goal_id="fixture-goal") - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) request = _request() request["turn_envelope"]["agent_id"] = "finance-fixture-agent" request["turn_envelope"]["action"]["selected_todo"]["todo_id"] = "todo-managed" @@ -396,6 +406,7 @@ def call_next(): "threadId": thread, "turnId": turn, "delta": json.dumps({"results": results})}}) emit({"method": "turn/completed", "params": { "threadId": thread, "turn": {"id": turn, "status": "completed"}}}) + raise SystemExit(0) for line in sys.stdin: row = json.loads(line) if row.get("method") == "initialize": @@ -471,9 +482,7 @@ def test_registered_source_tokens_survive_owned_native_prepare_and_store_readbac value["normalized_parameters"]["projection"]["simulated"] = False arguments = {"action": "prepare", "request": value} monkeypatch.setenv("FAKE_SOURCE_TOOL_CALLS", json.dumps([arguments, arguments])) - executable = tmp_path / "fake-source-prepare-server" - executable.write_text(SOURCE_PREPARE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path, SOURCE_PREPARE_SERVER) session = CodexChatAgentSession.start( codex_bin=str(executable), work_dir=tmp_path, @@ -548,9 +557,7 @@ def test_registered_source_tokens_survive_owned_native_prepare_and_store_readbac def test_owned_app_server_process_authenticates_native_metadata_and_resumes_same_profile( tmp_path: Path, deadline, ) -> None: - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) options = { "runtime_root": tmp_path / "runtime", "registry_path": _registered_host_goal(tmp_path), @@ -591,15 +598,42 @@ def test_owned_app_server_process_authenticates_native_metadata_and_resumes_same ) +@pytest.mark.parametrize('tool_attempt', [False, True]) +def test_checkpoint_direction_resumes_session_without_operation_or_child_adoption(tmp_path, monkeypatch, tool_attempt): + executable = _fake_server(tmp_path) + options = dict(runtime_root=tmp_path / 'runtime', registry_path=tmp_path / 'registry.json', + project=tmp_path, codex_bin=str(executable), model='test-model', + reasoning_effort='xhigh', timeout_seconds=5) + run_codex_operation_host(_request(), **options) + before = load_codex_cli_session(options['runtime_root'], lineage=_lineage(_request())) + actions = options['runtime_root'] / 'chat/actions/actions.json' + actions.parent.mkdir(parents=True) + actions.write_text('{}') + def forbidden(*args, **kwargs): + pytest.fail('Direction continuation must not adopt an operation or native child') + monkeypatch.setattr('loopx.control_plane.turn_driver.codex_operation_host.pending_operation_handoffs', forbidden) + monkeypatch.setattr('loopx.control_plane.turn_driver.codex_operation_host.native_child_observer', forbidden) + request = _request(session_action='resume') + request.update(phase='checkpoint_direction', checkpoint_context={'read_context_id': 'direction-read', + 'basis': {'todo': {'status': 'done'}, 'frontier': {'scope': 'goal'}}}) + if tool_attempt: + monkeypatch.setenv('FAKE_OPERATION_DIRECTION_TOOL', '1') + with pytest.raises(BuiltInHostError): + run_codex_operation_host(request, **options) + else: + result = run_codex_operation_host(request, **options) + assert result['read_context_id'] == 'direction-read' + assert load_codex_cli_session(options['runtime_root'], lineage=_lineage(request)) == before + assert actions.read_text() == '{}' + + def test_admitted_turn_cli_launches_owned_transport_without_plain_cli_fallback( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from loopx.cli import main as cli_main project, runtime, registry = _write_live_fixture(tmp_path) - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) def forbidden_plain_cli(*args, **kwargs): pytest.fail("Operation opt-in must not downgrade to plain Codex exec") @@ -712,9 +746,7 @@ def forbidden_start(command, *args, **kwargs): def test_owned_operation_host_reaps_descendants_on_success_and_timeout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, hang: bool ) -> None: - executable = tmp_path / "fake-codex-operation" - executable.write_text(FAKE_SERVER) - executable.chmod(0o700) + executable = _fake_server(tmp_path) marker = tmp_path / "owned-child" monkeypatch.setenv("FAKE_OPERATION_CHILD_MARKER", str(marker)) if hang: diff --git a/tests/test_file_lock_windows_atomic.py b/tests/test_file_lock_windows_atomic.py new file mode 100644 index 0000000000..7f043251dd --- /dev/null +++ b/tests/test_file_lock_windows_atomic.py @@ -0,0 +1,128 @@ +"""Windows atomic holder publication with real file-sharing boundaries.""" +from contextlib import contextmanager +import ctypes as ct +from ctypes import wintypes as w +import errno +import json +import os +from pathlib import Path +import threading +import time +from types import SimpleNamespace + +import pytest +import loopx.file_lock as locks + +pytestmark = pytest.mark.skipif(os.name != 'nt', reason='Windows file sharing boundary') + +@contextmanager +def reader_without_delete_share(path): + kernel = ct.WinDLL('kernel32', use_last_error=True) + kernel.CreateFileW.argtypes = [w.LPCWSTR,w.DWORD,w.DWORD,ct.c_void_p,w.DWORD,w.DWORD,w.HANDLE] + kernel.CreateFileW.restype = w.HANDLE + kernel.CloseHandle.argtypes = [w.HANDLE] + handle = kernel.CreateFileW(str(path),0x80000000,0x3,None,3,0,None) + assert handle != ct.c_void_p(-1).value + closed = False + def close(): + nonlocal closed + if not closed: + closed = True + assert kernel.CloseHandle(handle) + try: + yield close + finally: + close() + +@contextmanager +def transient_reader(path, monkeypatch): + attempts = [] + first_attempt = threading.Event() + replace = os.replace + def observed_replace(source,destination): + if Path(destination) == path: + attempts.append(time.monotonic()) + first_attempt.set() + return replace(source,destination) + with reader_without_delete_share(path) as close: + def release(): + first_attempt.wait(2) + time.sleep(0.1) + close() + worker = threading.Thread(target=release) + worker.start() + monkeypatch.setattr(locks.os,'replace',observed_replace) + try: + yield attempts + finally: + first_attempt.set() + worker.join(3) + assert not worker.is_alive() + +def test_transient_reader_allows_atomic_holder_replace(tmp_path,monkeypatch): + path=tmp_path/'holder.json';path.write_text(json.dumps({'sequence':0})) + with transient_reader(path,monkeypatch) as attempts: + locks._write_holder_sidecar(path,{'sequence':1}) + assert len(attempts)>=2 + assert json.loads(path.read_text())=={'sequence':1} + assert not list(tmp_path.glob('*.tmp')) + +def test_transient_reader_preserves_real_kernel_lock_body(tmp_path,monkeypatch): + target=tmp_path/'state.json' + with locks.exclusive_file_lock(target):pass + holder=locks.lock_holder_path(target);effects=[] + with transient_reader(holder,monkeypatch): + with locks.exclusive_file_lock(target,agent_id='writer',operation='atomic-publish'): + effects.append('business') + assert effects==['business'] + assert json.loads(holder.read_text())['released_at'] + with locks.try_exclusive_file_lock(target) as acquired:assert acquired is not None + +def test_persistent_reader_keeps_old_bytes_and_propagates_denial(tmp_path): + path=tmp_path/'holder.json';path.write_text(json.dumps({'sequence':0}));before=path.read_bytes() + with reader_without_delete_share(path): + start=time.monotonic() + with pytest.raises(OSError) as failure:locks._write_holder_sidecar(path,{'sequence':1}) + assert failure.value.winerror==5 and time.monotonic()-start<1.2 + assert path.read_bytes()==before + assert not list(tmp_path.glob('*.tmp')) + +def test_failed_holder_publication_releases_kernel_without_business(tmp_path): + target=tmp_path/'state.json' + with locks.exclusive_file_lock(target):pass + holder=locks.lock_holder_path(target);effects=[] + with reader_without_delete_share(holder): + start=time.monotonic() + with pytest.raises(OSError) as failure: + with locks.exclusive_file_lock(target):effects.append('business') + assert failure.value.winerror==5 and time.monotonic()-start<2 + assert effects==[] + with locks.try_exclusive_file_lock(target) as acquired:assert acquired is not None + assert not list(tmp_path.glob('*.tmp')) + +def test_unrelated_error_has_no_retry_or_fallback(tmp_path,monkeypatch): + attempts=[] + def fail(source,destination): + attempts.append(1) + raise FileNotFoundError(errno.ENOENT,'unrelated source error') + monkeypatch.setattr(locks.os,'replace',fail) + monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('unrelated error slept')) + with pytest.raises(FileNotFoundError):locks._write_holder_sidecar(tmp_path/'holder.json',{'sequence':1}) + assert attempts==[1] and not list(tmp_path.glob('*.tmp')) + +def test_successful_holder_publication_does_not_sleep(tmp_path,monkeypatch): + path=tmp_path/'holder.json';path.write_text(json.dumps({'sequence':0})) + monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('success slept')) + locks._write_holder_sidecar(path,{'sequence':1}) + assert json.loads(path.read_text())=={'sequence':1} + +def test_posix_adapter_permission_error_remains_immediate(tmp_path,monkeypatch): + attempts=[] + def fail(source,destination): + attempts.append(1) + error=PermissionError(errno.EACCES,'denied');error.winerror=5;raise error + adapter=SimpleNamespace(**{name:getattr(os,name) for name in ('fdopen','fsync')},name='posix',replace=fail) + monkeypatch.setattr(locks,'os',adapter) + monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('POSIX denial slept')) + with pytest.raises(PermissionError):locks._write_holder_sidecar(tmp_path/'holder.json',{'sequence':1}) + assert attempts==[1] and not list(tmp_path.glob('*.tmp')) diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 3a8d84284d..8958e9127c 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -255,6 +255,7 @@ def _run_cli(registry: Path, *args: str) -> tuple[int, dict[str, object]]: capture_output=True, text=True, ) + assert result.stdout.strip(), result.stderr return result.returncode, json.loads(result.stdout) @@ -275,6 +276,40 @@ def _control(registry: Path) -> GoalModeMCPControlPlane: return control + +def _complete_default_task(control, *args, **options): + """Fixture Agent consumes the actual returned post-result read, without opt-in.""" + from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + output = control.complete_task(*args, **options) + first = json.loads(output) + if first.get("stage") != "direction_pending": + return output + assert first["completed"] is True and first["settlement_complete"] is False + decision = checkpoint_decision(first["context"]) + return control.complete_task(*args, **options, read_context_id=decision["read_context_id"], + agent_vision=decision.get("agent_vision"), + vision_unchanged_reason=decision.get("vision_unchanged_reason") or None) + + +def test_real_mcp_unconnected_delivery_is_rejected_before_settlement(tmp_path: Path) -> None: + registry, state_file = _write_fixture(tmp_path) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"].pop("status") + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Deliver the bounded fixture.", task_class="advancement_task", claimed_by=AGENT_ID) + before = state_file.read_bytes() + + result = json.loads(_control(registry).complete_task(added["todo_id"], AGENT_ID, "Verified fixture.")) + + assert result["ok"] is False + assert result["settlement"]["failed_stage"] == "guard" + assert "delivery_not_allowed" in result["settlement"]["reason"] + assert state_file.read_bytes() == before + index = registry.parent / "runtime" / "goals" / GOAL_ID / "runs" / "index.jsonl" + assert not index.exists() or not index.read_bytes() + + def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identity( tmp_path: Path, ) -> None: @@ -344,7 +379,7 @@ def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identit ) control = _control(registry) - output = control.complete_task( + output = _complete_default_task(control, todo_id, AGENT_ID, "focused fixture validation passed", @@ -387,7 +422,7 @@ def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identit assert todo_by_id[str(successor_id)]["status"] == "open" replay = json.loads( - control.complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "focused fixture validation passed", @@ -428,9 +463,10 @@ def test_real_mcp_terminal_completion_closes_out_after_spend( claimed_by=AGENT_ID, ) todo_id = str(added["todo_id"]) - + control = _control(registry) + assert json.loads(control.should_run())["delivery_result_context"] result = json.loads( - _control(registry).complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "terminal fixture validation passed", @@ -489,7 +525,7 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response( lost_after: str, ) -> None: """The real write commits, but its caller sees a failure: retry must not pay twice.""" - registry, _ = _write_fixture(tmp_path) + registry, state_file = _write_fixture(tmp_path) added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, role="agent", text="Validate response-loss recovery.", task_class="advancement_task", @@ -497,6 +533,7 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response( ) control = _control(registry) original = control.run_cli + assert json.loads(control.should_run())["delivery_result_context"] injected = False bound_identity = None @@ -512,7 +549,7 @@ def lose_once(args, **kwargs): return output control.run_cli = lose_once - first = json.loads(control.complete_task( + first = json.loads(_complete_default_task(control, added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert injected and first["ok"] is False @@ -523,18 +560,28 @@ def lose_once(args, **kwargs): ) assert readback.replay_phase == ("settled" if lost_after == "spend" else "settlement_pending") if lost_after != "spend": + before_terminal = state_file.read_bytes() rc, terminal = _run_cli( registry, "todo", "complete", "--goal-id", GOAL_ID, "--todo-id", added["todo_id"], "--agent-id", AGENT_ID, "--turn-instance-id", bound_identity["turn_instance_id"], "--no-follow-up", "--evidence", "synthetic terminal intent", ) - assert rc != 0 and terminal["settlement_blocked_completion"] is True - replay = json.loads(control.complete_task( + # New default admissions retain the successful result while deferring + # closeout. Replaying it cannot close the Goal or change its evidence. + assert rc == 0 and terminal["ok"] is True, terminal + todo = next(row for row in parse_active_state_todos( + state_file.read_text(encoding="utf-8"))["agent_todos"]["items"] + if row["todo_id"] == added["todo_id"]) + assert todo["status"] == "done" and todo["completion_continuation"] == "active_goal" + assert todo["completion_requested_continuation"] == "no_followup" + assert todo.get("no_followup") is not True and todo.get("completion_recovery") is None + assert state_file.read_bytes() == before_terminal + replay = json.loads(_complete_default_task(control, added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert replay["ok"] is True - again = json.loads(control.complete_task( + again = json.loads(_complete_default_task(control, added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert again["ok"] is True @@ -555,16 +602,18 @@ def test_real_mcp_links_existing_successor_without_creating_another_todo( )["todo_id"]) for text in ("First accepted step.", "Already planned follow-up.")] control = _control(registry) before = state_file.read_bytes() - rejected = json.loads(control.complete_task( + work = json.loads(control.should_run(revalidate_todo_id=ids[0])) + assert work.get("delivery_result_context"), work + rejected = json.loads(_complete_default_task(control, ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], no_follow_up=True, )) assert rejected["ok"] is False assert state_file.read_bytes() == before - first = json.loads(control.complete_task( + first = json.loads(_complete_default_task(control, ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], )) assert first["ok"] is True, first - replay = json.loads(control.complete_task( + replay = json.loads(_complete_default_task(control, ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], )) assert replay["ok"] is True diff --git a/tests/test_kunluncode_goal_mode.py b/tests/test_kunluncode_goal_mode.py index 69815dceaa..746358e954 100644 --- a/tests/test_kunluncode_goal_mode.py +++ b/tests/test_kunluncode_goal_mode.py @@ -1246,8 +1246,10 @@ def test_strict_native_goal_requires_verification_passed_event() -> None: ) +@pytest.mark.parametrize("first_delivery", [False, True]) def test_native_controller_blocks_model_visible_mcp_mutations( monkeypatch: pytest.MonkeyPatch, + first_delivery: bool, ) -> None: server, control = create_fastmcp_server( GoalModeMCPConfig( @@ -1279,6 +1281,9 @@ def test_native_controller_blocks_model_visible_mcp_mutations( "agent_id": "kunlun", "evidence": "premature evidence", "claim_operation_id": "claim-fixture", + "first_delivery": first_delivery, + "delivery_read_context_id": "original-result", + "read_context_id": "original-direction", }, ) ) diff --git a/tests/test_usage_ping.py b/tests/test_usage_ping.py index 18f3d016fa..6f372f9696 100644 --- a/tests/test_usage_ping.py +++ b/tests/test_usage_ping.py @@ -610,3 +610,25 @@ def test_projection_failure_cannot_replace_business_output(isolated, monkeypatch assert json.loads(capsys.readouterr().out) == payload assert 'result_facts' not in usage_ping._observation.get() usage_ping.finish(None, 'turn', 0) + + +@pytest.mark.parametrize('compact', [False, True]) +@pytest.mark.parametrize('fmt', ['json', 'markdown']) +def test_payload_formatting_preserves_values_and_one_capture(monkeypatch, capsys, compact, fmt): + from loopx.cli_runtime import print_payload + payload = {'ok': True, 'evidence': [{'summary': '依据', 'count': 3}], + 'reason': None, 'must_attempt_work': False, 'command': 'loopx --goal-id test'} + captured = [] + monkeypatch.setattr(usage_ping, 'capture_result', captured.append) + print_payload(payload, fmt, lambda value: f"# Result\n{value['evidence'][0]['summary']}", + compact_json=compact) + text = capsys.readouterr().out + assert captured == [payload] and captured[0] is payload + if fmt == 'json': + assert json.loads(text) == payload + if compact: + assert len(text.splitlines()) == 1 + else: + assert '\n "evidence"' in text + else: + assert text == '# Result\n依据\n' From c6937eba3baa416af0aa26dfec52928c7aea0cc4 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 04:22:23 +0800 Subject: [PATCH 02/80] fix: preserve native turn recovery and packaged SDK continuity Signed-off-by: Tartar --- apps/presentation/dashboard/src/data/chat.ts | 2 + .../personal-workspace/goal-loopx-mode.tsx | 5 +- loopx/chat_agent.py | 27 +++ loopx/chat_coordinator_checkpoint.py | 62 +++++ loopx/chat_loopx_mode.py | 24 ++ loopx/chat_runtime.py | 3 + loopx/cli_commands/todo.py | 19 +- loopx/collaboration_mcp.py | 26 ++- loopx/control_plane/agents/workspace_guard.py | 2 + .../collaboration/delegation_validation.py | 26 +++ loopx/control_plane/collaboration/inbox.py | 16 +- .../coordination/todo_terminal_lifecycle.ts | 64 ++++++ .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/goals/acceptance.py | 3 +- .../goals/checkpoint_context_io.py | 19 ++ .../goals/checkpoint_read_context.ts | 48 +++- .../runtime/validation_command.py | 6 +- .../todos/completion_validation.py | 5 + loopx/control_plane/turn_driver/executor.py | 26 ++- .../turn_driver/host_process_transport.py | 214 +++++++++--------- .../turn_driver/journal_store.py | 38 ++++ .../turn_driver/turn_journal_effects.ts | 105 +++++++++ .../turn_driver/windows_host_job.py | 126 +++++++++++ loopx/goal_mode_mcp.py | 15 +- loopx/project_alias.py | 1 + loopx/repository_identity.py | 2 + 26 files changed, 771 insertions(+), 114 deletions(-) create mode 100644 loopx/chat_coordinator_checkpoint.py create mode 100644 loopx/control_plane/turn_driver/windows_host_job.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index df720acba4..5fe43528fc 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1386,6 +1386,8 @@ export type LoopXModeSnapshot = { settings: Partial & { execution_config?: string }; native: { status: string; tokenBudget?: number; tokensUsed?: number }; registered_agents: string[]; paused: boolean; recovery_required: boolean; + coordinator_checkpoint?: {stage: string; result_committed?: boolean; next_action?: string; + settlement_identity?: {todo_id?: string; turn_instance_id?: string}} | null; members: Array<{id: string; agent_id: string; todo_id: string | null}>; deliveries: Array<{operation_id: string; agent_id: string; todo_id: string | null; status: string}>; ingress: Array<{client_ingress_id: string; mode: string; status: string}>; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx index 9d5a11924f..52232d56ac 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx @@ -56,6 +56,8 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { }; const status = !snapshot?.enabled ? (zh ? "普通对话" : "Conversation") : snapshot.recovery_required ? (zh ? "LoopX · 需要恢复连接" : "LoopX · Reconnect required") + : snapshot.coordinator_checkpoint?.stage === "operation_unknown" ? (zh ? "LoopX · 原执行状态无法核验" : "LoopX · Original execution unverified") + : snapshot.coordinator_checkpoint?.result_committed ? (active ? (zh ? "LoopX · 正在核验已保留结果" : "LoopX · Reviewing retained result") : (zh ? "LoopX · 结果已保留,待协调员核验" : "LoopX · Result retained, coordinator review pending")) : native === "blocked" ? (zh ? "LoopX · 需要处理阻塞" : "LoopX · Blocked") : active ? (zh ? "LoopX · 正在推进" : "LoopX · Working") : native === "complete" ? (zh ? "LoopX · 本轮已结束" : "LoopX · Run finished") @@ -95,9 +97,10 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { if (active) void mutate("pause"); else if (!configured || Number(snapshot.settings.token_budget ?? 0) <= Number(snapshot.native.tokensUsed ?? 0)) openSettings(); else onExecute(resume ? "resume" : "start"); - }}>{active ? : }{active ? (zh ? "暂停协调员" : "Pause coordinator") : !snapshot?.enabled ? (zh ? "开启 LoopX 模式" : "Enable LoopX") : native === "complete" ? (zh ? "开启新一轮" : "Start new run") : (zh ? "恢复推进" : "Continue")} + }}>{active ? : }{active ? (zh ? "暂停协调员" : "Pause coordinator") : !snapshot?.enabled ? (zh ? "开启 LoopX 模式" : "Enable LoopX") : snapshot.coordinator_checkpoint ? (zh ? "继续核验" : "Continue review") : native === "complete" ? (zh ? "开启新一轮" : "Start new run") : (zh ? "恢复推进" : "Continue")} + {snapshot?.coordinator_checkpoint ?

{snapshot.coordinator_checkpoint.stage === "operation_unknown" ? (zh ? "原执行状态暂不可核验;恢复连接后重读,不能据此重新派发任务。" : "Original execution cannot be verified. Reconnect and reread before dispatching any work.") : (zh ? "原执行尚未完成结算;继续核验后,协调员会读取当前事实并核验下一步。" : "Original execution is awaiting settlement. Continue review lets the coordinator read current facts and review the next step.")}

: null} {pendingMessages.length ?

{zh ? "待处理消息:" : "Pending messages: "}{pendingMessages.map(row => `${row.mode === "loopx_queue" ? "queue" : "inbox"} · ${row.status}`).join(" / ")}

: null} {needsReadback ? : null} {(error || readError) && !panel ?

{error || readError}

: null} diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 3e8b5d5870..5dd29d0559 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -1348,6 +1348,31 @@ def _check_server_gate(self, message: dict[str, Any]) -> bool: ) return False + def _queue_tool_after_pending_start(self, message: dict[str, Any]) -> bool: + if ( + self.current_turn_id + or message.get("method") != "item/tool/call" + or _event_thread_id(message) != self.thread_id + ): + return False + turn_id = _event_turn_id(message) + if not turn_id: + return False + with self._pending_events.mutex: + pending_start = any( + event.get("method") == "turn/started" + and _event_thread_id(event) == self.thread_id + and _event_turn_id(event) == turn_id + for event in self._pending_events.queue + ) + if not pending_start: + return False + # A control RPC can read the notification before the Goal driver binds + # its Turn. Keep the tool behind that notification; the normal gate + # still checks its identity and permission after the driver binds it. + self._pending_events.put(message) + return True + def _request( self, method: str, @@ -1415,6 +1440,8 @@ def _request( "Codex app-server returned an unreadable response." ) message = raw + if self._queue_tool_after_pending_start(message): + continue if message.get("method") and self._check_server_gate( message ): diff --git a/loopx/chat_coordinator_checkpoint.py b/loopx/chat_coordinator_checkpoint.py new file mode 100644 index 0000000000..29fef449f2 --- /dev/null +++ b/loopx/chat_coordinator_checkpoint.py @@ -0,0 +1,62 @@ +"""Chat transport for an already committed coordinator result's native checkpoint.""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from .collaboration_mcp import _pinned_release_environment, _python_module_command +from .goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane +from .agent_registry import load_goal_from_registry +from .todos import list_goal_todos + + +class _CheckpointControlPlane(GoalModeMCPControlPlane): + def __init__(self, registry: Path, root: Path, goal_id: str, agent_id: str): + self.registry, self.root = registry, root + super().__init__(GoalModeMCPConfig( + server_name="loopx-chat", runtime_profile="codex_cli", + legacy_host_surface="codex_cli", + ), lambda: {"goal_id": goal_id, "agent_id": agent_id, "registry": str(registry)}) + + def run_cli(self, args, *, legacy_args=None): + goal = load_goal_from_registry(self.registry, self.context()[0]) + if not goal: + raise ValueError("Goal unavailable") + prefix = [*_python_module_command("loopx.cli"), "--registry", str(self.registry), + "--runtime-root", str(self.root), "--format", "json"] + result = subprocess.run([*prefix, *args], cwd=goal["repo"], + env=_pinned_release_environment(), stdin=subprocess.DEVNULL, + capture_output=True, text=True, encoding="utf-8", timeout=60) + return result.stdout or json.dumps({"ok": False, "error": "checkpoint_command_unavailable"}) + + +class CoordinatorCheckpoints: + """Only resume post-result work; native owners retain all acceptance decisions.""" + def __init__(self, registry: Path, root: Path, goal_id: str, agent_id: str): + self.registry, self.root, self.goal_id, self.agent_id = registry, root, goal_id, agent_id + self.control = _CheckpointControlPlane(registry, root, goal_id, agent_id) + + def _task(self, todo_id: str) -> dict: + todo = list_goal_todos(registry_path=self.registry, runtime_root_arg=str(self.root), + goal_id=self.goal_id, todo_id=todo_id).get("todo") + if (not todo or todo.get("claimed_by") != self.agent_id or todo.get("status") != "done" + or not todo.get("completion_turn_key") or not self.control._original_work_turn(todo_id)): + raise ValueError("the coordinator has no committed original result for this task") + return todo + + def read(self, todo_id: str) -> dict: + self._task(todo_id) + return json.loads(self.control.review_task_vision(todo_id, self.agent_id)) + + def resume(self, todo_id: str, decision: dict) -> dict: + if (not isinstance(decision, dict) + or set(decision) - {"read_context_id", "agent_vision", "vision_unchanged_reason"} + or not isinstance(decision.get("read_context_id"), str) + or not decision["read_context_id"].strip() + or bool(decision.get("agent_vision")) == bool(decision.get("vision_unchanged_reason"))): + raise ValueError("resume requires the Agent's fresh checkpoint context and one vision decision") + todo = self._task(todo_id) + return json.loads(self.control.complete_task(todo_id, self.agent_id, todo.get("evidence") or "", + read_context_id=decision["read_context_id"], agent_vision=decision.get("agent_vision"), + vision_unchanged_reason=decision.get("vision_unchanged_reason") or "")) diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py index 5ebbd1a378..5bf7b63906 100644 --- a/loopx/chat_loopx_mode.py +++ b/loopx/chat_loopx_mode.py @@ -43,6 +43,9 @@ "Action=adopt(operation_id,consumer_operation_id) records your decision after both results " "are accepted and the consumer has that exact uses input. Reading alone is not adoption. " "Read/wait/resume use the original operation_id. Running is not failure; do not duplicate it. " + "For a committed coordinator Todo awaiting direction, read(operation_id=todo_id, brief={purpose:first_delivery}) returns " + "its native checkpoint; resume with brief={purpose:first_delivery, read_context_id, agent_vision OR vision_unchanged_reason} " + "settles only that original result after your fresh judgment. Never substitute a new token onto an old judgment. " "After context loss, action=operations recovers this requester's durable work. Follow " "next_cursor for more; unavailable means reconcile, not redispatch. Action=inspect with binding_id checks the actual Turn/profile before new dispatch; unknown availability is not readiness. " "Action=prepare_check takes a stable operation_id and an acceptance document prepared from the owner's requested outcome and current task/file facts. " @@ -87,6 +90,9 @@ "member completion. Synthesize their actual artifacts and report remaining gaps here. " "For task_failure, repair the original artifact or validation environment before action=revalidate; it reruns checks without Host work. " "When current_use.state=unavailable, repair the declared source or receiver input and read again; do not propagate those artifacts as current facts. Read action=messages between work steps for owner inbox additions. " + "If scoped evidence reports coordinator_checkpoint, use loopx_collaboration read with its original Todo id and brief={purpose:first_delivery}, " + "judge the returned basis, then resume that same Todo with your brief containing its read_context_id " + "purpose:first_delivery and exactly one agent_vision or vision_unchanged_reason. Member operations retain their existing semantics. " "Do not claim the whole canonical Goal is complete. Keep independent analysis substantive. " "For a substantive deliverable or correction in a direct user message, reuse current inline outcome context (or action=outcomes if absent) and refine its stable outcome before work. " "At a material delivery milestone, review all retained requirements against actual artifacts; record evidence and unmet/uncertain conclusions in optional review. " @@ -265,6 +271,11 @@ def snapshot(self, session_id): else None ) deliveries = session.get("loopx_deliveries") or [] + checkpoint = None + if settings.get("agent_id"): + from .control_plane.goals.checkpoint_context_io import pending_first_delivery_progress + checkpoint = pending_first_delivery_progress(self.store.root.parent, session["goal_id"], + settings["agent_id"], registry_path=self.controller.registry_path) # Refresh unfinished observations even while the coordinator is paused: # delegated members have their own lifecycles. Accepted labels remain # explicitly last-read observations, never canonical Goal settlement. @@ -298,6 +309,7 @@ def snapshot(self, session_id): active and session_id not in self.controller.adapters ), "deliveries": deliveries, + "coordinator_checkpoint": checkpoint, "ingress": [ {key: row.get(key) for key in ("client_ingress_id", "mode", "status")} for row in self.store.loopx_ingress(session_id)[-20:] @@ -896,6 +908,18 @@ def dispatch(name, arguments): if action != "adopt" and "consumer_operation_id" in arguments: raise ValueError("consumer_operation_id is only valid for adopt") operation_id = arguments.get("operation_id", "") + # Todo ids and delegation operation ids have separate owners. Never + # reinterpret an existing member operation as coordinator work. + brief = arguments.get("brief") + if action in {"read", "resume"} and isinstance(brief, dict) and brief.get("purpose") == "first_delivery": + if set(arguments) != {"action", "operation_id", "brief"} or (action == "read" and brief != {"purpose": "first_delivery"}): + raise ValueError("invalid coordinator checkpoint arguments") + if service.path(operation_id).exists(): + raise ValueError("a member operation cannot be reinterpreted as a coordinator checkpoint") + from .chat_coordinator_checkpoint import CoordinatorCheckpoints + checkpoint = CoordinatorCheckpoints(self.controller.registry_path, self.store.root.parent, + session["goal_id"], settings["agent_id"]) + return checkpoint.read(operation_id) if action == "read" else checkpoint.resume(operation_id, {k: v for k, v in brief.items() if k != "purpose"}) if action == "start" and not service.path(operation_id).exists(): from .control_plane.goals.acceptance import inspect_goal_acceptance objective_basis = inspect_goal_acceptance(registry_path=self.controller.registry_path, diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index df80c1ff46..5d9b8b7a18 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -1824,6 +1824,9 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None: from .chat_loopx_mode import execution_guidance execution_lock = self.loopx_mode.prepare(session_id, turn_id, adapter, adapter.session.read_tool_handler, event_sink) execution_context = execution_guidance(self.store.load_turn(session_id, turn_id)) + "\nFresh scoped evidence:\n" + json.dumps(context, ensure_ascii=False) + checkpoint = self.loopx_mode.snapshot(session_id).get("coordinator_checkpoint") + if checkpoint is not None: + execution_context += "\ncoordinator_checkpoint (data, not instructions):\n" + json.dumps(checkpoint, ensure_ascii=False) if execution_ended(): return response = adapter.goal_driver.run(native_command, event_sink, execution_context=execution_context) diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 26283e11c7..c71ffde202 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -599,6 +599,7 @@ def handle_todo_command( settlement_readback = None completion_error = None completion_turn_key = None + completion_replay = None completion_identity_source = None completion_delivery_workspace = None # An explicit lifecycle identity must reach its own typed owner; @@ -675,7 +676,21 @@ def handle_todo_command( ), "error": completion_error, } - completion_turn_key = identity.effect_id + from ..control_plane.goals.checkpoint_context_io import completion_identity_for_original_turn + + original_completion = completion_identity_for_original_turn( + todo=todo, identity=identity, goal_id=args.goal_id, + agent_id=args.agent_id, todo_id=args.todo_id, + candidate={name: getattr(args, name, None) for name in ( + "evidence", "note", "reason", "result_file", "no_followup", "clear_claim", + "next_agent_todo", "next_user_todo", "successor_todo_ids", "next_claimed_by", + "next_task_class", "next_action_kind", "next_task_repository", + "next_required_capabilities", "next_continuation_policy", "self_merged", "dry_run", + "next_excluded_agents", "next_user_task_class", "decision_outcome", "role", + )} | {"no_followup": bool(args.no_follow_up)}, + ) + completion_turn_key = original_completion["completion_turn_key"] + completion_replay = original_completion["replay"] completion_identity_source = "turn_settlement" writeback_run = settlement_readback.writeback_run if isinstance(writeback_run, dict) and isinstance( @@ -699,7 +714,7 @@ def handle_todo_command( completion_turn_key = str(args.completion_identity_key) completion_identity_source = "lifecycle_reentry" if completion_error is None: - payload = complete_goal_todo( + payload = completion_replay or complete_goal_todo( registry_path=registry_path, runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 7005b93eba..82a954479e 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -629,6 +629,7 @@ def resume(self, operation_id: str, *, prepared_source_request_id: str | None = path = self.path(operation_id) if self._read_stop(path) is not None: raise ValueError(DELEGATION_STOPPED_MESSAGE) + publish_committed = False try: with exclusive_file_lock( path, policy=LockAcquisitionPolicy.SINGLE_FLIGHT @@ -649,12 +650,17 @@ def resume(self, operation_id: str, *, prepared_source_request_id: str | None = else row["status"] not in DELEGATION_TERMINAL_STATUSES) if should_spawn: self._bound(row, require_active=True) + publish_committed = delegation_validation.committed_revalidation_key( + self, row, binding + ) is not None except LockAcquireTimeoutError: # A live worker already owns the operation lock. Observation is # sufficient; spawning another process cannot advance settlement. return self.read(operation_id) if should_spawn: - if prepared_source_request_id is None: + if publish_committed: + self.execute(operation_id) + elif prepared_source_request_id is None: self._spawn(operation_id) else: # Carry prepared CAS across startup; a delayed child only observes advanced work. @@ -708,7 +714,9 @@ def revalidate(self, operation_id: str) -> dict: self._record_turn_result(path, row, result, publish=False) recovered = delegation_validation.recover_validated_settlement(self, path, row, binding) if recovered: - self._spawn(operation_id) + # Revalidation already ran its bounded CLI. Publish that original + # result before its MCP caller closes; no new Host work is started. + self.execute(operation_id) return self.read(operation_id) def _bound( @@ -1201,6 +1209,7 @@ def _cli(self, binding: dict, *args: str, timeout: int = 60, # The Turn's Host transport names the process group its supervisor owns. environment[HOST_PROCESS_RECORD_ENV] = str(host_record) if delegated_lease is None: + # This argv-only CLI must not inherit the MCP conversation's input pipe. completed = subprocess.run([*_python_module_command("loopx.cli"), *arguments], cwd=binding["workspace"], capture_output=True, text=True, encoding="utf-8", # Requests are entirely in argv; never inherit the caller's MCP/HTTP pipe. @@ -1542,6 +1551,8 @@ def _complete_delegated_todo(self, row: dict, binding: dict) -> None: if binding["todo_id"] is None: self._validate(binding) return # Request validation never creates or completes a Todo. + if delegation_validation.original_task_completed(self, row, binding): + return lease = row.get("task_lease") if not isinstance(lease, dict): raise ValueError("delegation Todo completion requires its acquired task lease") @@ -1609,6 +1620,17 @@ def _complete_delegated_todo(self, row: dict, binding: dict) -> None: # Goal intent. Ordinary completion may precede its original Turn's # accounting (controller validation requires that order). Do not add # no-follow-up, which correctly requires already-settled receipts. + if delegation_validation.original_work_context(self, row, binding) is not None: + if "completion_read_context_id" not in row: + context = self._cli(binding, "checkpoint-context", "--goal-id", self.goal_id, + "--agent-id", binding["agent_id"], "--todo-id", binding["todo_id"], + "--turn-instance-id", self._turn_instance_id(row), "--purpose", "delivery_result") + row["completion_read_context_id"] = context["read_context_id"] + _write(self.path(row["identity"]["operation_id"]), row) + # This is a new work read, not a new Turn or direction judgment. + # Native completion independently verifies the retained candidate + # on this basis and rejects changes during that verification. + arguments += ["--delivery-read-context", row["completion_read_context_id"]] result = self._cli(binding, *arguments) if result.get("ok") is not True: raise ValueError( diff --git a/loopx/control_plane/agents/workspace_guard.py b/loopx/control_plane/agents/workspace_guard.py index 551a08403d..268b30960a 100644 --- a/loopx/control_plane/agents/workspace_guard.py +++ b/loopx/control_plane/agents/workspace_guard.py @@ -40,6 +40,7 @@ def _git_command_output(path: Path, *args: str) -> str | None: try: result = subprocess.run( ["git", "-C", str(path), *args], + stdin=subprocess.DEVNULL, check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, @@ -135,6 +136,7 @@ def _git_origin_is_absent(path: Path) -> bool: try: result = subprocess.run( ["git", "-C", str(path), "config", "--get", "remote.origin.url"], + stdin=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1.5, ) diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py index a34ad76538..48d06892a5 100644 --- a/loopx/control_plane/collaboration/delegation_validation.py +++ b/loopx/control_plane/collaboration/delegation_validation.py @@ -255,6 +255,7 @@ def validate(service, binding: dict) -> dict: effect=effect, registry_path=service.registry, goal_id=service.goal_id, delivery_workspace=before["delivery_workspace"], validation_workspace_path=Path(binding["workspace"]), + stdin=subprocess.DEVNULL, ) for effect in before["plan"]["effects"]] after = capture(service, binding) # The TS plan binds this task's work, claim/lifecycle and current rules. @@ -406,6 +407,31 @@ def matching_turn_key(service, row: dict, binding: dict) -> str | None: ) +def original_work_context(service, row: dict, binding: dict) -> dict | None: + key = matching_turn_key(service, row, binding) + if key is None: + return None + journal = load_turn_journal(turn_journal_path(service.root, goal_id=service.goal_id, turn_key=key)) + context = (journal or {}).get("result_work_context") + return context if isinstance(context, dict) else None + + +def original_task_completed(service, row: dict, binding: dict) -> bool: + """Reconcile this Turn's native completion before replaying its effects.""" + from ..goals.checkpoint_context_io import TurnResultWork + from ..quota.settlement import SettlementIdentity + + context = original_work_context(service, row, binding) + if context is None: + return False + identity = SettlementIdentity.from_runtime_payload(context["settlement_identity"]) + if (identity.goal_id, identity.agent_id, identity.todo_id, identity.turn_instance_id) != ( + service.goal_id, binding["agent_id"], binding["todo_id"], service._turn_instance_id(row)): + raise ValueError("delegation result work identity changed; reconcile the original Turn") + return TurnResultWork(service.registry, service.root, identity, + service._caller_goal_ref()).result_committed() + + def validated_turn_journal(service, row: dict, binding: dict) -> dict | None: turn_key = matching_turn_key(service, row, binding) if turn_key is None: diff --git a/loopx/control_plane/collaboration/inbox.py b/loopx/control_plane/collaboration/inbox.py index 0b77ebe5bb..cf1b33991b 100644 --- a/loopx/control_plane/collaboration/inbox.py +++ b/loopx/control_plane/collaboration/inbox.py @@ -11,6 +11,7 @@ import os import re import tempfile +import time from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path @@ -88,7 +89,20 @@ def _write(path: Path, value: dict) -> None: json.dump(value, f, ensure_ascii=False) f.flush() os.fsync(f.fileno()) - os.replace(tmp, path) + # Windows observers may briefly deny DELETE sharing. Retain the old + # complete record until replacement succeeds, as for lock sidecars. + # This retries only the same IO effect; persistent denial still fails. + deadline = time.monotonic() + 0.5 + while True: + try: + os.replace(tmp, path) + break + except OSError as error: + remaining = deadline - time.monotonic() + if (os.name != "nt" or getattr(error, "winerror", None) != 5 + or remaining <= 0): + raise + time.sleep(min(0.05, remaining)) finally: if os.path.exists(tmp): os.unlink(tmp) diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 0cd5ec46e1..5592eeb3b8 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1206,6 +1206,44 @@ function semanticDuplicate( return undefined; } +async function exactStoredDeferredParent( + store: AuthorityStore, input: ResolvedCoordinationTodoTerminalLifecycleInput, + todo: JsonObject, +): Promise { + const operationId = completionTurnOperationId(input, false); + const saved = await store.readReceipt(operationId); + if (saved.status !== "found" || saved.receipts.length !== 1) return null; + const receipt = saved.receipts[0]; + if (receipt.schema_version !== COORDINATION_TODO_TERMINAL_LIFECYCLE_RECEIPT_SCHEMA || + receipt.operation_id !== operationId || receipt.goal_id !== input.goal_id || + receipt.todo_id !== input.todo_id || receipt.command !== "complete" || + typeof receipt.delivery_read_context_id !== "string") return null; + const result = canonicalAuthorityObject(receipt.result, "original completion result"); + if (result.completion_policy == null) return null; + const recordedPolicy = canonicalAuthorityObject(result.completion_policy, "original completion policy"); + if (!Object.hasOwn(recordedPolicy, "effective_claimed_by")) return null; + const policy = input.completion_policy_request; + if (policy?.claimed_by != null && policy.claimed_by !== todo.claimed_by) return null; + // Optional transport defaults preserve the committed result. Even this + // reconstruction must match its entire original fingerprint; no supplied + // actor, lease, evidence, read identity or validation is replaced. + const recovered = {...input, + expected_role: input.expected_role ?? requireLiteral(todo.role, TODO_ROLES, "original Todo role"), + note: input.note ?? optionalString(todo.note, "original note"), + evidence: input.evidence ?? optionalString(todo.evidence, "original evidence"), + reason: input.reason ?? optionalString(todo.reason, "original reason"), + delivery_read_context_id: input.delivery_read_context_id ?? receipt.delivery_read_context_id, + completion_policy_request: policy === null ? null : {...policy, + claimed_by: recordedPolicy.effective_claimed_by}, + operation_id: operationId, defer_no_followup: true}; + for (const requestedNoFollowup of [false, true]) { + const parent = {...recovered, requested_no_followup: requestedNoFollowup}; + const exact = await terminalReceipt(parent, terminalRequestSha(parent)).read(store); + if (exact?.status === "replayed") return exact; + } + return null; +} + /** * Execute complete/supersede as one provider-neutral CAS transaction. * @@ -1277,6 +1315,28 @@ export async function executeCoordinationTodoTerminalLifecycle( const original = await terminalReceipt(parent, terminalRequestSha(parent)).read(store); if (original?.status === "replayed") return original; } + if (replay?.reason_code === "coordination_operation_identity_mismatch" && + normalized.operation_identity.kind === "completion_turn" && + normalized.requested_no_followup && normalized.defer_no_followup === true) { + // Pending checkpoint reentry recovers the accepted RESULT as history. + // It cannot finalize the continuation or perform a new mutation. The + // full historical hash still binds every supplied execution identity. + const current = await store.loadAuthority(); + if (current.status === "loaded") { + let todo: JsonObject | undefined; + try { + todo = indexCoordinationProjection(current.head, input.goal_id).todos.get(input.todo_id); + } catch { + return replay; + } + if (todo?.status === "done" && + todo.completion_turn_key === input.requested_completion_turn_key && + todo.completion_requested_continuation === "no_followup") { + const original = await exactStoredDeferredParent(store, input, todo); + if (original?.status === "replayed") return original; + } + } + } if (replay !== null) return replay; if (normalized.operation_identity.kind === "completion_turn" && normalized.requested_no_followup && normalized.defer_no_followup !== true) { // Older adapters used the ordinary id for an already accepted closeout. @@ -1642,6 +1702,10 @@ export async function executeCoordinationTodoTerminalLifecycle( } } } + if (todo.completion_requested_continuation === "no_followup" && + (original === null || original.reason_code === "coordination_operation_identity_mismatch")) { + original = await exactStoredDeferredParent(store, originalInput, todo) ?? original; + } if (original === null) return terminalFailure("terminal_completion_receipt_required", "Same-Turn closeout requires the original accepted ordinary completion receipt.", {}, "decision_rejection"); if (original.status !== "replayed") return original; diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index d48fdb3532..9aab62b060 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -268,6 +268,7 @@ export function createEffectRuntimeHandlers( ], ["turn.controller", lazyHandler(() => import("./turn_driver/loop_controller.ts"), ({controllerRequest}) => controllerRequest)], ["turn_journal.write", lazyHandler(() => import("./turn_driver/turn_journal_effects.ts"), ({commitTurnJournal}) => commitTurnJournal)], + ["turn_journal.reconcile_settlement", lazyHandler(() => import("./turn_driver/turn_journal_effects.ts"), ({reconcileTurnJournalSettlement}) => reconcileTurnJournalSettlement)], ["turn_journal.find_settlement", lazyHandler(() => import("./turn_driver/turn_journal_query.ts"), ({findTurnJournalBySettlement}) => findTurnJournalBySettlement)], ["turn_journal.observed_capabilities", lazyHandler(() => import("./turn_driver/turn_journal_query.ts"), ({readTurnJournalCapabilities}) => readTurnJournalCapabilities)], ["todo.completion_fence.evaluate", lazyHandler(() => import("./todos/completion_fence.ts"), ({evaluateTodoCompletionFence}) => evaluateTodoCompletionFence)], diff --git a/loopx/control_plane/goals/acceptance.py b/loopx/control_plane/goals/acceptance.py index 7137059b44..148005f097 100644 --- a/loopx/control_plane/goals/acceptance.py +++ b/loopx/control_plane/goals/acceptance.py @@ -447,6 +447,7 @@ def run_goal_acceptance_validation_effect( goal_id: str, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + stdin: int | None = None, ) -> dict[str, Any]: """Use the ordinary runner while preserving declared verifier-file identity. @@ -503,7 +504,7 @@ def stale_receipt() -> dict[str, Any]: receipt = run_declared_completion_validation_effect( effect={key: value for key, value in effect.items() if key != "validation_files"}, registry_path=registry_path, goal_id=goal_id, - delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, + delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, stdin=stdin, ) if not _acceptance_pins_match(pins, workspace): return stale_receipt() diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index cd0cbd70ef..310a8000fd 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -214,6 +214,25 @@ def terminal_validation_effect_io(*, runtime_root: Path, request: Mapping[str, A return result +def completion_identity_for_original_turn(*, todo: Mapping[str, Any], identity: SettlementIdentity, + goal_id: str, agent_id: str, todo_id: str, + candidate: Mapping[str, Any]) -> dict[str, Any]: + """Forward the admitted identity and actual Todo to the existing typed owner.""" + result = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { + "phase": "completion_identity", "todo": dict(todo), "identity": identity.as_dict(), + "goal_id": goal_id, "agent_id": agent_id, "todo_id": todo_id, + "candidate": dict(candidate), + }) + key = result.get("completion_turn_key") + if not isinstance(key, str) or not key: + raise RuntimeError("Typed original completion identity is unavailable") + replay = result.get("replay") + if replay is not None and (not isinstance(replay, dict) or replay.get("ok") is not True + or replay.get("completed") is not True or replay.get("changed") is not False): + raise RuntimeError("Typed original completion replay is invalid") + return dict(result) + + class TurnResultWork: """Managed Turn IO for the existing typed work-basis owner.""" diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index e6f7d1a419..1951f99ce6 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -1,7 +1,6 @@ /** Read basis for a missing decision/qualification checkpoint, not an execution/permission lease. * The commit effect holds source/index claims and the real provider fence. */ -import type {JsonObject} from "../effect_program.ts"; -import {settlementIdentity} from "../effect_program.ts"; +import {settlementIdentity, type JsonObject} from "../effect_program.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; @@ -12,6 +11,8 @@ import {visionAuthoringContract} from "./vision_checkpoint.ts"; import {hostRetryPolicyCheck} from "../turn_driver/turn_journal.ts"; import {originalTurnRequestsNoFollowup} from "../todos/completion_state.ts"; import {terminalValidationRecovery} from "../todos/completion_validation_plan.ts"; +import {decodeTodoCompletionFenceRequest, evaluateTodoCompletionFence, + TODO_COMPLETION_FENCE_REQUEST_SCHEMA} from "../todos/completion_fence.ts"; const RECEIPT_SCHEMA = "checkpoint_read_context_v1"; const FIRST_RECEIPT_SCHEMA = "checkpoint_read_context_v2"; @@ -278,6 +279,49 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { /** Work input and validation share the existing read-receipt identity owner. */ export function evaluateResultWorkBinding(value: unknown): JsonObject { const request = requireJsonObject(value, "result work binding"); + if (request.phase === "completion_identity") { + const supplied = requireJsonObject(request.identity, "completion identity"); + const identity = settlementIdentity({ + goal_id: requireNonEmptyString(request.goal_id, "goal_id"), + agent_id: requireNonEmptyString(request.agent_id, "agent_id"), + todo_id: requireNonEmptyString(request.todo_id, "todo_id"), + turn_instance_id: requireNonEmptyString(supplied.turn_instance_id, "turn_instance_id"), + }); + if (["goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id"].some( + field => supplied[field] !== identity[field as keyof typeof identity], + )) throw new EffectRuntimeRequestError("completion identity does not match the original scoped Turn"); + const todo = requireJsonObject(request.todo, "completion Todo"); + // Managed journals store the raw Turn id; interactive callers store its + // scoped effect id. Preserve history only for this already committed result. + const managedReplay = todo.status === "done" && todo.todo_id === identity.todo_id && + todo.claimed_by === identity.agent_id && todo.completion_turn_key === identity.turn_instance_id; + const key = managedReplay ? identity.turn_instance_id : identity.effect_id; + const candidate = jsonObject(request.candidate); + const unchanged = candidate !== null && + ["evidence", "note", "reason"].every(field => candidate[field] == null || candidate[field] === todo[field]) && + ["result_file", "next_agent_todo", "next_user_todo", "next_claimed_by", "next_task_class", + "next_action_kind", "next_task_repository", "next_required_capabilities", "next_continuation_policy", + "next_excluded_agents", "next_user_task_class", "decision_outcome"] + .every(field => candidate[field] == null || candidate[field] === "") && + (candidate.successor_todo_ids == null || (Array.isArray(candidate.successor_todo_ids) && candidate.successor_todo_ids.length === 0)) && + candidate.clear_claim !== true && candidate.self_merged !== true && + (candidate.role == null || candidate.role === todo.role); + if (managedReplay && unchanged) { + const fence = evaluateTodoCompletionFence(decodeTodoCompletionFenceRequest({ + schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, projection_source: "materialized", + todo, goal_id: identity.goal_id, todo_id: identity.todo_id, + requested_no_followup: candidate.no_followup === true, + requested_completion_turn_key: key, requested_completion_identity_source: "turn_settlement", + })); + if (fence.outcome === "replay") return {completion_turn_key: key, replay: { + ok: true, completed: true, changed: false, idempotent_replay: true, + dry_run: candidate.dry_run === true, goal_id: identity.goal_id, todo_id: identity.todo_id, + status: "done", completion_continuation: fence.completion_continuation, + completion_receipt_id: todo.completion_receipt_id ?? null, updated_at: todo.updated_at ?? null, + }}; + } + return {completion_turn_key: key, replay: null}; + } if (request.phase === "direction_terminal_intent") { const todo = requireJsonObject(request.todo, "completion Todo"); const identity = requireJsonObject(request.identity, "completion identity"); diff --git a/loopx/control_plane/runtime/validation_command.py b/loopx/control_plane/runtime/validation_command.py index 1e896e9b5b..2efce5dbfb 100644 --- a/loopx/control_plane/runtime/validation_command.py +++ b/loopx/control_plane/runtime/validation_command.py @@ -39,12 +39,14 @@ def run_caller_validation( validation_argv: list[str] | None = None, validation_label: str, timeout_seconds: int, + stdin: int | None = None, ) -> dict[str, Any]: """Run a caller-approved validation command and return a privacy-safe receipt. Exactly one of ``validation_command`` (shell-free ``shlex.split``) or ``validation_argv`` (pre-split JSON argv array, no shell parsing) selects - the command form. The command runs with ``cwd=workspace``. + the command form. The command runs with ``cwd=workspace``. Host adapters + can isolate protocol input; ordinary callers retain inherited stdin. Only ``exit_code`` and the boolean ``passed`` are recorded; command stdout, stderr, and local paths are deliberately not captured. A timeout raises ``subprocess.TimeoutExpired``; callers decide whether to convert that into @@ -67,7 +69,7 @@ def run_caller_validation( argv, cwd=workspace, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, - stdin=_VALIDATION_STDIN.get(), + stdin=stdin if stdin is not None else _VALIDATION_STDIN.get(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=os.name == "posix", diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index 0f77e5978d..70c009fd9b 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -257,6 +257,7 @@ def _run_declared_completion_validation( task_repository: str | None = None, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + stdin: int | None = None, ) -> dict[str, Any] | None: """Run a todo's declared caller-approved validation command. @@ -299,6 +300,7 @@ def _run_declared_completion_validation( validation_argv=validation_argv, validation_label=label, timeout_seconds=timeout_seconds, + stdin=stdin, ), ) return cast( @@ -308,6 +310,7 @@ def _run_declared_completion_validation( validation_command=str(validation_command), validation_label=label, timeout_seconds=timeout_seconds, + stdin=stdin, ), ) except subprocess.TimeoutExpired: @@ -363,6 +366,7 @@ def run_declared_completion_validation_effect( goal_id: str, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + stdin: int | None = None, ) -> dict[str, Any]: """Execute exactly one TypeScript-authorized validation effect. @@ -414,6 +418,7 @@ def run_declared_completion_validation_effect( ), delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, + stdin=stdin, ) if receipt is None: raise RuntimeError("authorized validation effect produced no receipt") diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index bda9293611..15dabdd1ac 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -1627,8 +1627,32 @@ def run_loopx_turn_once( "receipt": None, "scheduler": {"disposition": "not_evaluated"}, } + # A retained Turn may only need settlement. Project the same typed + # recovery owner that execution consumes, without changing its journal + # or calling any execution callback. A new Turn keeps the original plan. + journal = _load_journal(turn_journal_path( + runtime_root, goal_id=goal_id, turn_key=str(transaction_plan["turn_key"]) + )) + preview_plan = plan + if journal is not None: + envelope = plan.get("turn_envelope") + assessment = assess_existing_turn_recovery( + journal, request, goal_id=goal_id, + agent_id=str(envelope.get("agent_id") or "") + if isinstance(envelope, Mapping) else "", + turn_key=str(transaction_plan["turn_key"]), + retry_failed=retry_failed, + session_binding_resolver=session_binding_resolver, + ) + preview_plan = {**plan, "route": { + **plan["route"], + "would_invoke_host": assessment.decision["reinvoke_host"], + }} + preview["recovery_audit"] = build_turn_recovery_audit( + assessment.decision, journal, status="finished", host_invoked=False + ) return execution_payload( - plan, + preview_plan, preview, execute=False, replayed=False, diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index bd9a1c646a..1ebc75fc43 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -306,107 +306,119 @@ def run_host_process( launch_deadline = time.monotonic() + launch_budget if launch_guard is not None else None launch_arguments = (["--guarded-launch", str((time.time() + launch_budget) * 1000)] if launch_guard is not None else []) - proc = subprocess.Popen( - [ - _node_executable(), - "--no-warnings", - "--experimental-strip-types", - str(bridge), - *launch_arguments, - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - encoding="utf-8", - errors="strict", - start_new_session=True, - env=bridge_environment, - ) - assert proc.stdin is not None and proc.stdout is not None - result: dict[str, Any] | None = None - record = None - launch_scope = ExitStack() - guarded = (GuardedHostBridge(proc, deadline=launch_deadline, - cleanup_timeout=8 if delegated_lease is not None else 5) - if launch_deadline is not None else None) - try: - if launch_guard is not None: - launch_scope.enter_context(launch_guard()) - record = _begin_host_process_record(record_path, record_value=record_value, - supervises=supervises, supervision=supervision, bridge_pid=proc.pid) - frame = json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" - if guarded is not None: - guarded.send(frame) - else: - proc.stdin.write(frame) - proc.stdin.flush() - lines = iter(proc.stdout) - while True: - event = (guarded.next(deadline=launch_deadline) if guarded is not None - else json.loads(line) if (line := next(lines, None)) is not None else None) - if event is None: - break - kind = event.get("kind") - if kind == "spawned" and isinstance(event.get("pid"), int): - if record is not None: - assert record_path is not None - group = event.get("process_group") - record.update(phase="spawned", host_pid=event["pid"], - process_group=group if isinstance(group, int) else None) - _write_host_process_record(record_path, record) - launch_deadline = None - launch_scope.close() - elif kind in {"stdout", "stderr"} and isinstance(event.get("text"), str): - consume = on_stdout if kind == "stdout" else on_stderr - if consume is not None: - consume(event["text"]) - elif kind == "result" and event.get("outcome") in { - "exited", - "timeout", - "cancelled", - "output_limit", - "spawn_failed", - }: - result = event - launch_deadline = None - launch_scope.close() - elif kind == "not_launched" and guarded is not None: - guarded.not_launched = True - raise TimeoutError("managed Host bridge launch deadline expired") - else: - raise RuntimeError("Invalid managed Host process observation") - finally: + with ExitStack() as containment: + windows_job = None + if sys.platform == "win32": + from .windows_host_job import WindowsHostJob + windows_job = containment.enter_context(WindowsHostJob()) + proc = subprocess.Popen( + [ + _node_executable(), + "--no-warnings", + "--experimental-strip-types", + str(bridge), + *launch_arguments, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="strict", + start_new_session=True, + env=bridge_environment, + ) + assert proc.stdin is not None and proc.stdout is not None + result: dict[str, Any] | None = None + record = None + launch_scope = ExitStack() + guarded = (GuardedHostBridge(proc, deadline=launch_deadline, + cleanup_timeout=8 if delegated_lease is not None else 5) + if launch_deadline is not None else None) try: - if guarded is not None and (launch_deadline is not None or sys.exc_info()[0] is not None): - # A failed handoff still owns the Goal fence. The TS bridge must - # report no launch or finish its Host cleanup before it releases. - guarded.cancel() - if record is not None and guarded.not_launched: - assert record_path is not None - record.update(phase=HOST_PROCESS_NOT_LAUNCHED, process_group=None) - record.pop("bridge_pid", None) - _write_host_process_record(record_path, record) - else: - # EOF asks the TS supervisor to clean its Host, also on a - # callback failure. Leased cleanup retains its original budget. - proc.stdin.close() - try: - proc.wait(timeout=8 if delegated_lease is not None else 5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - finally: - launch_scope.close() + if windows_job is not None: + windows_job.assign(proc) + if launch_guard is not None: + launch_scope.enter_context(launch_guard()) + record = _begin_host_process_record(record_path, record_value=record_value, + supervises=supervises, supervision=supervision, bridge_pid=proc.pid) + if record is not None and windows_job is not None: + record["windows_job"] = windows_job.name + _write_host_process_record(record_path, record) + frame = json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" if guarded is not None: - guarded.close_output() + guarded.send(frame) else: - proc.stdout.close() - if record is not None and result is not None and proc.returncode == 0: - assert record_path is not None - # The supervisor returned only after cleaning its group; the group is still re-read. - record["phase"] = "finished" - _write_host_process_record(record_path, record) - if proc.returncode != 0 or result is None: - raise RuntimeError("Managed Host process supervision returned no result") - return result + proc.stdin.write(frame) + proc.stdin.flush() + lines = iter(proc.stdout) + while True: + event = (guarded.next(deadline=launch_deadline) if guarded is not None + else json.loads(line) if (line := next(lines, None)) is not None else None) + if event is None: + break + kind = event.get("kind") + if kind == "spawned" and isinstance(event.get("pid"), int): + if record is not None: + assert record_path is not None + group = event.get("process_group") + record.update(phase="spawned", host_pid=event["pid"], + process_group=group if isinstance(group, int) else None) + _write_host_process_record(record_path, record) + launch_deadline = None + launch_scope.close() + elif kind in {"stdout", "stderr"} and isinstance(event.get("text"), str): + consume = on_stdout if kind == "stdout" else on_stderr + if consume is not None: + consume(event["text"]) + elif kind == "result" and event.get("outcome") in { + "exited", + "timeout", + "cancelled", + "output_limit", + "spawn_failed", + }: + result = event + launch_deadline = None + launch_scope.close() + elif kind == "not_launched" and guarded is not None: + guarded.not_launched = True + raise TimeoutError("managed Host bridge launch deadline expired") + else: + raise RuntimeError("Invalid managed Host process observation") + finally: + try: + if guarded is not None and (launch_deadline is not None or sys.exc_info()[0] is not None): + # A failed handoff still owns the Goal fence. The TS bridge must + # report no launch or finish its Host cleanup before it releases. + guarded.cancel() + if record is not None and guarded.not_launched: + assert record_path is not None + record.update(phase=HOST_PROCESS_NOT_LAUNCHED, process_group=None) + record.pop("bridge_pid", None) + _write_host_process_record(record_path, record) + else: + # EOF asks the TS supervisor to clean its Host, also on a + # callback failure. Leased cleanup retains its original budget. + proc.stdin.close() + try: + proc.wait(timeout=8 if delegated_lease is not None else 5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + finally: + if windows_job is not None: + windows_job.finish() + launch_scope.close() + if guarded is not None: + guarded.close_output() + else: + proc.stdout.close() + if record is not None and result is not None and proc.returncode == 0: + assert record_path is not None + # The supervisor returned only after cleaning its group; the group is still re-read. + record["phase"] = "finished" + _write_host_process_record(record_path, record) + if proc.returncode != 0 or result is None: + raise RuntimeError("Managed Host process supervision returned no result") + return result diff --git a/loopx/control_plane/turn_driver/journal_store.py b/loopx/control_plane/turn_driver/journal_store.py index 16638a7f21..e5be325ffa 100644 --- a/loopx/control_plane/turn_driver/journal_store.py +++ b/loopx/control_plane/turn_driver/journal_store.py @@ -195,3 +195,41 @@ def turn_journal_observed_capabilities( if not isinstance(observed, list) or not all(isinstance(item, str) for item in observed): return None return observed + + +def reconcile_settled_turn_journal( + runtime_root: Path, *, registry_path: Path, settlement_identity: Mapping[str, Any], +) -> dict[str, Any] | None: + """Transport native receipts into the original journal; never re-enter a Host.""" + from ..effect_program import SettlementIdentity + from ..quota.accounting_admission import quota_accounting_admission + + # Reuse the typed identity codec; a rendered id cannot override its fields. + identity = SettlementIdentity( + goal_id=settlement_identity["goal_id"], agent_id=settlement_identity["agent_id"], + todo_id=settlement_identity["todo_id"], turn_instance_id=settlement_identity["turn_instance_id"], + ) + if identity.effect_id != settlement_identity.get("effect_id"): + raise ValueError("Settlement identity fields differ from their effect id") + key = find_loopx_turn_key_by_settlement_identity(runtime_root, + goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, + turn_instance_id=identity.turn_instance_id) + if key is None: + return None + path = turn_journal_path(runtime_root, goal_id=identity.goal_id, turn_key=key) + journal = load_turn_journal(path) + goal_ref = journal["plan"].get("goal_ref") + with quota_accounting_admission(runtime_root=runtime_root, registry_path=registry_path, + goal_id=identity.goal_id, goal_ref=goal_ref, operation="turn-journal-receipt-reconciliation") as admission: + with exclusive_cross_runtime_file_lock(runtime_artifact_lock_target(runtime_root, identity.goal_id), + operation="turn_journal_runtime_artifact_guard"): + with exclusive_cross_runtime_file_lock(shadow_maintenance_lock_target(runtime_root, identity.goal_id), + operation="turn_journal_runtime_artifact_commit"): + request = {"path": str(path.resolve()), "runtime_root": str(runtime_root.resolve()), + "settlement_identity": identity.as_dict()} + if goal_ref is not None: + request.update(goal_ref=goal_ref, source_admission=admission) + result = effect_runtime_result("turn_journal.reconcile_settlement", request) + if not isinstance(result, dict) or result.get("ok") is not True or result.get("status") != "committed": + raise RuntimeError("Native Turn journal reconciliation did not commit") + return result diff --git a/loopx/control_plane/turn_driver/turn_journal_effects.ts b/loopx/control_plane/turn_driver/turn_journal_effects.ts index 3c55b3f05f..12faa55487 100644 --- a/loopx/control_plane/turn_driver/turn_journal_effects.ts +++ b/loopx/control_plane/turn_driver/turn_journal_effects.ts @@ -21,6 +21,8 @@ import { decideFirstPartyHostRuntime } from "../goals/first_party_host_runtime.t import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { requireNonEmptyString as requiredString, sourceNumberTokens } from "../runtime_decode.ts"; import { preparedAttemptViolation } from "./turn_journal_attempt_contract.ts"; +import { readQuotaSettlementForAdmittedOwnerFromSnapshot, readQuotaSettlementSnapshot } from "../quota/settlement_readback.ts"; +import { parseQuotaAccountingOwner, requireQuotaOwnerProjection, withQuotaAccountingOwner } from "../quota/source_admission.ts"; import { interpretTurnJournalEffect, journalPhaseViolation, @@ -602,3 +604,106 @@ export async function commitTurnJournal( } } } + +/** Reflect provider-verified settlement; never authorize another Host or debit. */ +export function planSettledTurnJournal(existing: JsonObject, readback: JsonObject): JsonObject[] { + const state = requireJournalState(existing); + const plan = asObject(existing.plan); + const parsed = settlementIdentityFromPlan(asObject(plan.transaction)); + if (parsed.failure !== null) conflict("Original journal settlement identity is unavailable"); + const expected = parsed.value; + const identity = asObject(asObject(readback.identity).result).value; + const decoded = settlementIdentityFromPlan({ settlement_plan: { identity } }); + if (readback.found !== true || decoded.failure !== null || + (["goal_id", "agent_id", "binding_kind", "binding_id", "turn_instance_id", "effect_id"] as const) + .some(key => decoded.value[key] !== expected[key])) { + conflict("Provider settlement does not match the original journal identity"); + } + const terminal = asObject(existing.completion_intent).continuation === "no_followup"; + const settled = asObject(asObject(readback[terminal ? "terminal_settlement" : "settlement"]).payload); + const required = ["validation", "durable_writeback", "quota_spend", ...(terminal ? ["terminal_closeout"] : [])]; + const receipts = settled.receipts; + if (settled.ok !== true || !Array.isArray(receipts) || receipts.length !== required.length || + receipts.some((value, index) => { + const receipt = asObject(value); + return receipt.schema_version !== "quota_settlement_receipt_v1" || receipt.step_kind !== required[index] || + receipt.status !== "committed" || receipt.effect_id !== expected.effect_id || + typeof receipt.source_ref !== "string" || !receipt.source_ref.trim(); + }) || !readback.writeback_run || !readback.spend_run) { + conflict("Original journal requires complete verified provider settlement receipts"); + } + if (state.status === "committed") return []; + const committedReceipts = receipts as JsonObject[]; + if (!["failed", "in_progress", "scheduler_action_required"].includes(state.status) || + state.completedPhases.length < 3 || state.completedPhases.length > 5 || + asObject(existing.task_validation).ok !== true || + !["validated_completion", "validated_progress"].includes(String(asObject(existing.host_result).result_kind)) || + existing.effect_attempts !== undefined || existing.source_effect_hold !== undefined || + asObject(plan.host).scheduler_owner !== "outer_controller") { + conflict("Original journal is not eligible for receipt-only settlement reconciliation"); + } + const steps: JsonObject[] = []; + let current = structuredClone(existing); + const audit = existing.settlement_reconciliation ?? { + previous_receipt: existing.receipt, previous_failure: existing.settlement_result, + previous_reason: existing.reason, receipts: committedReceipts, + }; + function advance(count: number, status: string): void { + const phases = transactionPhases.slice(0, count); + const resultKind = asObject(existing.host_result).result_kind; + const next: JsonObject = { ...current, status, completed_phases: phases, result_kind: resultKind, + settlement_reconciliation: audit, + receipt: { ...asObject(current.receipt), ok: true, status: count === 7 ? "committed" : "validated", + result_kind: resultKind, completed_phases: phases, failed_phase: null, + next_phase: transactionPhases[count] ?? null, settlement_effect_id: expected.effect_id, + commit_eligibility: { writeback: true, quota_spend: count >= 4, scheduler_ack: count === 7 }, errors: [] } }; + if (count >= 4) next.writeback = readback.writeback_run; + if (count >= 5) next.quota_spend = readback.spend_run; + if (terminal && count >= 5) next.terminal_closeout = asObject(readback.terminal_closeout).payload ?? committedReceipts.at(-1); + if (count === 7) { + next.scheduler = { completed: true, acknowledged: false, disposition: "outer_controller" }; + next.settlement_result = settled; + delete next.reason; + } + requireJournalTransition(current, next, requireJournalState(current), requireJournalState(next)); + current = next; steps.push(next); + } + if (state.status === "failed") advance(state.completedPhases.length, "in_progress"); + if (state.completedPhases.length < 4) advance(4, "in_progress"); + if (state.completedPhases.length < 5) advance(5, "in_progress"); + advance(7, "committed"); + return steps; +} + +export async function reconcileTurnJournalSettlement(params: JsonObject): Promise { + const path = requiredString(params.path, "path"); + const runtimeRoot = requiredString(params.runtime_root, "runtime_root"); + if (!isAbsolute(path) || !isAbsolute(runtimeRoot)) throw new EffectRuntimeRequestError("Journal reconciliation paths must be absolute"); + const parsed = settlementIdentityFromPlan({ settlement_plan: { identity: params.settlement_identity } }); + if (parsed.failure !== null || parsed.value.binding_kind !== "todo") throw new EffectRuntimeRequestError("Journal reconciliation requires a typed Todo identity"); + const identity = parsed.value; + const owner = parseQuotaAccountingOwner({ runtimeRoot, goalId: identity.goal_id, + goalRefValue: params.goal_ref, sourceAdmissionValue: params.source_admission }); + return withQuotaAccountingOwner(owner, async () => withFileMutationLock(path, async () => { + const journal = asObject(JSON.parse(await readFile(path, "utf8"))); + const original = settlementIdentityFromPlan(asObject(asObject(journal.plan).transaction)); + if (original.failure !== null || (["goal_id", "agent_id", "binding_kind", "binding_id", "turn_instance_id", "effect_id"] as const) + .some(key => original.value[key] !== identity[key]) || + path !== join(runtimeRoot, "goals", identity.goal_id, "turns", `${String(journal.turn_key).slice(7)}.json`)) { + conflict("Journal reconciliation address differs from its original identity"); + } + const binding = parseTurnJournalGoalBinding(journal); + if (binding.kind === "invalid") conflict("Journal reconciliation has invalid GoalRef binding"); + requireQuotaOwnerProjection(owner, binding.kind === "exact" ? binding.goal_ref : undefined, "Turn journal"); + const readback = readQuotaSettlementForAdmittedOwnerFromSnapshot({ + schema_version: "loopx_quota_settlement_readback_request_v0", runtime_root: runtimeRoot, + goal_id: identity.goal_id, agent_id: identity.agent_id, todo_id: identity.todo_id, + turn_instance_id: identity.turn_instance_id, replan_obligation_id: null, + infer_turn_instance_id: false, allow_unbound_binding: false, + }, owner, await readQuotaSettlementSnapshot(runtimeRoot, identity.goal_id)); + const steps = planSettledTurnJournal(journal, readback); + for (const snapshot of steps) await atomicWriteJson(path, snapshot); + return { ok: true, appended: steps.length > 0, replayed: steps.length === 0, + effect_id: identity.effect_id, turn_key: journal.turn_key, status: "committed" }; + })); +} diff --git a/loopx/control_plane/turn_driver/windows_host_job.py b/loopx/control_plane/turn_driver/windows_host_job.py new file mode 100644 index 0000000000..3a23e7cd84 --- /dev/null +++ b/loopx/control_plane/turn_driver/windows_host_job.py @@ -0,0 +1,126 @@ +"""Windows-only kernel containment IO for the existing typed Host supervisor. + +No task, stop, lease or deadline decision belongs here. The parent assigns the +control bridge before sending its first request; the bridge cannot launch a +Host before that input. Neither breakaway nor inherited Job handles is allowed. +""" +from __future__ import annotations + +import ctypes +import re +import time +import uuid +from ctypes import wintypes + + +class _BasicLimit(ctypes.Structure): + _fields_ = [("per_process_time", ctypes.c_longlong), ("per_job_time", ctypes.c_longlong), + ("flags", wintypes.DWORD), ("min_working_set", ctypes.c_size_t), + ("max_working_set", ctypes.c_size_t), ("active_limit", wintypes.DWORD), + ("affinity", ctypes.c_size_t), ("priority", wintypes.DWORD), ("scheduling", wintypes.DWORD)] + + +class _IoCounters(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in + ("read_ops", "write_ops", "other_ops", "read_bytes", "write_bytes", "other_bytes")] + + +class _ExtendedLimit(ctypes.Structure): + _fields_ = [("basic", _BasicLimit), ("io", _IoCounters), + ("process_memory", ctypes.c_size_t), ("job_memory", ctypes.c_size_t), + ("peak_process_memory", ctypes.c_size_t), ("peak_job_memory", ctypes.c_size_t)] + + +class _Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_longlong), ("kernel", ctypes.c_longlong), + ("period_user", ctypes.c_longlong), ("period_kernel", ctypes.c_longlong), + ("page_faults", wintypes.DWORD), ("total", wintypes.DWORD), + ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + + +_kernel = ctypes.WinDLL("kernel32", use_last_error=True) +for _name, _arguments, _result in ( + ("CreateJobObjectW", [ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE), + ("OpenJobObjectW", [wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR], wintypes.HANDLE), + ("SetInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL), + ("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + ("QueryInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL), + ("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + ("CloseHandle", [wintypes.HANDLE], wintypes.BOOL), +): + _function = getattr(_kernel, _name) + _function.argtypes, _function.restype = _arguments, _result + + +def _checked(ok): + if not ok: + raise ctypes.WinError(ctypes.get_last_error()) + return ok + + +def _active(handle) -> int: + info = _Accounting() + _checked(_kernel.QueryInformationJobObject(handle, 1, ctypes.byref(info), ctypes.sizeof(info), None)) + return info.active + + +def valid_job_name(value: object) -> bool: + return isinstance(value, str) and re.fullmatch(r"Local\\LoopXHost-[0-9a-f]{32}", value) is not None + + +def active_job_processes(name: str) -> int | None: + """Fresh kernel observation. None means the named Job no longer exists. + + An inaccessible/unreadable Job is never absence. Closing this temporary + non-inherited query handle also avoids keeping a dead owner's Job alive. + """ + if not valid_job_name(name): + raise ValueError("invalid managed Host Job identity") + handle = _kernel.OpenJobObjectW(0x0004, False, name) # JOB_OBJECT_QUERY + if not handle: + error = ctypes.get_last_error() + if error == 2: # ERROR_FILE_NOT_FOUND, never access denied + return None + raise ctypes.WinError(error) + try: + return _active(handle) + finally: + _checked(_kernel.CloseHandle(handle)) + + +class WindowsHostJob: + """One execution's non-inherited, non-breakaway Job; no sandbox claims.""" + def __init__(self): + self.name = "Local\\LoopXHost-" + uuid.uuid4().hex + self.handle = _checked(_kernel.CreateJobObjectW(None, self.name)) + try: + if ctypes.get_last_error() == 183: # Never reuse another execution's Job. + raise RuntimeError("managed Host Job identity already exists") + limits = _ExtendedLimit() + limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, no breakaway + _checked(_kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))) + except BaseException: + _kernel.CloseHandle(self.handle) + raise + + def __enter__(self): + return self + + def assign(self, process) -> None: + # Popen retains the original process HANDLE, so PID reuse cannot redirect this assignment. + _checked(_kernel.AssignProcessToJobObject(self.handle, int(process._handle))) + + def finish(self) -> None: + if _active(self.handle): + _checked(_kernel.TerminateJobObject(self.handle, 1)) + deadline = time.monotonic() + 5 + while _active(self.handle): + if time.monotonic() >= deadline: + raise TimeoutError("managed Host Job still has active processes") + time.sleep(.01) + + def __exit__(self, *_exc): + try: + self.finish() + finally: + _checked(_kernel.CloseHandle(self.handle)) diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 70fb054f31..02007b0a62 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -355,7 +355,20 @@ def complete_task( ) with host_vision_request(request, agent_vision, vision_unchanged_reason, allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: - return settle_host_todo_completion(authored, run_cli=self.run_cli) + output = settle_host_todo_completion(authored, run_cli=self.run_cli) + completed = json.loads(output) + if completed.get("ok") is True and completed.get("settlement_complete") is True and turn_id: + from pathlib import Path + from .history import load_registry + from .paths import resolve_runtime_root + from .control_plane.turn_driver.journal_store import reconcile_settled_turn_journal + + _, registry = self.context() + path = Path(registry) + root = resolve_runtime_root(load_registry(path), None, registry_path=path) + reconcile_settled_turn_journal(root, registry_path=path, + settlement_identity=completed["settlement_identity"]) + return output def review_task_vision( self, todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, diff --git a/loopx/project_alias.py b/loopx/project_alias.py index 996bbc5ef8..bff625a029 100644 --- a/loopx/project_alias.py +++ b/loopx/project_alias.py @@ -22,6 +22,7 @@ def _run_git(project: Path, *args: str) -> subprocess.CompletedProcess[str] | No return subprocess.run( ["git", "-C", str(project), *args], text=True, encoding="utf-8", errors="replace", + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, diff --git a/loopx/repository_identity.py b/loopx/repository_identity.py index 050a0c0a54..294740ae68 100644 --- a/loopx/repository_identity.py +++ b/loopx/repository_identity.py @@ -56,6 +56,8 @@ def _origin_remote(project: Path, git_bin: str) -> str: try: completed = subprocess.run( [git_bin, "-C", str(project), "config", "--get", "remote.origin.url"], + # This read must not borrow a Chat/RPC caller's open input pipe. + stdin=subprocess.DEVNULL, capture_output=True, check=False, text=True, encoding="utf-8", errors="replace", From da8955a44c92acdcc18b5a6accf1ec2d11dc6d0b Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 04:22:24 +0800 Subject: [PATCH 03/80] test: cover recovery identity transport and terminal settlement Signed-off-by: Tartar --- .../test_collaboration_atomic_publish.py | 65 +++++++ .../test_native_journal_settlement.py | 165 ++++++++++++++++ .../test_validation_process_cleanup.py | 17 ++ .../test_windows_host_process.py | 124 ++++++++++++ .../checkpoint_read_context.test.ts | 39 +++- .../default_parent_transport.test.mjs | 40 ++++ .../turn_journal_settlement.test.ts | 40 ++++ .../team_task_revalidation_fixture.py | 53 +++++- .../test_team_task_fixture_cleanup.py | 116 ++++++++++++ tests/test_chat_agent_control_rpc_order.py | 62 ++++++ tests/test_chat_coordinator_checkpoint.py | 177 ++++++++++++++++++ tests/test_delegation_task_revalidation.py | 22 ++- tests/test_inbox_receipt_recovery.py | 29 ++- tests/test_local_delegation.py | 8 +- tests/test_project_alias_stdin.py | 43 +++++ tests/test_repository_probe_stdin.py | 67 +++++++ tests/test_turn_recovery_preview.py | 86 +++++++++ tests/test_workspace_probe_stdin.py | 76 ++++++++ 18 files changed, 1217 insertions(+), 12 deletions(-) create mode 100644 tests/control_plane/test_collaboration_atomic_publish.py create mode 100644 tests/control_plane/test_native_journal_settlement.py create mode 100644 tests/control_plane/test_windows_host_process.py create mode 100644 tests/control_plane_ts/default_parent_transport.test.mjs create mode 100644 tests/control_plane_ts/turn_journal_settlement.test.ts create mode 100644 tests/presentation/test_team_task_fixture_cleanup.py create mode 100644 tests/test_chat_agent_control_rpc_order.py create mode 100644 tests/test_chat_coordinator_checkpoint.py create mode 100644 tests/test_project_alias_stdin.py create mode 100644 tests/test_repository_probe_stdin.py create mode 100644 tests/test_turn_recovery_preview.py create mode 100644 tests/test_workspace_probe_stdin.py diff --git a/tests/control_plane/test_collaboration_atomic_publish.py b/tests/control_plane/test_collaboration_atomic_publish.py new file mode 100644 index 0000000000..dd01bd581d --- /dev/null +++ b/tests/control_plane/test_collaboration_atomic_publish.py @@ -0,0 +1,65 @@ +"""Windows record publication keeps complete bytes during ordinary observation.""" +import json +import os +from threading import Timer +import time + +import pytest + +from loopx.control_plane.collaboration.inbox import _read, _write +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +@pytest.mark.skipif(os.name != "nt", reason="Windows delete-sharing boundary") +def test_observer_releases_file_before_atomic_publish_deadline(tmp_path): + # Real CRT read handles deny DELETE sharing. Preserve the full opaque + # storage address, including when the native path exceeds MAX_PATH. + path = windows_extended_path(tmp_path / ("a" * 64) / ("b" * 64) / "operation.json") + old, new = {"status": "prepared"}, {"status": "running", "complete": True} + _write(path, old) + reader = path.open("rb") + release = Timer(0.15, reader.close) + release.start() + try: + _write(path, new) + finally: + release.join() + reader.close() + assert _read(path) == new + assert list(path.parent.iterdir()) == [path] + + +@pytest.mark.skipif(os.name != "nt", reason="Windows delete-sharing boundary") +def test_persistent_observer_denial_preserves_old_complete_record(tmp_path): + path = tmp_path / "operation.json" + old = {"status": "prepared", "identity": "original"} + _write(path, old) + begin = time.monotonic() + with path.open("rb") as reader: + with pytest.raises(PermissionError) as caught: + _write(path, {"status": "running"}) + assert caught.value.winerror == 5 + assert json.loads(reader.read()) == old + assert time.monotonic() - begin < 2, "access denial must remain bounded" + assert _read(path) == old + assert list(tmp_path.iterdir()) == [path] + + +def test_other_publish_error_propagates_without_changing_record(tmp_path, monkeypatch): + from loopx.control_plane.collaboration import inbox + + path = tmp_path / "operation.json" + old = {"status": "prepared"} + _write(path, old) + calls = [] + + def reject(source, target): + calls.append((source, target)) + raise OSError(28, "No space left on device") + + monkeypatch.setattr(inbox.os, "replace", reject) + with pytest.raises(OSError, match="No space left"): + _write(path, {"status": "running"}) + assert len(calls) == 1 + assert _read(path) == old + assert list(tmp_path.iterdir()) == [path] diff --git a/tests/control_plane/test_native_journal_settlement.py b/tests/control_plane/test_native_journal_settlement.py new file mode 100644 index 0000000000..227043c4cf --- /dev/null +++ b/tests/control_plane/test_native_journal_settlement.py @@ -0,0 +1,165 @@ +import json +import sys +import pytest +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import _run_cli, GOAL_ID, AGENT_ID +from loopx.control_plane.turn_driver.journal_store import ( + load_turn_journal, + turn_journal_path, + reconcile_settled_turn_journal, +) +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) +from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_original_native_mcp_settlement_reconciles_managed_journal( + provider, tmp_path, monkeypatch +): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + project = state.parent + runtime = tmp_path / "runtime" + host = project / "host.py" + validator = project / "verify.py" + host.write_text( + "import json,pathlib,sys\nr=json.load(sys.stdin)\nif r.get('phase')=='checkpoint_direction':\n json.dump({'read_context_id':'deliberately-stale','vision_unchanged_reason':'Old direction rejected.'},sys.stdout)\nelse:\n assert not pathlib.Path('candidate.txt').exists()\n pathlib.Path('candidate.txt').write_text('valid');pathlib.Path('host-count.txt').write_text('1')\n json.dump({'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'],'result_kind':'validated_completion','completed_phases':['host_execute','typed_result'],'classification':'candidate_verified','recommended_action':'Read remaining work.','next_action':'Read remaining work.','delivery_batch_scale':'implementation','delivery_outcome':'outcome_progress','summary':'Candidate verified.','vision_unchanged_reason':'Pre-result judgment.'},sys.stdout)\n" + ) + validator.write_text( + "from pathlib import Path;assert Path('candidate.txt').read_text()=='valid'" + ) + _, managed = _run_cli( + registry, + "turn", + "run-once", + "--host", + "generic-cli", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--project", + str(project), + "--host-adapter-command-json", + json.dumps([sys.executable, str(host)]), + "--validation-command-json", + json.dumps([sys.executable, str(validator)]), + "--scan-root", + str(project), + "--no-global-sync", + "--execute", + ) + assert managed["status"] == "failed" and managed["validation"]["ok"], managed + path = turn_journal_path( + runtime, goal_id=GOAL_ID, turn_key=managed["resume_turn_key"] + ) + before = load_turn_journal(path) + assert before["status"] == "failed" and before["completed_phases"] == [ + "host_execute", + "typed_result", + "validation", + ] + # An already committed result does not supply missing native settlement receipts. + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + pending_journal, pending_ledger = path.read_bytes(), index.read_bytes() + with pytest.raises((ValueError, RuntimeError)): + reconcile_settled_turn_journal( + runtime, registry_path=registry, + settlement_identity=before["plan"]["transaction"]["settlement_plan"]["identity"], + ) + assert pending_journal == path.read_bytes() and pending_ledger == index.read_bytes() + old = next( + t + for t in read_canonical_todos_if_promoted( + runtime_root=runtime, goal_id=GOAL_ID + )["todos"] + if t["todo_id"] == todo + ) + checkpoint = CoordinatorCheckpoints(registry, runtime, GOAL_ID, AGENT_ID) + context = checkpoint.read(todo) + result = checkpoint.resume( + todo, + { + "read_context_id": context["read_context_id"], + "vision_unchanged_reason": "Current original result and obligation preserve the route.", + }, + ) + assert result["ok"] and result["settlement_complete"], result + after = load_turn_journal(path) + assert after["status"] == after["receipt"]["status"] == "committed", after[ + "receipt" + ] + assert ( + after["turn_key"] == before["turn_key"] + and after["receipt"]["settlement_effect_id"] + == result["settlement_identity"]["effect_id"] + ) + assert ( + after["receipt"]["next_phase"] is None + and after["receipt"]["failed_phase"] is None + ) + for k in ["plan", "host_result", "task_validation", "completion_intent"]: + assert after[k] == before[k], k + new = next( + t + for t in read_canonical_todos_if_promoted( + runtime_root=runtime, goal_id=GOAL_ID + )["todos"] + if t["todo_id"] == todo + ) + for k in [ + "completion_turn_key", + "completion_receipt_id", + "completed_at", + "evidence", + "status", + "claimed_by", + ]: + assert old.get(k) == new.get(k), k + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + ledger = index.read_bytes() + original = path.read_bytes() + repeated = reconcile_settled_turn_journal( + runtime, + registry_path=registry, + settlement_identity=result["settlement_identity"], + ) + assert repeated["replayed"] and not repeated["appended"] + assert ledger == index.read_bytes() and original == path.read_bytes() + foreign = {**result["settlement_identity"], "agent_id": "foreign"} + with pytest.raises((ValueError, RuntimeError)): + reconcile_settled_turn_journal( + runtime, registry_path=registry, settlement_identity=foreign + ) + assert ledger == index.read_bytes() and original == path.read_bytes() + assert (project / "host-count.txt").read_text() == "1" and ( + project / "candidate.txt" + ).read_text() == "valid" + rows = [json.loads(line) for line in index.read_text().splitlines()] + assert ( + sum( + r.get("classification") == "quota_slot_spent" + and r.get("settlement_identity") == result["settlement_identity"] + for r in rows + ) + == 1 + ) + (tmp_path / "journal-reconciliation-proof.json").write_text( + json.dumps( + { + "provider": provider, + "before": before, + "after": after, + "native_result": result, + "original_todo_before": old, + "original_todo_after": new, + "host_calls": 1, + "spend_calls": 1, + "repeat_no_effects": True, + "foreign_no_effects": True, + "source_qualification_only": True, + }, + indent=2, + ) + ) diff --git a/tests/control_plane/test_validation_process_cleanup.py b/tests/control_plane/test_validation_process_cleanup.py index 059549904d..4d29330eef 100644 --- a/tests/control_plane/test_validation_process_cleanup.py +++ b/tests/control_plane/test_validation_process_cleanup.py @@ -250,6 +250,23 @@ def read(): process.communicate(timeout=5) +def test_host_adapter_can_isolate_protocol_stdin() -> None: + inner = "import sys;assert sys.stdin.read()==''" + outer = ( + "import json,sys,subprocess;from pathlib import Path;" + "from loopx.control_plane.runtime.validation_command import run_caller_validation;" + f"print(json.dumps(run_caller_validation(Path.cwd(),validation_argv=[sys.executable,'-c',{inner!r}]," + "validation_label='Isolated protocol stdin',timeout_seconds=3,stdin=subprocess.DEVNULL)))" + ) + proc = subprocess.run( + [sys.executable, "-c", outer], + input="synthetic protocol input", text=True, capture_output=True, + cwd=Path(__file__).resolve().parents[2], timeout=5, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["passed"] is True + + @pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_real_cli_timeout_keeps_canonical_todo_open_and_declaration_unchanged( diff --git a/tests/control_plane/test_windows_host_process.py b/tests/control_plane/test_windows_host_process.py new file mode 100644 index 0000000000..0ea0d92225 --- /dev/null +++ b/tests/control_plane/test_windows_host_process.py @@ -0,0 +1,124 @@ +"""Real Windows descendant cleanup, including loss of the Python owner.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from loopx.control_plane.turn_driver import host_process_transport as transport + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Job Object transport") + + +def host_source(marker: Path, pid: Path, *, exit_leader: bool = False) -> str: + child = ( + "import os,time;from pathlib import Path;" + f"Path({str(pid)!r}).write_text(str(os.getpid()));" + f"p=Path({str(marker)!r});" + "\nwhile True:\n with p.open('a') as f:f.write('x');f.flush()\n time.sleep(.02)" + ) + return ( + "import subprocess,sys,time;from pathlib import Path;" + f"subprocess.Popen([sys.executable,'-c',{child!r}],stdin=subprocess.DEVNULL," + "stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,close_fds=True);" + f"p=Path({str(marker)!r});" + "\nwhile not p.exists():time.sleep(.01)\n" + "print('started',flush=True)\n" + ("" if exit_leader else "time.sleep(60)") + ) + + +def clean_fixture_child(pid: Path) -> None: + # Only a child this test actually launched and recorded; never enumerate processes. + if pid.exists(): + try: + os.kill(int(pid.read_text()), 9) + except (ProcessLookupError, PermissionError, OSError): + pass + + +@pytest.mark.parametrize("boundary", ["leader_exit", "timeout", "consumer_failure"]) +def test_windows_real_host_descendants_stop_before_return(tmp_path: Path, boundary: str) -> None: + marker, pid, record = (tmp_path / name for name in ("writes", "child.pid", "host.json")) + environment = {**os.environ, transport.HOST_PROCESS_RECORD_ENV: str(record)} + def consume(_text: str) -> None: + if boundary == "consumer_failure": + raise ValueError("consumer rejected") + try: + def run(): + return transport.run_host_process( + [sys.executable, "-c", host_source(marker, pid, exit_leader=boundary == "leader_exit")], + project=tmp_path, input_text="", timeout_seconds=2, + environment=environment, on_stdout=consume, + ) + if boundary == "consumer_failure": + with pytest.raises(ValueError, match="consumer rejected"): + run() + else: + result = run() + assert result["outcome"] == ("exited" if boundary == "leader_exit" else "timeout") + assert marker.exists(), "The real descendant must have executed" + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_DRAINED + before = marker.read_bytes() + time.sleep(.2) + assert marker.read_bytes() == before, "A descendant continued writing after cleanup" + finally: + clean_fixture_child(pid) + + +def test_windows_cold_readback_after_owner_kill_sees_whole_job_exit(tmp_path: Path) -> None: + marker, pid, record = (tmp_path / name for name in ("writes", "child.pid", "host.json")) + argv = [sys.executable, "-c", host_source(marker, pid)] + source = ( + "from pathlib import Path;from loopx.control_plane.turn_driver.host_process_transport " + f"import run_host_process;run_host_process({argv!r},project=Path({str(tmp_path)!r})," + "input_text='',timeout_seconds=60)" + ) + owner = subprocess.Popen([sys.executable, "-c", source], + env={**os.environ, transport.HOST_PROCESS_RECORD_ENV: str(record)}, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + deadline = time.monotonic() + 10 + while not marker.exists() and time.monotonic() < deadline: + time.sleep(.02) + assert marker.exists(), "Host never launched" + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_DRAINING + owner.kill() + owner.wait(timeout=5) + deadline = time.monotonic() + 5 + while transport.execution_host_drain(record) != transport.HOST_PROCESS_DRAINED and time.monotonic() < deadline: + time.sleep(.02) + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_DRAINED + before = marker.read_bytes() + time.sleep(.2) + assert marker.read_bytes() == before + finally: + if owner.poll() is None: + owner.kill() + owner.wait(timeout=5) + clean_fixture_child(pid) + + +def test_windows_legacy_finished_pid_does_not_prove_job_drain(tmp_path: Path) -> None: + record = tmp_path / "old.json" + from loopx.file_lock import lock_holder_host_label + record.write_text(json.dumps({"schema_version": transport.HOST_PROCESS_RECORD_SCHEMA_VERSION, + "host": lock_holder_host_label(), "supervises": "host", "supervision": "direct", + "phase": "finished", "bridge_pid": 9999999, "process_group": None})) + assert transport.execution_host_drain(record) == transport.HOST_PROCESS_UNSUPPORTED_PLATFORM + + +def test_windows_job_assignment_failure_launches_no_host(tmp_path: Path, monkeypatch) -> None: + from loopx.control_plane.turn_driver.windows_host_job import WindowsHostJob + marker = tmp_path / "must-not-execute" + def deny(_self, _process): + raise PermissionError("assignment rejected") + monkeypatch.setattr(WindowsHostJob, "assign", deny) + with pytest.raises(PermissionError, match="assignment rejected"): + transport.run_host_process([sys.executable, "-c", f"from pathlib import Path;Path({str(marker)!r}).write_text('bad')"], + project=tmp_path, input_text="", timeout_seconds=5) + assert not marker.exists() diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index 4f993288ac..7571600741 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -4,11 +4,46 @@ import {resolve} from "node:path"; import assert from "node:assert/strict"; import test from "node:test"; import {evaluateCheckpointReadContext as evaluate, evaluateResultWorkBinding as work, executeResultWorkBinding} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; -import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; -import {settlementIdentity} from "../../loopx/control_plane/effect_program.ts"; +import {settlementIdentity, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {selectEffectiveHeartbeatReceipt, heartbeatReceiptFactFromEvent} from "../../loopx/control_plane/quota/heartbeat_receipt_identity.ts"; const identity = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; +test("completion replay preserves the exact managed key without aliasing another scope", () => { + const scoped = settlementIdentity({goal_id: "goal", agent_id: "agent", + todo_id: "todo_original", turn_instance_id: "original-turn"}); + const todo = {todo_id: "todo_original", claimed_by: "agent", status: "done", + completion_turn_key: "original-turn"}; + const key = (snapshot: JsonObject = todo, bound: JsonObject = scoped) => work({ + phase: "completion_identity", goal_id: "goal", agent_id: "agent", todo_id: "todo_original", + todo: snapshot, identity: bound, + }).completion_turn_key; + assert.equal(key(), "original-turn"); + assert.equal(key({...todo, completion_turn_key: scoped.effect_id}), scoped.effect_id); + for (const changed of [{...todo, status: "open"}, {...todo, todo_id: "todo_other"}, + {...todo, claimed_by: "another-agent"}, {...todo, completion_turn_key: "another-turn"}]) { + assert.equal(key(changed), scoped.effect_id); + } + assert.throws(() => key(todo, {...scoped, effect_id: "forged"}), /original scoped Turn/); + assert.throws(() => key(todo, settlementIdentity({goal_id: "other", agent_id: "agent", + todo_id: "todo_original", turn_instance_id: "original-turn"})), /original scoped Turn/); +}); +test("managed result replay cannot submit a different candidate, successor or closeout", () => { + const scoped = settlementIdentity({goal_id: "goal", agent_id: "agent", + todo_id: "todo_original", turn_instance_id: "original-turn"}); + const todo = {todo_id: "todo_original", claimed_by: "agent", role: "agent", status: "done", + completion_turn_key: "original-turn", completion_continuation: "active_goal", no_followup: false, + successor_todo_ids: [], evidence: "retained", completion_receipt_id: "original-receipt"}; + const replay = (candidate: JsonObject) => work({phase: "completion_identity", + goal_id: "goal", agent_id: "agent", todo_id: "todo_original", todo, identity: scoped, candidate}).replay; + const candidate = {evidence: "retained", role: "agent"}; + assert.deepEqual(replay(candidate), {ok: true, completed: true, changed: false, idempotent_replay: true, + dry_run: false, goal_id: "goal", todo_id: "todo_original", status: "done", + completion_continuation: "active_goal", completion_receipt_id: "original-receipt", updated_at: null}); + for (const changed of [{evidence: "replacement"}, {next_agent_todo: "new"}, + {next_excluded_agents: ["other"]}, {no_followup: true}, {role: "user"}, {clear_claim: true}]) { + assert.equal(replay({...candidate, ...changed}), null); + } +}); const facts = { todos: [ {todo_id: "task", status: "open", text: "Build a page", depends_on_todo_ids: ["upstream"]}, diff --git a/tests/control_plane_ts/default_parent_transport.test.mjs b/tests/control_plane_ts/default_parent_transport.test.mjs new file mode 100644 index 0000000000..4e48f0afc0 --- /dev/null +++ b/tests/control_plane_ts/default_parent_transport.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs'; +import {createHash,randomUUID} from 'node:crypto'; +import {tmpdir} from 'node:os'; +import {dirname,resolve} from 'node:path';import {pathToFileURL,fileURLToPath} from 'node:url'; +const P=dirname(fileURLToPath(import.meta.url)),D=process.env.LOOPX_PARENT_SOURCE_ROOT?resolve(process.env.LOOPX_PARENT_SOURCE_ROOT):resolve(P,'../..'); +const imp=p=>import(pathToFileURL(resolve(D,p))); +const {FileAuthorityStore}=await imp('loopx/control_plane/coordination/file_authority_store.ts'); +const {SqliteAuthorityStore}=await imp('loopx/control_plane/coordination/sqlite_authority_store.ts'); +const {canonicalAuthoritySha256}=await imp('loopx/control_plane/coordination/authority_store_codec.ts'); +const {executeCoordinationTodoTerminalLifecycle:execute,completionTurnOperationId}=await imp('loopx/control_plane/coordination/todo_terminal_lifecycle.ts'); +const {TODO_DOMAIN_ITEM_SCHEMA,TODO_DOMAIN_READ_RECORD_SCHEMA,TODO_DOMAIN_RECORD_CONTRACT}=await imp('loopx/control_plane/coordination/coordination_state_contract.ts'); +const cases=['exact_parent','mcp_default_parent','changed_evidence','changed_note','wrong_role','foreign_actor','foreign_policy_claim','foreign_delivery_context','foreign_lease','foreign_turn','changed_validation','clear_claim']; +const expanded=[...cases,...cases.map(id=>'deferred_'+id)]; +const rows=[];const root=process.env.LOOPX_PARENT_TEST_ROOT?resolve(process.env.LOOPX_PARENT_TEST_ROOT):resolve(tmpdir(),'loopx-parent-'+randomUUID());assert.ok(!existsSync(root));mkdirSync(root); +for(const provider of ['file','sqlite'])for(const caseId of expanded){ + const deferred=caseId.startsWith("deferred_");const id=deferred?caseId.slice(9):caseId; + const dir=resolve(root,provider,caseId);mkdirSync(dir,{recursive:true});const goal='synthetic-parent-'+caseId,tid='todo_parent',owner='agent-a',agents=[owner,'agent-b']; + const store=provider==='file'?new FileAuthorityStore(resolve(dir,'authority'),goal):new SqliteAuthorityStore(resolve(dir,'authority'),goal); + const todo={schema_version:TODO_DOMAIN_ITEM_SCHEMA,todo_id:tid,role:'agent',status:'open',done:false,archive_state:'active',task_class:'advancement_task',text:'Validate a synthetic retained result',claimed_by:owner,no_followup:true};const todos=[todo]; + const seeded=await store.commitAuthority({operation_id:'seed',expected_provider_revision:null,events:[],receipts:[],next_projection:{goal_id:goal,handoff_mode:'legacy',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]}}});writeFileSync(resolve(dir,'seed-result.json'),JSON.stringify(seeded,null,2));assert.equal(seeded.status,'applied',JSON.stringify(seeded)); + const policy={schema_version:'loopx_todo_completion_policy_request_v0',goal_id:goal,agent_model:'peer_v1',claimed_by:null,registered_agents:agents,next_claimed_by:null,next_agent_todo:null,next_continuation_policy:null,next_excluded_agents:[],self_merged:false,evidence:'Synthetic validation',linked_successors:[]}; + const parent={goal_id:goal,todo_id:tid,expected_role:'agent',command:'complete',actor_agent_id:owner,registered_agents:agents,lifecycle_grants:[],authority_reason:null,decision_outcome:null,operation_identity:{kind:'completion_turn'},lease_idempotency_key:null,lease_expected_version:null,allow_user_gate_auto_acquire:true,requested_no_followup:false,defer_no_followup:true,requested_completion_turn_key:'original-synthetic-turn',requested_completion_identity_source:'turn_settlement',linked_successor_todo_ids:[],successor_intents:[],note:'Original managed next action',evidence:'Synthetic validation',reason:null,clear_claim:false,validation_declaration:null,validation_receipt:null,completion_policy_request:policy,dry_run:false,now:new Date('2026-10-13T00:00:00Z'),delivery_read_context_id:'original-synthetic-result-context'}; + const basis=async()=>({ok:true});const result=await execute(store,parent,async()=>true,undefined,basis);assert.equal(result.status,'applied',JSON.stringify(result)); + const parentId=completionTurnOperationId(parent,false),receipt=await store.readReceipt(parentId);const before=await store.loadAuthority();assert.equal(before.status,'loaded');assert.equal(before.head.todos[0].status,'done');assert.equal(before.head.todos[0].completion_requested_continuation,'no_followup'); + const exact={...parent,requested_no_followup:true,defer_no_followup:false};const mcp={...exact,expected_role:null,note:null,delivery_read_context_id:undefined,completion_policy_request:{...policy,claimed_by:owner}}; + let request=id==='exact_parent'?exact:mcp; + const patches={changed_evidence:{evidence:'Changed result'},changed_note:{note:'Changed intended note'},wrong_role:{expected_role:'user'},foreign_actor:{actor_agent_id:'agent-b'},foreign_policy_claim:{completion_policy_request:{...policy,claimed_by:'agent-b'}},foreign_delivery_context:{delivery_read_context_id:'foreign-result-context'},foreign_lease:{lease_idempotency_key:'foreign-lease',lease_expected_version:999},foreign_turn:{requested_completion_turn_key:'another-turn'},changed_validation:{validation_declaration_sha256:'f'.repeat(64)},clear_claim:{clear_claim:true}}; + if(patches[id])request={...mcp,...patches[id]};if(deferred)request={...request,defer_no_followup:true};const afterResult=await execute(store,request,async()=>true,undefined,basis);const after=await store.loadAuthority();const parentPreserved=canonicalAuthoritySha256(await store.readReceipt(parentId))===canonicalAuthoritySha256(receipt); + let passed;let replayResult=null; + if(deferred&&(id==='exact_parent'||id==='mcp_default_parent')){ + passed=afterResult.status==='replayed'&&canonicalAuthoritySha256(after)===canonicalAuthoritySha256(before)&&parentPreserved&&after.head.todos[0].no_followup===false; + if(passed){const final=await execute(store,{...request,defer_no_followup:false},async()=>true,undefined,basis);const closed=await store.loadAuthority();replayResult=await execute(store,{...request,defer_no_followup:false},async()=>{throw new Error('final replay must precede authority');},undefined,basis);passed=final.status==='applied'&&closed.head.todos[0].no_followup===true&&replayResult.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(closed);} + }else if(id==='exact_parent'||id==='mcp_default_parent'){ + passed=afterResult.status==='applied'&&after.head.todos[0].no_followup===true&&after.head.todos[0].completion_continuation==='no_followup'&&parentPreserved; + if(passed){const replay=await execute(store,request,async()=>{throw new Error('replay must precede authority');},undefined,basis);replayResult=replay;passed=replay.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(after);} + }else passed=afterResult.status==='failed'&&canonicalAuthoritySha256(after)===canonicalAuthoritySha256(before)&&parentPreserved; + const witness={provider,id:caseId,passed,replayResult,parent_accepted:true,limited_basis_callback:'synthetic owner boundary; does not qualify real checkpoint or installed MCP/browser',native_result:afterResult,head_before:before,head_after:after,parent_receipt_before:receipt,parent_receipt_after:await store.readReceipt(parentId)};const path=resolve(dir,'witness.json');writeFileSync(path,JSON.stringify(witness,null,2));rows.push({provider,id:caseId,passed,status:afterResult.status,reason_code:afterResult.reason_code??null,parent_receipt_preserved:parentPreserved,witness:path}); +} +writeFileSync(process.env.LOOPX_PARENT_RESULT_FILE?resolve(process.env.LOOPX_PARENT_RESULT_FILE):resolve(root,'native-evaluation.json'),JSON.stringify({scope:'48 native File/SQLite owner cases; acceptance fixture only, not full G5 or live model qualification',executed_source_root:D,owner_sha256:createHash('sha256').update(readFileSync(resolve(D,'loopx/control_plane/coordination/todo_terminal_lifecycle.ts'))).digest('hex'),rows,failed:rows.filter(r=>!r.passed).map(r=>r.provider+':'+r.id)},null,2));console.log(JSON.stringify(rows));if(rows.some(r=>!r.passed))process.exitCode=1; diff --git a/tests/control_plane_ts/turn_journal_settlement.test.ts b/tests/control_plane_ts/turn_journal_settlement.test.ts new file mode 100644 index 0000000000..dd7ebc1fbd --- /dev/null +++ b/tests/control_plane_ts/turn_journal_settlement.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {planSettledTurnJournal} from "../../loopx/control_plane/turn_driver/turn_journal_effects.ts"; +const phases=['host_execute','typed_result','validation','durable_writeback','quota_spend','scheduler_apply','scheduler_ack']; +const identity={schema_version:'quota_settlement_identity_v0',goal_id:'goal',agent_id:'actor',todo_id:'todo_task',turn_instance_id:'turn-7',effect_id:'goal:actor:todo_task:turn-7'}; +const key='sha256:'+'a'.repeat(64); +const receipt=(step:string)=>({schema_version:'quota_settlement_receipt_v1',step_kind:step,status:'committed',effect_id:identity.effect_id,source_ref:'native:'+step}); +function fixture(){ + const journal={schema_version:'loopx_turn_journal_v0',goal_id:'goal',turn_key:key,status:'failed',completed_phases:phases.slice(0,3), + plan:{host:{scheduler_owner:'outer_controller'},turn_envelope:{goal_id:'goal',agent_id:'actor',action:{selected_todo:{todo_id:'todo_task'}}},transaction:{turn_key:key,turn_instance_id:'turn-7',settlement_plan:{schema_version:'quota_settlement_plan_v1',identity}}}, + host_result:{schema_version:'loopx_turn_result_v0',turn_key:key,result_kind:'validated_completion',summary:'Original candidate'},task_validation:{ok:true},completion_intent:{todo_id:'todo_task',continuation:'active_goal'}, + receipt:{ok:true,turn_key:key,status:'failed',failed_phase:'durable_writeback',next_phase:'durable_writeback',result_kind:'writeback_failed'},settlement_result:{ok:false},reason:'original failure'}; + const bundle=(value:unknown,steps:string[])=>({result:{value,receipts:steps.map(receipt),failure:null},payload:{ok:true,receipts:steps.map(receipt),failure:null}}); + const readback={found:true,identity:bundle(identity,['validation']),settlement:bundle({},['validation','durable_writeback','quota_spend']), + terminal_settlement:bundle({},['validation','durable_writeback','quota_spend','terminal_closeout']), + writeback_run:{settlement_identity:identity,marker:'original-writeback'},spend_run:{settlement_identity:identity,slots:1,marker:'original-spend'}}; + return {journal,readback}; +} +test('ordered receipt reconciliation retains original result and does no work replay',()=>{ + const {journal,readback}=fixture();const before=structuredClone(journal);const steps=planSettledTurnJournal(journal,readback); + assert.deepEqual(journal,before);assert.deepEqual(steps.map(s=>[s.status,s.completed_phases.length]),[['in_progress',3],['in_progress',4],['in_progress',5],['committed',7]]); + const final=steps.at(-1)!;assert.equal(final.turn_key,key);assert.equal(final.receipt.status,'committed');assert.equal(final.receipt.settlement_effect_id,identity.effect_id); + assert.equal(final.receipt.failed_phase,null);assert.equal(final.receipt.next_phase,null); + for(const k of ['plan','host_result','task_validation','completion_intent'])assert.deepEqual(final[k],journal[k]); + assert.deepEqual(final.settlement_reconciliation.previous_receipt,journal.receipt);assert.deepEqual(final.settlement_reconciliation.previous_failure,journal.settlement_result); + assert.deepEqual(planSettledTurnJournal(final,readback),[]); +}); +for(const [name,mutate] of [ + ['non-outer scheduler',(j:any,r:any)=>j.plan.host.scheduler_owner='local_scheduler'],['missing readback',(j:any,r:any)=>r.found=false],['foreign effect',(j:any,r:any)=>r.identity.result.value={...identity,effect_id:'other'}], + ['foreign structured agent',(j:any,r:any)=>r.identity.result.value={...identity,agent_id:'other'}],['missing spend',(j:any,r:any)=>r.settlement.payload.receipts.pop()], + ['wrong spend receipt',(j:any,r:any)=>r.settlement.payload.receipts[2].effect_id='other'],['failed settlement',(j:any,r:any)=>r.settlement.payload.ok=false], + ['missing provider source',(j:any,r:any)=>r.settlement.payload.receipts[1].source_ref=null],['no validation',(j:any,r:any)=>j.task_validation.ok=false], + ['unvalidated prefix',(j:any,r:any)=>j.completed_phases=phases.slice(0,2)],['wrong result',(j:any,r:any)=>j.host_result.result_kind='host_failure'], + ['prepared unknown effects',(j:any,r:any)=>j.effect_attempts={durable_writeback:{state:'unknown'}}],['active source hold',(j:any,r:any)=>j.source_effect_hold={}], + ['foreign original key',(j:any,r:any)=>j.turn_key='sha256:'+'b'.repeat(64)],['malformed original snapshot',(j:any,r:any)=>j.schema_version='unknown'], + ['stopped journal',(j:any,r:any)=>j.status='stopped'],['missing writeback run',(j:any,r:any)=>r.writeback_run=null], + ['missing spend run',(j:any,r:any)=>r.spend_run=null],['missing terminal receipt',(j:any,r:any)=>{j.completion_intent.continuation='no_followup';r.terminal_settlement.payload.receipts.pop();}], + ] as const){test('refuses '+name+' without input mutation',()=>{const {journal,readback}=fixture();mutate(journal,readback);const before=structuredClone(journal);assert.throws(()=>planSettledTurnJournal(journal,readback));assert.deepEqual(journal,before);});} +test('partial durable phase resumes only the remaining journal checkpoints',()=>{const {journal,readback}=fixture();const steps=planSettledTurnJournal(journal,readback);const afterWriteback=steps[1];assert.deepEqual(planSettledTurnJournal(afterWriteback,readback).map(s=>s.completed_phases.length),[5,7]);}); +test('terminal reconciliation requires native terminal receipt',()=>{const {journal,readback}=fixture();journal.completion_intent.continuation='no_followup';const final=planSettledTurnJournal(journal,readback).at(-1)!;assert.equal(final.status,'committed');assert.ok(final.terminal_closeout);}); diff --git a/tests/presentation/team_task_revalidation_fixture.py b/tests/presentation/team_task_revalidation_fixture.py index 84de266a29..d94f2dcece 100644 --- a/tests/presentation/team_task_revalidation_fixture.py +++ b/tests/presentation/team_task_revalidation_fixture.py @@ -1,6 +1,7 @@ """Disposable production delegation + Chat HTTP for packaged recovery validation.""" from pathlib import Path import hashlib +from contextlib import contextmanager import json import subprocess import sys @@ -15,16 +16,58 @@ from loopx.chat_runtime import ChatRuntimeController from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.chat_store import ChatSessionStore +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +@contextmanager +def fixture_workspace(): + temporary = tempfile.TemporaryDirectory(prefix="lx-rv-") + directory = Path(temporary.name) + assert directory.resolve().parent == Path(tempfile.gettempdir()).resolve() + # Keep every registered path/opaque identity unchanged. Only recursive + # teardown needs extended syntax to see Windows lock sidecars beyond MAX_PATH. + temporary.name = str(windows_extended_path(directory)) + with temporary: + yield directory + + +def drain_fixture_workers(workers, *, timeout=30): + """Wait on this fixture's actual process handles, never infer exit from status.""" + for worker in workers: + code = worker.wait(timeout=timeout) + if code != 0: + raise RuntimeError(f"fixture worker exited with status {code}") + + +def fixture_http_server(address, handler): + server = ChatHTTPServer(address, handler) + # A closed browser can leave an admitted HTTP request running. Join those + # handlers before their disposable backend is removed; production stays unchanged. + server.daemon_threads = False + return server def main(): cleanups = [] patch = pytest.MonkeyPatch() - # Leave room for lock-incident filenames under ordinary Windows temp roots. - with tempfile.TemporaryDirectory(prefix="lx-rv-") as directory: + with fixture_workspace() as directory: try: root, runner = service.__wrapped__(Path(directory), SimpleNamespace(param="file", addfinalizer=cleanups.append), patch) + workers = [] + + class FixtureProcess(subprocess.Popen): + def __init__(self, argv, *args, **kwargs): + super().__init__(argv, *args, **kwargs) + if (isinstance(argv, list) and "--delegation-action" in argv + and argv[argv.index("--delegation-action") + 1] == "worker" + and "--runtime-root" in argv + and argv[argv.index("--runtime-root") + 1] == str(runner.root)): + workers.append(self) + + # Keep the production spawn argv/IO intact; retain only our workers' + # handles so accepted/rejected observations cannot race directory removal. + patch.setattr(subprocess, "Popen", FixtureProcess) config = root / "project/.loopx/config/delegations.json" config.parent.mkdir(parents=True, exist_ok=True) catalog = json.loads(runner.config.read_text()) @@ -52,9 +95,7 @@ def observe_before_release(path, row, status, **facts): upstream_thread_id="fixture", session_id="recovery", adapter_kind="codex_app_server", upstream_mode="chat") store.update_session("recovery", native_goal={"status": "active"}, loopx_mode={"settings": { "agent_id": "lead", "execution_config": ".loopx/config/delegations.json"}}) - server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) - # Drain this fixture's request handlers before retiring their runtime. - server.daemon_threads = False + server = fixture_http_server(("127.0.0.1", 0), ChatRequestHandler) server.verbose = False server.registry_path = runner.registry server.runtime_root = runner.root @@ -120,6 +161,8 @@ def lose_response(binding, *args, **kwargs): server.server_close() thread.join(timeout=5) controller.close() + assert workers, "fixture did not observe its own launched worker" + drain_fixture_workers(workers) finally: for cleanup in reversed(cleanups): cleanup() diff --git a/tests/presentation/test_team_task_fixture_cleanup.py b/tests/presentation/test_team_task_fixture_cleanup.py new file mode 100644 index 0000000000..6d7332e127 --- /dev/null +++ b/tests/presentation/test_team_task_fixture_cleanup.py @@ -0,0 +1,116 @@ +"""A disposable browser fixture must drain its own workers before deletion.""" +import subprocess +import sys +import time +import threading +import http.client +from http.server import BaseHTTPRequestHandler +from pathlib import Path +import tempfile + +import pytest + +import team_task_revalidation_fixture as fixture +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +def test_fixture_removes_long_opaque_paths(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, 'tempdir', str(tmp_path)) + with fixture.fixture_workspace() as directory: + root = Path(directory) + leaf = root / ('a' * 64) / ('b' * 64) / (('c' * 64) + '.json.lock.incidents.jsonl') + extended = windows_extended_path(leaf) + extended.parent.mkdir(parents=True) + extended.write_text('owned fixture data') + assert extended.read_text() == 'owned fixture data' + assert len(str(leaf)) > 260 + assert not root.exists(), 'long-path cleanup silently left fixture data behind' + + +def test_fixture_closes_inflight_http_before_directory_cleanup(): + entered, release, closed = threading.Event(), threading.Event(), threading.Event() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + entered.set() + assert release.wait(timeout=5) + self.send_response(200) + self.end_headers() + def log_message(self, *_): + pass + + server = fixture.fixture_http_server(('127.0.0.1', 0), Handler) + serving = threading.Thread(target=server.serve_forever) + serving.start() + responses = [] + def request(): + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + try: + connection.request('GET', '/') + responses.append(connection.getresponse().status) + finally: + connection.close() + client = threading.Thread(target=request) + client.start() + closer = None + try: + assert entered.wait(timeout=5) + server.shutdown() + def close(): + server.server_close() + closed.set() + closer = threading.Thread(target=close) + closer.start() + assert not closed.wait(timeout=.05), 'cleanup raced an active HTTP handler' + release.set() + assert closed.wait(timeout=5) + client.join(timeout=5) + assert responses == [200] + finally: + release.set() + server.shutdown() + server.server_close() + serving.join(timeout=5) + client.join(timeout=5) + if closer is not None: + closer.join(timeout=5) + + +def test_fixture_waits_for_real_owned_writer(tmp_path): + completed = tmp_path / 'completed' + worker = subprocess.Popen([sys.executable, '-c', + "import sys,time; from pathlib import Path; time.sleep(.2); Path(sys.argv[1]).write_text('complete')", + str(completed)]) + try: + fixture.drain_fixture_workers([worker], timeout=5) + assert worker.returncode == 0 and completed.read_text() == 'complete' + finally: + if worker.poll() is None: + worker.terminate() + worker.wait(timeout=5) + + +def test_fixture_does_not_accept_an_undrained_worker(tmp_path): + ready = tmp_path / 'ready' + worker = subprocess.Popen([sys.executable, '-c', + "import sys,time; from pathlib import Path; Path(sys.argv[1]).touch(); time.sleep(60)", str(ready)]) + try: + deadline = time.monotonic()+5 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(.01) + assert ready.exists() and worker.poll() is None + with pytest.raises(subprocess.TimeoutExpired): + fixture.drain_fixture_workers([worker], timeout=.02) + assert worker.poll() is None + finally: + worker.terminate() + worker.wait(timeout=5) + + +def test_fixture_does_not_accept_worker_failure(): + worker = subprocess.Popen([sys.executable, '-c', 'raise SystemExit(7)']) + try: + with pytest.raises(RuntimeError, match='worker exited with status 7'): + fixture.drain_fixture_workers([worker], timeout=5) + finally: + worker.wait(timeout=5) diff --git a/tests/test_chat_agent_control_rpc_order.py b/tests/test_chat_agent_control_rpc_order.py new file mode 100644 index 0000000000..1eeab6b809 --- /dev/null +++ b/tests/test_chat_agent_control_rpc_order.py @@ -0,0 +1,62 @@ +"""Tools must retain the preceding Turn notification during a control RPC.""" + +import queue +import time +from pathlib import Path + +import pytest + +from loopx.chat_agent import CodexChatAgentSession + + +@pytest.mark.parametrize( + "started,foreign,authorized", [(True, False, True), (True, True, True), + (False, False, True), (True, False, False)] +) +def test_control_read_preserves_turn_binding_and_tool_permission( + started, foreign, authorized +): + messages = queue.Queue() + notification = { + "method": "turn/started", + "params": {"threadId": "thread", "turnId": "new-turn"}, + } + request = { + "id": 200, + "method": "item/tool/call", + "params": {"threadId": "thread", "turnId": "foreign" if foreign else "new-turn", + "callId": "200", "tool": "loopx_collaboration", + "arguments": {"action": "bindings"}}, + } + if started: + messages.put(notification) + messages.put(request) + messages.put({"id": 1, "result": {"goal": {"status": "active"}}}) + session = CodexChatAgentSession( + process=None, messages=messages, thread_id="thread", work_dir=Path.cwd() + ) + writes, calls = [], [] + session._write = writes.append + + def handler(name, arguments): + calls.append((name, arguments)) + return {"ok": authorized} + + session.read_tool_handler = handler + assert session._request("thread/goal/get", {"threadId": "thread"}, request_id=1) == { + "goal": {"status": "active"} + } + assert not calls + if started: + assert session._next_event(deadline=time.monotonic() + 1) == notification + session.current_turn_id = "new-turn" + if started and not foreign: + assert len(writes) == 1 + event = session._next_event(deadline=time.monotonic() + 1) + assert event == request + assert session._check_server_gate(event) + assert calls == [("loopx_collaboration", {"action": "bindings"})] + assert writes[-1]["result"]["success"] is authorized + else: + assert not calls + assert writes[-1]["result"]["success"] is False diff --git a/tests/test_chat_coordinator_checkpoint.py b/tests/test_chat_coordinator_checkpoint.py new file mode 100644 index 0000000000..a4cd04038d --- /dev/null +++ b/tests/test_chat_coordinator_checkpoint.py @@ -0,0 +1,177 @@ +import json +import subprocess +from threading import Event +from types import SimpleNamespace +from test_chat_loopx_mode import mode as mode, apply +from test_chat_project_coordination import project as project +import pytest +from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.collaboration_mcp import _pinned_release_environment, _python_module_command +from loopx.todos import add_goal_todo +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) +from tests.control_plane import test_default_checkpoint_continuation as native +from tests.test_goal_mode_mcp_settlement import _control, GOAL_ID, AGENT_ID + + +def cli(registry, *args): + result = subprocess.run( + [ + *_python_module_command("loopx.cli"), + "--registry", + str(registry), + "--format", + "json", + *args, + ], + env=_pinned_release_environment(), + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + return result.returncode, json.loads(result.stdout) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_original_checkpoint_context_stale_rejection_and_single_settlement( + provider, tmp_path, monkeypatch +): + monkeypatch.setattr(native, "_run_cli", cli) + registry, state, todo = native._fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + control.run_cli = lambda args, **kw: json.dumps(cli(registry, *args)[1]) + json.loads(control.should_run()) + first = json.loads( + control.complete_task(todo, AGENT_ID, "Candidate verified.", no_follow_up=True) + ) + assert first["stage"] == "direction_pending" + root = tmp_path / "runtime" + index = root / "goals" / GOAL_ID / "runs/index.jsonl" + checkpoint = CoordinatorCheckpoints(registry, root, GOAL_ID, AGENT_ID) + observed = checkpoint.read(todo) + assert observed["settlement_identity"] == first["settlement_identity"] + extra = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Verify the newly arrived dependency.", + claimed_by=AGENT_ID, + task_class="advancement_task", + continuation_policy="same_agent_non_delivery", + ) + before = index.read_bytes() + stale = checkpoint.resume( + todo, + { + "read_context_id": observed["read_context_id"], + "vision_unchanged_reason": "The original candidate remains, but this old basis is obsolete.", + }, + ) + assert not stale["ok"] and index.read_bytes() == before + with pytest.raises(ValueError): + checkpoint.read(extra["todo_id"]) + with pytest.raises(ValueError): + CoordinatorCheckpoints(registry, root, GOAL_ID, "different-agent").read(todo) + fresh = checkpoint.read(todo) + assert fresh["read_context_id"] != observed["read_context_id"] and extra[ + "todo_id" + ] in json.dumps(fresh["basis"]) + decision = { + "read_context_id": fresh["read_context_id"], + "vision_unchanged_reason": "The committed candidate remains accepted; the new dependency is the next obligation.", + } + with pytest.raises(ValueError): + checkpoint.resume(todo, {**decision, "agent_id": "different-agent"}) + assert index.read_bytes() == before + settled = checkpoint.resume(todo, decision) + assert ( + settled["ok"] + and settled["settlement_complete"] + and settled["settlement_identity"] == first["settlement_identity"] + ) + assert not settled.get("goal_completion_certified") + repeated = checkpoint.resume(todo, decision) + assert ( + repeated["ok"] + and repeated["settlement_identity"] == first["settlement_identity"] + ) + events = [json.loads(line) for line in index.read_text().splitlines()] + assert sum(e.get("classification") == "quota_slot_spent" for e in events) == 1 + rows = read_canonical_todos_if_promoted(runtime_root=root, goal_id=GOAL_ID)["todos"] + original = next(t for t in rows if t["todo_id"] == todo) + assert ( + original["status"] == "done" and original["evidence"] == "Candidate verified." + ) + assert next(t for t in rows if t["todo_id"] == extra["todo_id"])["status"] == "open" + + +def test_chat_checkpoint_dispatch_keeps_pause_binding_and_member_namespace( + mode, monkeypatch +): + service, sid, repo, settings, _ = mode + started = apply(mode, "start", settings=settings) + calls = [] + + class Checkpoints: + def __init__(self, registry, root, goal_id, agent_id): + assert registry == service.controller.registry_path + assert goal_id == "research" and agent_id == "coordinator" + + def read(self, todo): + calls.append(("read", todo)) + return {"ok": True, "read_context_id": "fresh"} + + def resume(self, todo, decision): + calls.append(("resume", todo, decision)) + return {"ok": True} + + monkeypatch.setattr( + "loopx.chat_coordinator_checkpoint.CoordinatorCheckpoints", Checkpoints + ) + adapter = SimpleNamespace( + goal_driver=SimpleNamespace(stopped=Event()), session=SimpleNamespace() + ) + lock = service.prepare( + sid, started["turn_id"], adapter, lambda *a: {}, lambda *a: None + ) + try: + handler = adapter.session.read_tool_handler + read = { + "action": "read", + "operation_id": "todo_original", + "brief": {"purpose": "first_delivery"}, + } + assert handler(TOOL_NAME, read)["ok"] + resume = { + "action": "resume", + "operation_id": "todo_original", + "brief": { + "purpose": "first_delivery", + "read_context_id": "fresh", + "vision_unchanged_reason": "Current facts preserve the route.", + }, + } + assert handler(TOOL_NAME, resume)["ok"] + assert calls[-1][2] == { + "read_context_id": "fresh", + "vision_unchanged_reason": "Current facts preserve the route.", + } + assert not handler(TOOL_NAME, {**read, "agent_id": "foreign"})["ok"] + binding = service.store.load_session(sid)["loopx_mode"]["settings"] + member, _, _, _ = service._execution(service.store.load_session(sid), binding) + path = member.path("todo_collision") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}") + assert not handler(TOOL_NAME, {**read, "operation_id": "todo_collision"})["ok"] + assert len(calls) == 2 + monkeypatch.setattr(service.controller, "interrupt_turn", lambda **_: None) + apply(mode, "pause") + assert handler(TOOL_NAME, resume)["error"] == "conversation_execution_inactive" + assert len(calls) == 2 + finally: + lock.__exit__(None, None, None) + + +TOOL_NAME = "loopx_collaboration" diff --git a/tests/test_delegation_task_revalidation.py b/tests/test_delegation_task_revalidation.py index a532c7e838..5a7c81465f 100644 --- a/tests/test_delegation_task_revalidation.py +++ b/tests/test_delegation_task_revalidation.py @@ -6,6 +6,7 @@ import subprocess from concurrent.futures import ThreadPoolExecutor from threading import Event +from pathlib import Path import pytest from mcp import ClientSession, StdioServerParameters @@ -189,7 +190,26 @@ def lose_response(binding, *args, **kwargs): getattr(restarted, entrypoint)("analysis-1") assert restarted.path("analysis-1").read_bytes() == frozen stop_path.unlink() # Remove only this synthetic injected fence. - getattr(restarted, entrypoint)("analysis-1") + if entrypoint == "resume": + async def resume_over_mcp(): + params = StdioServerParameters(command=sys.executable, + cwd=str(Path(__file__).resolve().parents[1]), env={ + key: os.environ[key] for key in ("NODE_OPTIONS", "TMPDIR", "TEMP", "TMP") + if key in os.environ}, args=["-m", "loopx.collaboration_mcp", + "--registry", str(restarted.registry), "--runtime-root", str(restarted.root), + "--goal-id", restarted.goal_id, "--agent-id", restarted.agent_id, + "--workspace", str(root / "lead"), "--execution-config", str(restarted.config)]) + async with stdio_client(params) as (reader, writer): + async with ClientSession(reader, writer) as session: + await session.initialize() + reply = await session.call_tool("resume_delegation", {"operation_id": "analysis-1"}) + assert not reply.isError, reply + # A previously committed result needs publication, not a + # new asynchronous Host. Closing this caller is safe now. + assert json.loads(reply.content[0].text)["status"] == "accepted" + asyncio.run(resume_over_mcp()) + else: + getattr(restarted, entrypoint)("analysis-1") accepted = wait(restarted) assert accepted["status"] == "accepted", accepted assert json.loads(restarted.path("analysis-1").read_text())["turn_key"] == turn_key diff --git a/tests/test_inbox_receipt_recovery.py b/tests/test_inbox_receipt_recovery.py index f280edc292..60cd399799 100644 --- a/tests/test_inbox_receipt_recovery.py +++ b/tests/test_inbox_receipt_recovery.py @@ -12,6 +12,31 @@ from loopx.capabilities.manager_context.roundtrip import report from loopx.control_plane.collaboration.inbox import acknowledge, pending from loopx.control_plane.collaboration.peers import read_inbox, request +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +def chat_turn_records(store, session_id): + from loopx.chat_store import CHAT_TURN_SCHEMA_VERSION + + records = [json.loads(path.read_text(encoding="utf-8")) + for path in (store.sessions_root / session_id / "turns").glob("*.json")] + # Windows lock holder diagnostics also end in .json; count business Turns. + return [row for row in records if row.get("schema_version") == CHAT_TURN_SCHEMA_VERSION] + + +def test_chat_turn_count_detects_a_real_additional_turn(tmp_path): + from loopx.chat_store import ChatSessionStore + + store = ChatSessionStore(tmp_path) + session = store.create_session(goal_id="loopx-manager", agent_id="codex", + adapter_kind="codex_app_server", upstream_thread_id="fixture", channel_id="manager") + first, _ = store.create_turn(session["session_id"], client_turn_id="first", message="First", origin="web") + store.update_turn(session["session_id"], first["turn_id"], status="completed") + store.finalize_managed_turn_completion(session["session_id"], first["turn_id"]) + second, _ = store.create_turn(session["session_id"], client_turn_id="second", message="Second", origin="web") + records = chat_turn_records(store, session["session_id"]) + assert {row["turn_id"] for row in records} == {first["turn_id"], second["turn_id"]} + assert len(records) == 2 @pytest.fixture @@ -79,7 +104,7 @@ def test_legal_unicode_results_cannot_overflow_the_existing_receipt_bridge(excha from loopx.control_plane.collaboration.inbox import _write root, _, rid, healthy = exchange - entry = next((root / ".local/manager-context/entries").glob(f"*/{rid}.json")) + entry = next(windows_extended_path(root / ".local/manager-context/entries").glob(f"*/{rid}.json")) request_row = json.loads(entry.read_text()) decision = json.loads((root / ".local/manager-context/decisions" / (rid + ".json")).read_text()) result = json.loads((root / ".local/manager-context/replies" / rid / "conclusion.json").read_text()) @@ -175,7 +200,7 @@ def no_external_send(*args, **kwargs): returned = [item for item in after["messages"] if item.get("origin") == "manager_followup"] assert len(returned) == 1 and returned[0]["turn_id"] == turn["turn_id"] assert returned[0]["return_delivery"]["status"] == "delivered" - assert len(list((store.sessions_root / session["session_id"] / "turns").glob("*.json"))) == 1 + assert [row["turn_id"] for row in chat_turn_records(store, session["session_id"])] == [turn["turn_id"]] finally: server.shutdown() server.server_close() diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index f8168f2f5e..e433ed99d0 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -30,14 +30,18 @@ from loopx.control_plane.turn_driver.lane_fence import turn_lane_liveness, turn_lane_singleflight # noqa: E402 from loopx.file_lock import exclusive_file_lock, try_exclusive_file_lock # noqa: E402 from loopx.file_lock import lock_holder_host_label # noqa: E402 +from tests.control_plane.host_checkpoint_fixture import checkpoint_fixture_script # noqa: E402 -HOST = '''import json, os, sys, time +HOST = checkpoint_fixture_script('''import json, os, sys, time from pathlib import Path +from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint from loopx.control_plane.turn_driver.host_candidate import build_result from loopx.control_plane.collaboration.inbox import acknowledge from loopx.control_plane.collaboration.peers import return_result request = json.load(sys.stdin) +if reply_to_checkpoint(request): + raise SystemExit(0) workspace = Path.cwd() root = Path(sys.argv[1]) envelope = request['turn_envelope'] @@ -58,7 +62,7 @@ acknowledge(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'adopt', 'Independently checked the requested scope.') return_result(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'Independent member conclusion; host acceptance is separate.') print(json.dumps(build_result(request, {'result_kind':'validated_progress', 'classification':'artifact_written', 'summary':'Fixture host supplied output for independent verification.', 'next_action':'Return verified evidence.', 'work_evidence':['artifact:delegation-output'], 'validation_evidence':['check:independent-member-result']}, host_name='Fixture'))) -''' +''') @pytest.fixture(params=["file", "sqlite"]) diff --git a/tests/test_project_alias_stdin.py b/tests/test_project_alias_stdin.py new file mode 100644 index 0000000000..4752c905f4 --- /dev/null +++ b/tests/test_project_alias_stdin.py @@ -0,0 +1,43 @@ +"""Git alias probes must preserve the caller's open protocol input.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("missing_origin", [False, True]) +def test_git_probe_preserves_protocol_input(missing_origin: bool) -> None: + source = Path(__file__).resolve().parents[1] + # Replace only the Git executable with a portable process that reads stdin. + # All pipe, timeout and return-code behavior comes from the real probe. + fake_git = ( + "import sys; head=sys.stdin.buffer.read(1); " + f"print('consumed RPC' if head else 'missing-origin' if {missing_origin!r} " + "else 'alias-root',flush=True); " + f"raise SystemExit({128 if missing_origin else 0})" + ) + code = ( + f"import sys,json; sys.path.insert(0,{str(source)!r}); " + "from pathlib import Path; import loopx.project_alias as alias; " + "run=alias.subprocess.run; " + f"alias.subprocess.run=lambda argv,**kwargs: " + f"run([sys.executable,'-I','-c',{fake_git!r}],**kwargs); " + "result=alias._run_git(Path.cwd(),'rev-parse','--show-toplevel'); " + "print(json.dumps({'code':result.returncode,'stdout':result.stdout," + "'remaining':sys.stdin.readline()}))" + ) + result = subprocess.run( + [sys.executable, "-I", "-c", code], + input="RPC packet\n", + text=True, + capture_output=True, + timeout=10, + check=True, + ) + row = json.loads(result.stdout) + assert row["remaining"] == "RPC packet\n" + assert row["code"] == (128 if missing_origin else 0) + assert row["stdout"].strip() == ("missing-origin" if missing_origin else "alias-root") diff --git a/tests/test_repository_probe_stdin.py b/tests/test_repository_probe_stdin.py new file mode 100644 index 0000000000..81e64fd8fb --- /dev/null +++ b/tests/test_repository_probe_stdin.py @@ -0,0 +1,67 @@ +import json +import os +import queue +import shlex +import subprocess +import sys +import threading +from pathlib import Path +import loopx + + +def test_repository_probe_preserves_open_caller_stdin(tmp_path): + release = Path(loopx.__file__).resolve().parent.parent + remote = "https://example.test/team/fixture.git" + git_script = tmp_path / "git_child.py" + git_script.write_text( + 'import sys\nassert sys.argv[1:]==["-C",sys.argv[2],"config","--get","remote.origin.url"]\nsys.stdin.buffer.read()\nprint(' + + repr(remote) + + ",flush=True)\n" + ) + git_cmd = tmp_path / ("fixture-git.cmd" if os.name == "nt" else "fixture-git") + if os.name == "nt": + git_cmd.write_text( + '@echo off\n"' + sys.executable + '" "' + str(git_script) + '" %*\n' + ) + else: + git_cmd.write_text( + "#!/bin/sh\nexec " + + shlex.quote(sys.executable) + + " " + + shlex.quote(str(git_script)) + + ' "$@"\n' + ) + git_cmd.chmod(0o700) + driver = tmp_path / "driver.py" + driver.write_text( + "import sys,json\nsys.path.insert(0," + + repr(str(release)) + + ')\nfrom pathlib import Path\nfrom loopx.repository_identity import _origin_remote\ntry:\n value=_origin_remote(Path(sys.argv[1]),sys.argv[2])\n print(json.dumps({"remote":value}),flush=True)\nexcept Exception as exc:print(json.dumps({"error":repr(exc)}),flush=True)\nprint(json.dumps({"caller_packet":sys.stdin.readline().strip()}),flush=True)\n' + ) + process = subprocess.Popen( + [sys.executable, "-I", "-u", str(driver), str(tmp_path), str(git_cmd)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + messages = queue.Queue() + reader = threading.Thread( + target=lambda: messages.put(process.stdout.readline()), daemon=True + ) + reader.start() + process.stdin.write("owned-rpc-packet\n") + process.stdin.flush() + try: + response = json.loads(messages.get(timeout=3)) + assert response == {"remote": remote}, response + assert json.loads(process.stdout.readline()) == { + "caller_packet": "owned-rpc-packet" + } + finally: + process.stdin.close() + process.wait(timeout=20) + reader.join(timeout=2) + process.stdout.close() + process.stderr.close() + assert process.returncode == 0 diff --git a/tests/test_turn_recovery_preview.py b/tests/test_turn_recovery_preview.py new file mode 100644 index 0000000000..5dc7e5c3fb --- /dev/null +++ b/tests/test_turn_recovery_preview.py @@ -0,0 +1,86 @@ +import copy +import json +import pytest +from loopx.control_plane.turn_driver import build_loopx_turn_plan, run_loopx_turn_once +from loopx.control_plane.turn_driver.executor import turn_journal_path +from loopx.control_plane.turn_driver.recovery import assess_existing_turn_recovery +from loopx.control_plane.turn_driver.transaction import TRANSACTION_PHASES + +def plan(): + return build_loopx_turn_plan({ + 'ok': True, 'schema_version': 'loopx_turn_envelope_v0', + 'goal_id': 'fixture-goal', 'agent_id': 'fixture-agent', + 'should_run': True, 'effective_action': 'normal_run', + 'action': {'must_attempt': True, 'delivery_allowed': True, + 'quiet_noop_allowed': False, 'selected_todo': {'todo_id': 'todo_fixture0001', 'text': 'fixture'}}, + 'user': {'action_required': False, 'open_count': 0, 'notify': 'DONT_NOTIFY'}, + 'writeback': {'spend_after_validation': True}, 'scheduler': {'action': 'run_now'}, + 'action_signature': {'matches': True, 'source_hash': 'sha256:fixture', 'envelope_hash': 'sha256:fixture'}, + 'compaction': {'within_budget': True}, + }, host='generic-cli', execution_mode='isolated-headless') + +def forbidden(*args, **kwargs): + pytest.fail('read-only preview invoked an execution callback') + +@pytest.mark.parametrize('prefix,retry', [(0, True), (3, True), (4, True), (5, True), (3, False)]) +def test_preview_uses_existing_typed_phase_without_effects(tmp_path, prefix, retry): + original = plan() + key = original['transaction']['turn_key'] + runtime = tmp_path / 'runtime' + path = turn_journal_path(runtime, goal_id='fixture-goal', turn_key=key) + journal = {'schema_version': 'loopx_turn_journal_v0', 'goal_id': 'fixture-goal', + 'turn_key': key, 'plan': original, 'status': 'failed', + 'completed_phases': list(TRANSACTION_PHASES[:prefix]), + 'receipt': {'turn_key': key, 'failed_phase': TRANSACTION_PHASES[prefix]}} + path.parent.mkdir(parents=True) + path.write_text(json.dumps(journal)) + before = path.read_bytes() + old_plan = copy.deepcopy(original) + decision = assess_existing_turn_recovery(journal, {}, goal_id='fixture-goal', + agent_id='fixture-agent', turn_key=key, retry_failed=retry, session_binding_resolver=None).decision + assert decision['reason'] != 'journal_inconsistent' + result = run_loopx_turn_once(original, host_runner=forbidden, project=tmp_path, + runtime_root=runtime, goal_id='fixture-goal', timeout_seconds=5, + execute=False, retry_failed=retry, task_validator=forbidden, + writeback=forbidden, spend=forbidden, scheduler=forbidden) + assert result['dry_run'] and result['status'] == 'preview' + assert result['route']['would_invoke_host'] == decision['reinvoke_host'] + assert result['recovery']['planned'] == decision + assert result['recovery']['actual']['journal_status'] == 'failed' + assert result['recovery']['actual']['host_invoked'] is False + assert not any(result['effects'].values()) + assert path.read_bytes() == before and original == old_plan + assert len(list(path.parent.iterdir())) == 1 + +@pytest.mark.parametrize('case', ['committed', 'foreign_goal', 'foreign_turn', 'session_blocked']) +def test_preview_returns_retained_or_blocked_without_host(tmp_path, case): + original = plan() + key = original['transaction']['turn_key'] + path = turn_journal_path(tmp_path, goal_id='fixture-goal', turn_key=key) + journal = {'schema_version': 'loopx_turn_journal_v0', 'goal_id': 'fixture-goal', 'turn_key': key, + 'plan': original, 'status': 'failed', 'completed_phases': list(TRANSACTION_PHASES[:3]), + 'receipt': {'turn_key': key, 'failed_phase': 'durable_writeback'}} + if case == 'committed': + journal.update(status='committed', completed_phases=list(TRANSACTION_PHASES)) + journal.pop('receipt') + elif case == 'foreign_goal': + journal['goal_id'] = 'other' + elif case == 'foreign_turn': + journal['turn_key'] = 'sha256:' + 'f' * 64 + else: + journal['host_recovery'] = {'unavailable': True} + path.parent.mkdir(parents=True) + path.write_text(json.dumps(journal)) + before = path.read_bytes() + result = run_loopx_turn_once(original, host_runner=forbidden, project=tmp_path, + runtime_root=tmp_path, goal_id='fixture-goal', timeout_seconds=5, execute=False, retry_failed=True) + assert result['route']['would_invoke_host'] is False + assert result['recovery']['planned']['action'] == ('return_existing' if case == 'committed' else 'blocked') + assert not any(result['effects'].values()) and path.read_bytes() == before + +def test_new_turn_preview_keeps_the_host_plan_and_writes_nothing(tmp_path): + result = run_loopx_turn_once(plan(), host_runner=forbidden, project=tmp_path, + runtime_root=tmp_path, goal_id='fixture-goal', timeout_seconds=5, execute=False) + assert result['route']['would_invoke_host'] is True + assert 'recovery' not in result and not any(result['effects'].values()) + assert list(tmp_path.iterdir()) == [] diff --git a/tests/test_workspace_probe_stdin.py b/tests/test_workspace_probe_stdin.py new file mode 100644 index 0000000000..28b38785a8 --- /dev/null +++ b/tests/test_workspace_probe_stdin.py @@ -0,0 +1,76 @@ +import json +import os +import queue +import shlex +import subprocess +import sys +import threading +from pathlib import Path +import loopx +import pytest + + +@pytest.mark.parametrize("kind", ["output", "missing"]) +def test_workspace_probe_preserves_caller_rpc(tmp_path, kind): + release = Path(loopx.__file__).resolve().parent.parent + fake = tmp_path / "git_child.py" + fake.write_text( + "import sys\nsys.stdin.buffer.read()\n" + + ( + 'print("workspace-output",flush=True)\n' + if kind == "output" + else "raise SystemExit(1)\n" + ) + ) + git = tmp_path / ("fixture-git.cmd" if os.name == "nt" else "fixture-git") + if os.name == "nt": + git.write_text('@echo off\n"' + sys.executable + '" "' + str(fake) + '" %*\n') + else: + git.write_text( + "#!/bin/sh\nexec " + + shlex.quote(sys.executable) + + " " + + shlex.quote(str(fake)) + + ' "$@"\n' + ) + git.chmod(0o700) + driver = tmp_path / "driver.py" + driver.write_text( + "import sys,json,subprocess\nsys.path.insert(0," + + repr(str(release)) + + ")\nfrom pathlib import Path\nfrom loopx.control_plane.agents.workspace_guard import _git_command_output,_git_origin_is_absent\nreal_run=subprocess.run\ndef run(argv,**kw):return real_run([sys.argv[2],*argv[1:]],**kw)\nsubprocess.run=run\nvalue=" + + ( + '_git_command_output(Path(sys.argv[1]),"rev-parse","--show-toplevel")' + if kind == "output" + else "_git_origin_is_absent(Path(sys.argv[1]))" + ) + + '\nprint(json.dumps({"value":value}),flush=True)\nprint(json.dumps({"caller_packet":sys.stdin.readline().strip()}),flush=True)\n' + ) + process = subprocess.Popen( + [sys.executable, "-I", "-u", str(driver), str(tmp_path), str(git)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + queue_ = queue.Queue() + reader = threading.Thread( + target=lambda: queue_.put(process.stdout.readline()), daemon=True + ) + reader.start() + process.stdin.write("owned-rpc-packet\n") + process.stdin.flush() + try: + assert json.loads(queue_.get(timeout=3)) == { + "value": "workspace-output" if kind == "output" else True + } + assert json.loads(process.stdout.readline()) == { + "caller_packet": "owned-rpc-packet" + } + finally: + process.stdin.close() + process.wait(timeout=20) + reader.join(timeout=2) + process.stdout.close() + process.stderr.close() + assert process.returncode == 0 From 135050f3845174eaf7ac1724385184fc7918b1bd Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 04:22:24 +0800 Subject: [PATCH 04/80] docs: clarify local delegation and recovery handoff Signed-off-by: Tartar --- docs/reference/local-delegation.md | 52 +++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 0b2ce2e6c8..5a3df1cdad 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -375,6 +375,15 @@ lost, readback exposes recovery and the team view offers **恢复原执行**. Re `revalidate` or using `resume` reconciles the original committed receipts without repeating Host work or quota spending. +For an already committed original recheck, `resume` and `revalidate` perform +its remaining native result publication before returning. This +also covers a committed recheck whose result projection is still `turn_returned`. +The MCP caller can close after that reply without leaving publication to a child +worker tied to its process lifetime. Current completion validation, grants, +ancestry and stop fences still apply; recovery can fail without publishing. +Execution without a committed original recheck keeps the +existing asynchronous worker path and may still return a pending state. + Current grants, stop state and declared ancestry are checked again before revalidation or committed-result recovery. Restoring the task output alone does not restore unavailable source inputs. @@ -396,6 +405,8 @@ reconciles its durable execution; it does not bypass the host's process policy. 委派校验器同样读取 EOF,不消费对话输入;普通独立调用校验器仍保留原有的标准输入继承行为。 宿主断连时若终止整个进程树(如 Windows MCP Job),重连后可沿原 operation 执行 `resume`,对账持久化执行,不绕过宿主进程策略。 +已提交的原重验结果会在 CLI/MCP 回复前完成剩余原生发布,仍受当前完成验收与权限、来源、stop 门约束;尚未提交的执行继续使用原异步 worker 路径,可能返回 pending。 + ## Source use and recheck `read` qualifies the current use of declared source operations separately from @@ -741,8 +752,15 @@ while it is unproven the stop stays `acknowledged` with `required_lease_release_unproven`, and an authority that cannot be read keeps it open with `lease_obligation_unproven`. If that host cannot be attributed, or its supervisor never finished cleaning up, the stop stays `acknowledged` and a -later `stop` rereads it. On a platform without process groups the launched -host cannot be proven drained at all, so `stop --execute` fails with an +later `stop` rereads it. New Windows managed Host launches assign their control +bridge to a non-breakaway kernel Job Object before sending the Host request. +The transport closes the whole Job on owner loss and reads its active process +count before returning; cold readback uses the original named Job, not a dead +leader PID. Assignment or observation failure supplies no exit proof. TypeScript +still owns the stop/lease decision. Historical Windows launches without that +Job evidence retain their unsupported boundary; they are never upgraded from +an old `finished` PID. On a platform without attributable process groups or +Job containment the launched host cannot be proven drained, so `stop --execute` fails with an actionable error naming that boundary before writing a cancellation intent, acknowledging, signalling a worker, or releasing a lease. Repeating a refused request preserves the operation and any existing stop receipt unchanged. An @@ -796,8 +814,12 @@ authority 路径恢复,stop 不会重建它。回执的 `lease.state` 为 `rel 因此不会产生一份「已结算」却让成员 Todo 被租约阻塞到 TTL 的回执;在释放得到证明前,停止保持 `acknowledged`,原因为 `required_lease_release_unproven`;权威无法读取时同样保持打开,原因为 `lease_obligation_unproven`。host 无法归属或其 supervisor 未完成清理时, -停止保持 `acknowledged`,之后再次调用 `stop` 会重新读取。在没有进程组的平台上, -启动过的 host 根本无法被证明已收尾,因此 `stop --execute` 会以指明该平台边界的 +停止保持 `acknowledged`,之后再次调用 `stop` 会重新读取。新 Windows managed Host +在发送 Host 请求前将控制 bridge 归入禁止 breakaway 的内核 Job Object;owner 消失时 +整组退出,返回前读取活动进程数,冷读回使用原 named Job,而非已退出的 leader PID。 +归组或观测失败不给退出证明,停止与租约判断仍由 TypeScript owner 负责。 +旧 Windows 执行没有 Job 证据时继续保持 unsupported,不能凭历史 `finished` PID 升级。 +没有可归属进程组或 Job containment 的执行无法证明已收尾,因此 `stop --execute` 会以指明该平台边界的 可操作错误在写入停止意图、确认、发送信号或释放租约之前失败,重复拒绝不修改原记录。 Host 记录尚未出现但 worker 仍活跃或无法归属时也拒绝;没有持有者且尚未启动的 operation 仍可安全取消。`unknown` 表示持有者在确认前消失;其 Host 被证明已退出后, @@ -1185,6 +1207,28 @@ paths, credential/endpoint configuration, or provider payloads. ### Inspect accepted evidence and return +When an enabled Goal Chat coordinator has committed a result but its native +direction checkpoint remains pending, the existing mode bar shows the retained +result and **Continue review**. The current SDK conversation reads and judges +fresh native facts; a finished SDK run does not settle the LoopX Todo or Goal. +No owner copies a context token and no additional execution setting is added. + +The existing `loopx_collaboration` tool retains its name and action vocabulary. +`read(operation_id=todo_id, brief={purpose:"first_delivery"})` reads the configured +coordinator's original post-result checkpoint. `resume` uses that same Todo and +`brief={purpose:"first_delivery", read_context_id, agent_vision}` or an authored +`vision_unchanged_reason`. The native checkpoint and host Todo transaction owners +reject stale inputs and retain the original result, Turn and single settlement. +This does not grant completion of arbitrary open or foreign tasks. Existing +member operations keep their original route; an existing member operation cannot +be reinterpreted as a coordinator checkpoint. Pause, registration/configuration +revocation, current Chat Turn binding, lease and independent acceptance still apply. + +中文:已保留的协调员结果若还待原生方向核验,运行栏显示“继续核验”。原 SDK +对话读取新事实并判断,用户不用搬 token;SDK 本轮结束不代表 Todo 或 Goal 完成。 +沿用原工具的 read/resume 和原 Todo,通过 `brief.purpose=first_delivery` 明确选择 +协调员 checkpoint,不能借成员操作、旧判断或其他 Agent 的任务扩大权限。 + Enabled MCP exposes `inspect_execution_binding`; newly enrolled Goal Chat tools accept `action=inspect` with `binding_id`. Existing native thread schemas remain unchanged. Owners can use **Team execution** directly below the Goal conversation From 0be397380f77cf34cd1547f943a4df728f279fc1 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 05:57:11 +0800 Subject: [PATCH 05/80] fix(checkpoint): retain legacy admission across repeated turn reads Signed-off-by: Tartar --- loopx/control_plane/goals/checkpoint_read_context.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index 1951f99ce6..da6879bc22 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -408,8 +408,10 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { if (!protectedEvents.length) return {required: false}; const match = protectedEvents.filter(event => event.agent_id === request.agent_id && [event.run_id, jsonObject(event.details)?.settlement_effect_id].includes(request.completion_turn_key)); - if (match.length !== 1 || request.identity == null || - jsonObject(request.identity)?.turn_instance_id !== match[0].run_id || + // Re-reading an admitted Turn records another observation, not a new + // admission identity. Every matching observation must retain that Turn. + if (match.length === 0 || request.identity == null || + match.some(event => jsonObject(request.identity)?.turn_instance_id !== event.run_id) || jsonObject(request.identity)?.agent_id !== match[0].agent_id || jsonObject(request.identity)?.todo_id !== request.todo_id) return rejected("delivery_work_context_required"); return {required: true}; From f2f5fa04518b6f7074af2c9a128dbb75de1bedf8 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 07:21:53 +0800 Subject: [PATCH 06/80] test(collaboration): preserve exact input bytes in result-use fixtures Signed-off-by: Tartar --- tests/test_delegation_result_use.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_delegation_result_use.py b/tests/test_delegation_result_use.py index 8b97dc42e0..7dc5f1a275 100644 --- a/tests/test_delegation_result_use.py +++ b/tests/test_delegation_result_use.py @@ -28,7 +28,7 @@ def test_version_bound_retry_reads_existing_operation_after_input_changes(servic source = wait(runner) artifact = source["artifacts"][0] consumer_input = root / "reviewer/corrected/accepted-input.json" - consumer_input.write_text(artifact["text"]) + consumer_input.write_bytes(artifact["text"].encode("utf-8")) dependency = {"ref": "accepted-input.json", "description": "Accepted analysis for synthesis", "sha256": artifact["sha256"], "delegation": { "operation_id": "analysis-1", "ref": artifact["ref"], "relation": "uses"}} @@ -78,7 +78,7 @@ def test_result_use_requires_exact_accepted_input_and_survives_reconnect(service assert "adoptions" not in source and "dependencies" not in source artifact = source["artifacts"][0] consumer_input = root / "reviewer/corrected/accepted-input.json" - consumer_input.write_text(artifact["text"]) + consumer_input.write_bytes(artifact["text"].encode("utf-8")) dependency = {"ref": "accepted-input.json", "description": "Accepted analysis for synthesis", "sha256": artifact["sha256"], "delegation": { "operation_id": "analysis-1", "ref": artifact["ref"], "relation": "uses"}} @@ -138,7 +138,7 @@ async def reconnect_mcp(): assert runner.read("analysis-1")["adoptions"][0]["state"] == "unavailable" with pytest.raises(Exception, match="exact current uses input"): runner.adopt_result("analysis-1", "synthesis-1") - consumer_input.write_text(artifact["text"]) + consumer_input.write_bytes(artifact["text"].encode("utf-8")) assert runner.read("analysis-1")["adoptions"][0]["state"] == "current" (root / "reviewer/corrected/output.json").write_text("{}") assert runner.read("analysis-1")["adoptions"][0]["state"] == "unavailable" From a42e3dddb09dd753bea522c3ea7b2fbfe58eb374 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 13:45:11 +0800 Subject: [PATCH 07/80] fix(mcp): support quiet waits for unfinished admitted work Signed-off-by: Tartar --- .../coordination/local_authority_runtime.ts | 2 +- .../coordination/todo_delivery_context.ts | 4 +- .../control_plane/host_adapter_settlement.py | 103 +++++++-- .../turn_driver/host_todo_completion.ts | 46 +++- loopx/goal_mode_mcp.py | 211 +++++++++++++++--- .../terminal_source_conformance.ts | 32 +-- tests/test_goal_mode_mcp_wait.py | 155 +++++++++++++ 7 files changed, 476 insertions(+), 77 deletions(-) create mode 100644 tests/test_goal_mode_mcp_wait.py diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index d46f3840db..e3c980ebfc 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1413,7 +1413,7 @@ export async function terminalLifecycleLocalCoordinationTodo( result.conflict_kind === "provider_revision_mismatch"; attempt++) result = await execute(); return {...result, ...providerEvidence}; } finally {await researchEvidence.close();} - }, {businessWrite: "todo_terminal"}); + }); } catch (error) { return {schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, status: "failed", changed: false, diff --git a/loopx/control_plane/coordination/todo_delivery_context.ts b/loopx/control_plane/coordination/todo_delivery_context.ts index dcd6c642ea..26d8c0088a 100644 --- a/loopx/control_plane/coordination/todo_delivery_context.ts +++ b/loopx/control_plane/coordination/todo_delivery_context.ts @@ -36,7 +36,7 @@ function bytes(path: string): Buffer { export async function withTerminalDeliverySources( root: string, goalId: string, input: JsonObject, write: () => Promise, ): Promise { - if (input.delivery_context == null) return withCanonicalWriter(root, goalId, input.dry_run === true, write); + if (input.delivery_context == null) return withCanonicalWriter(root, goalId, input.dry_run === true, write, {businessWrite: "todo_terminal"}); const context = requireJsonObject(input.delivery_context, "delivery context"); const registry = requireJsonObject(input.registry_source, "registry source"); const state = resolve(requireNonEmptyString(context.state_file, "delivery state file")); @@ -44,7 +44,7 @@ export async function withTerminalDeliverySources( withFileMutationLock(requireNonEmptyString(registry.path, "registry path"), () => withCanonicalWriter(root, goalId, false, () => withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => - withFileMutationLock(state, write, 30_000), 30_000)), 30_000), 30_000); + withFileMutationLock(state, write, 30_000), 30_000), {businessWrite: "todo_terminal"}), 30_000), 30_000); } /** Called only after durable receipt recovery, against the head used for CAS. */ diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index 67e8130aec..615f920a74 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -113,7 +113,13 @@ def _request_payload( payload["goal_ref"] = dict(request.goal_ref) if request.turn_instance_id is not None: payload["turn_instance_id"] = request.turn_instance_id - if request.vision_path or request.vision_unchanged_reason or request.checkpoint_read_context_id or phase in {"vision_refresh", "vision_context"} or request.first_delivery: + if ( + request.vision_path + or request.vision_unchanged_reason + or request.checkpoint_read_context_id + or phase in {"vision_refresh", "vision_context"} + or request.first_delivery + ): payload.update( schema_version="loopx_host_todo_completion_transaction_v1", vision_path=request.vision_path, @@ -121,9 +127,12 @@ def _request_payload( checkpoint_read_context_id=request.checkpoint_read_context_id, ) if request.first_delivery or request.current_agent_checkpoint: - payload.update(schema_version="loopx_host_todo_completion_transaction_v2", first_delivery=True, + payload.update( + schema_version="loopx_host_todo_completion_transaction_v2", + first_delivery=True, delivery_read_context_id=request.delivery_read_context_id, - current_agent_checkpoint=request.current_agent_checkpoint) + current_agent_checkpoint=request.current_agent_checkpoint, + ) elif request.delivery_read_context_id: payload["delivery_read_context_id"] = request.delivery_read_context_id return payload @@ -150,15 +159,52 @@ def host_vision_request( allow_terminal_stride_path=allow_terminal_stride_path) with tempfile.TemporaryDirectory(prefix="loopx-host-vision-") as directory: path = Path(directory) / "vision.json" - path.write_text(json.dumps(vision, ensure_ascii=False, allow_nan=False), encoding="utf-8") + path.write_text( + json.dumps(vision, ensure_ascii=False, allow_nan=False), encoding="utf-8" + ) yield replace(request, vision_path=str(path)) -def refresh_host_todo_vision(request: HostTodoSettlementRequest, *, run_cli: HostCliRunner) -> str: +def settle_host_todo_wait( + request: HostTodoSettlementRequest, + reason: str, + *, + run_cli: HostCliRunner, +) -> str: + """Transport a typed quiet wait on the original admitted unfinished work.""" + payload = {**_request_payload(request, phase="wait"), "wait_reason": reason} + plan = _runtime_reduction(payload, phase="wait") + if isinstance(plan.get("result"), Mapping): + return json.dumps(dict(plan["result"]), ensure_ascii=False, sort_keys=True) + args = _string_tuple(plan.get("args"), label="wait args") + legacy = plan.get("legacy_args") + output = run_cli( + list(args), + legacy_args=list(_string_tuple(legacy, label="wait legacy args")) + if legacy + else None, + ) + result = _runtime_reduction({**payload, "guard_output": output}, phase="wait").get( + "result" + ) + if not isinstance(result, Mapping): + raise RuntimeError("TypeScript host wait result shape mismatch") + return json.dumps(dict(result), ensure_ascii=False, sort_keys=True) + + +def refresh_host_todo_vision( + request: HostTodoSettlementRequest, *, run_cli: HostCliRunner +) -> str: """Repair the original checkpoint; no lifecycle operation, new Turn or spend.""" - phase = "vision_refresh" if ( - request.vision_path or request.vision_unchanged_reason or request.checkpoint_read_context_id - ) else "vision_context" + phase = ( + "vision_refresh" + if ( + request.vision_path + or request.vision_unchanged_reason + or request.checkpoint_read_context_id + ) + else "vision_context" + ) plan = _runtime_reduction(_request_payload(request, phase=phase), phase=phase) _runtime_identity(plan.get("identity")) args = plan.get("args") @@ -175,10 +221,14 @@ def project_host_interaction(output: str) -> str: return output if not isinstance(packet, dict): return output - projection = _runtime_reduction({ - "schema_version": "loopx_host_todo_completion_transaction_v1", - "phase": "project_guard", "packet": packet, - }, phase="project_guard") + projection = _runtime_reduction( + { + "schema_version": "loopx_host_todo_completion_transaction_v1", + "phase": "project_guard", + "packet": packet, + }, + phase="project_guard", + ) if not isinstance(projection.get("packet"), dict): raise RuntimeError("TypeScript host interaction projection shape mismatch") return json.dumps(projection["packet"], ensure_ascii=False) @@ -293,9 +343,13 @@ def _decode_provider_steps(value: Any) -> tuple[_ProviderStep, ...]: or len(raw_steps) not in {2, 3, 4, 5} ): raise RuntimeError("TypeScript host provider plan shape mismatch") - expected = (("guard", "delivery_context") if len(raw_steps) == 2 else - ("guard", "lifecycle_completion", "direction_context") if len(raw_steps) == 3 else - HOST_ADAPTER_SETTLEMENT_STEP_KINDS[: len(raw_steps)]) + expected = ( + ("guard", "delivery_context") + if len(raw_steps) == 2 + else ("guard", "lifecycle_completion", "direction_context") + if len(raw_steps) == 3 + else HOST_ADAPTER_SETTLEMENT_STEP_KINDS[: len(raw_steps)] + ) steps: list[_ProviderStep] = [] for index, raw in enumerate(raw_steps): if not isinstance(raw, Mapping) or raw.get("step_kind") != expected[index]: @@ -422,13 +476,22 @@ def settle_host_todo_completion( ) if finalized.get("decision") == "execute": token = finalized.get("resume_checkpoint_read_context_id") - if (not request.current_agent_checkpoint or request.checkpoint_read_context_id is not None - or not isinstance(token, str) or not token - or finalized.get("identity") != prepared["identity"]): + if ( + not request.current_agent_checkpoint + or request.checkpoint_read_context_id is not None + or not isinstance(token, str) + or not token + or finalized.get("identity") != prepared["identity"] + ): raise RuntimeError("Committed direction recovery binding is invalid") request = replace(request, checkpoint_read_context_id=token) - outcomes = _run_provider_plan(_decode_provider_steps(finalized["provider_effect"]), run_cli=run_cli) - finalized = _runtime_reduction(_request_payload(request, phase="finalize", provider_outcomes=outcomes), phase="finalize") + outcomes = _run_provider_plan( + _decode_provider_steps(finalized["provider_effect"]), run_cli=run_cli + ) + finalized = _runtime_reduction( + _request_payload(request, phase="finalize", provider_outcomes=outcomes), + phase="finalize", + ) result = finalized.get("result") if ( finalized.get("decision") not in {"complete", "blocked", "provider_result"} diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 37b0fb2be4..7c764ef7f8 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -11,6 +11,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { normalizeVisionUnchangedReason } from "../goals/vision_checkpoint.ts"; import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { projectMcpInteraction } from "./host_interaction.ts"; +import { TURN_CONTROLLER_CONTRACT } from "./turn_contract_generated.ts"; import { jsonObject, requireBoolean, @@ -30,7 +31,7 @@ export const HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION = export const HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION = "host_adapter_todo_settlement_v0"; -const PHASES = ["prepare", "finalize", "classify_guard", "vision_refresh", "vision_context", "project_guard"] as const; +const PHASES = ["prepare", "finalize", "classify_guard", "vision_refresh", "vision_context", "project_guard", "wait"] as const; export const HOST_ADAPTER_SETTLEMENT_STEP_KINDS = [ "guard", "lifecycle_completion", @@ -1081,6 +1082,48 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { }); } + +// Unfinished MCP work uses the same scoped guard owner. No lifecycle, vision, +// result validation, writeback or quota effect belongs to this quiet wait. +function waitForUnfinishedWork(request: HostTodoCompletionRequest, value: JsonObject): JsonObject { + const reason = requireNonEmptyString(value.wait_reason, "wait_reason").trim(); + if (!reason || reason.length > 240) throw new EffectRuntimeRequestError("wait_reason must contain 1..240 characters"); + if (request.completion_args.length || request.no_follow_up || request.first_delivery || + request.current_agent_checkpoint || request.vision_path || request.vision_unchanged_reason || + request.checkpoint_read_context_id || request.delivery_read_context_id) { + throw new EffectRuntimeRequestError("unfinished wait cannot submit completion or a vision decision"); + } + if (request.turn_instance_id === null) { + return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", + result: {schema_version: HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, ok: false, completed: false, + error: "Read should_run before waiting on an admitted unfinished Todo."}}; + } + const {payload: identity} = expectedIdentity(request); + if (value.guard_output === undefined) { + const guard = providerSteps(request, identity)[0]; + return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", identity, + args: [...guard.args], legacy_args: guard.legacy_args}; + } + if (typeof value.guard_output !== "string") throw new EffectRuntimeRequestError("guard_output must be a string"); + let guard: JsonObject | null = null; + try {guard = jsonObject(JSON.parse(value.guard_output));} catch { /* Reject corrupt provider output below. */ } + const selection = guardSelection(value.guard_output); + const selected = jsonObject(guard?.selected_todo); + const valid = guard?.ok === true && guard.should_run === true && guard.normal_delivery_allowed === true && + guard.work_input_restored === true && selected?.status === "open" && selected.todo_id === request.todo_id && + selection.state === "selected" && selection.todo_id === request.todo_id && + selection.settlement_identity !== null && guardIdentityMatches(selection, identity); + if (!valid) return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", identity, + result: blockedResult(request, "guard", reasonFrom(guard) || "Current guard does not admit unfinished work on the original Turn.")}; + return {schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase: "wait", identity, + result: {schema_version: HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, ok: true, completed: false, + settlement_complete: false, goal_id: request.goal_id, todo_id: request.todo_id, settlement_identity: identity, + result_kind: TURN_CONTROLLER_CONTRACT.vocabularies.turn_result_kind.WAIT, reason, + task_validation: {status: "not_required", validator_kind: "stop_result"}, + effects: {host_invoked: false, todo_completed: false, state_written: false, quota_spent: false}, + next_tool: "should_run"}}; +} + export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { const phase = decodePhase(value); if (phase === "project_guard") { @@ -1102,6 +1145,7 @@ export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { }; } const request = decodeRequest(value, phase); + if (phase === "wait") return waitForUnfinishedWork(request, value); if (phase === "finalize") return finalize(request); const { payload: identity } = expectedIdentity(request); if (phase === "vision_context") { diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 02007b0a62..14063b4090 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -1,4 +1,5 @@ """Shared MCP control plane for host-specific LoopX goal adapters.""" + from __future__ import annotations import inspect @@ -10,15 +11,24 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Annotated, Any + +try: # pydantic ships with the FastMCP extra; base installs have no pydantic. + from pydantic import Strict +except ImportError: # pragma: no cover - exercised on base installs only + + class Strict: # type: ignore[no-redef] + """Inert stand-in so the shared annotation still evaluates without + pydantic; FastMCP cannot run on such an install anyway.""" + from .control_plane.host_adapter_settlement import ( HostTodoSettlementRequest, - classify_host_guard_snapshot, host_vision_request, project_host_interaction, refresh_host_todo_vision, settle_host_todo_completion, + settle_host_todo_wait, ) @@ -104,8 +114,7 @@ def _runtime_profile_flag_is_unsupported( result.returncode == 2 and "--runtime-profile" in diagnostic and ( - "unrecognized arguments" in diagnostic - or "invalid choice" in diagnostic + "unrecognized arguments" in diagnostic or "invalid choice" in diagnostic ) ) @@ -127,7 +136,9 @@ def run_cli( [*command, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=environment, ) - if legacy_args is not None and self._runtime_profile_flag_is_unsupported(result): + if legacy_args is not None and self._runtime_profile_flag_is_unsupported( + result + ): result = subprocess.run( [*command, *legacy_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=environment, @@ -158,7 +169,9 @@ def _identity_error(self, requested_agent_id: str) -> str | None: } ) - def should_run_args(self, goal_id: str, agent_id: str | None) -> tuple[list[str], list[str]]: + def should_run_args( + self, goal_id: str, agent_id: str | None + ) -> tuple[list[str], list[str]]: common = ["quota", "should-run", "--goal-id", goal_id] if agent_id: common += ["--agent-id", agent_id] @@ -186,15 +199,32 @@ def should_run(self, revalidate_todo_id: str = "") -> str: guard = json.loads(output) if guard.get("ok") is True and guard.get("delivery_result_context"): identity = guard["delivery_result_context"]["settlement_identity"] - refreshed = json.loads(self.run_cli(["checkpoint-context", "--goal-id", goal_id, - "--agent-id", self.bound_agent_id(), *suffix, - "--turn-instance-id", identity["turn_instance_id"], "--purpose", "delivery_result"])) + refreshed = json.loads( + self.run_cli( + [ + "checkpoint-context", + "--goal-id", + goal_id, + "--agent-id", + self.bound_agent_id(), + *suffix, + "--turn-instance-id", + identity["turn_instance_id"], + "--purpose", + "delivery_result", + ] + ) + ) if refreshed.get("ok") is not True: return json.dumps(refreshed) - guard["delivery_result_context"] = {key: refreshed[key] for key in - ("ok", "basis", "read_context_id", "settlement_identity")} + guard["delivery_result_context"] = { + key: refreshed[key] + for key in ("ok", "basis", "read_context_id", "settlement_identity") + } guard["result_revalidation_required"] = True - guard["next_action"] = "Revalidate the retained candidate against this basis, then call complete_task for the same Todo. The original Turn key is carried automatically." + guard["next_action"] = ( + "Revalidate the retained candidate against this basis, then call complete_task for the same Todo. The original Turn key is carried automatically." + ) output = json.dumps(guard) return project_host_interaction(output) @@ -209,8 +239,16 @@ def _original_work_turn(self, todo_id: str) -> str | None: if not registry or not Path(registry).is_file(): return None # Older host contracts retain their deterministic identity. path = Path(registry) - return restore_default_turn_id(runtime_root=resolve_runtime_root(load_registry(path), None, registry_path=path), - registry_path=path, goal_id=goal_id, agent_id=self.bound_agent_id(), todo_id=todo_id, pending_only=False) + return restore_default_turn_id( + runtime_root=resolve_runtime_root( + load_registry(path), None, registry_path=path + ), + registry_path=path, + goal_id=goal_id, + agent_id=self.bound_agent_id(), + todo_id=todo_id, + pending_only=False, + ) def list_todos(self) -> str: return self.should_run() @@ -222,13 +260,22 @@ def _checkpoint_continuation(self, todo_id: str, turn_id: str | None) -> bool: from .history import load_registry from .paths import resolve_runtime_root from .control_plane.quota.settlement import read_heartbeat_settlement - from .control_plane.goals.checkpoint_context_io import checkpoint_continuation_required + from .control_plane.goals.checkpoint_context_io import ( + checkpoint_continuation_required, + ) goal_id, registry = self.context() path = Path(registry) root = resolve_runtime_root(load_registry(path), None, registry_path=path) - readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=self.bound_agent_id(), - todo_id=todo_id, turn_instance_id=turn_id, registry_path=path, goal_ref=self.goal_ref()) + readback = read_heartbeat_settlement( + root, + goal_id=goal_id, + agent_id=self.bound_agent_id(), + todo_id=todo_id, + turn_instance_id=turn_id, + registry_path=path, + goal_ref=self.goal_ref(), + ) return readback is not None and checkpoint_continuation_required(readback) def _stored_terminal_intent(self, todo_id: str, turn_id: str) -> bool: @@ -238,20 +285,41 @@ def _stored_terminal_intent(self, todo_id: str, turn_id: str) -> bool: from .control_plane.goals.checkpoint_context_io import _checkpoint_effect goal_id, registry = self.context() - todo = list_goal_todos(registry_path=Path(registry), goal_id=goal_id, todo_id=todo_id).get("todo") - identity = SettlementIdentity(goal_id=goal_id, agent_id=self.bound_agent_id(), - todo_id=todo_id, turn_instance_id=turn_id) - return _checkpoint_effect("goal.checkpoint_read_context.work_binding", { - "phase": "direction_terminal_intent", "todo": todo, "identity": identity.as_dict()})["no_followup"] + todo = list_goal_todos( + registry_path=Path(registry), goal_id=goal_id, todo_id=todo_id + ).get("todo") + identity = SettlementIdentity( + goal_id=goal_id, + agent_id=self.bound_agent_id(), + todo_id=todo_id, + turn_instance_id=turn_id, + ) + return _checkpoint_effect( + "goal.checkpoint_read_context.work_binding", + { + "phase": "direction_terminal_intent", + "todo": todo, + "identity": identity.as_dict(), + }, + )["no_followup"] def host_prompt(self) -> str: from .claude_goal_mode.scripts.goalmode_cmd import loop_execution_content + state = self.state() goal_id, agent_id = state.get("goal_id"), state.get("agent_id") if not goal_id or not agent_id: - return json.dumps({"ok": False, "error": "bound Goal and agent are required"}) - return json.dumps({"ok": True, "goal_id": goal_id, "agent_id": agent_id, - "task_body": loop_execution_content(goal_id, agent_id)}) + return json.dumps( + {"ok": False, "error": "bound Goal and agent are required"} + ) + return json.dumps( + { + "ok": True, + "goal_id": goal_id, + "agent_id": agent_id, + "task_body": loop_execution_content(goal_id, agent_id), + } + ) def claim_task(self, todo_id: str, agent_id: str) -> str: goal_id, _ = self.context() @@ -275,6 +343,27 @@ def claim_task(self, todo_id: str, agent_id: str) -> str: ] ) + def wait_task(self, todo_id: str, agent_id: str, reason: str) -> str: + goal_id, _ = self.context() + if not goal_id: + return self.no_goal_message() + identity_error = self._identity_error(agent_id) + if identity_error: + return identity_error + request = HostTodoSettlementRequest( + goal_id=goal_id, + agent_id=agent_id, + todo_id=todo_id, + runtime_profile=self.config.runtime_profile, + legacy_host_surface=self.config.legacy_host_surface, + scheduler_owner=self.config.scheduler_owner, + execution_mode=self.config.execution_mode, + completion_args=(), + goal_ref=self.goal_ref(), + turn_instance_id=self._original_work_turn(todo_id), + ) + return settle_host_todo_wait(request, reason, run_cli=self.run_cli) + def complete_task( self, todo_id: str, @@ -309,11 +398,24 @@ def complete_task( ) if successor_todo_ids is not None and ( not isinstance(successor_todo_ids, list) - or any(not isinstance(value, str) or not value.strip() for value in successor_todo_ids) + or any( + not isinstance(value, str) or not value.strip() + for value in successor_todo_ids + ) ): - return json.dumps({"ok": False, "error": "successor_todo_ids must be a list of nonempty ids"}) + return json.dumps( + { + "ok": False, + "error": "successor_todo_ids must be a list of nonempty ids", + } + ) if successor_todo_ids and (next_agent_todo or no_follow_up): - return json.dumps({"ok": False, "error": "choose existing successors, a new successor, or no follow-up"}) + return json.dumps( + { + "ok": False, + "error": "choose existing successors, a new successor, or no follow-up", + } + ) args = [ "todo", "complete", @@ -357,21 +459,33 @@ def complete_task( allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: output = settle_host_todo_completion(authored, run_cli=self.run_cli) completed = json.loads(output) - if completed.get("ok") is True and completed.get("settlement_complete") is True and turn_id: + if ( + completed.get("ok") is True + and completed.get("settlement_complete") is True + and turn_id + ): from pathlib import Path from .history import load_registry from .paths import resolve_runtime_root - from .control_plane.turn_driver.journal_store import reconcile_settled_turn_journal + from .control_plane.turn_driver.journal_store import ( + reconcile_settled_turn_journal, + ) _, registry = self.context() path = Path(registry) root = resolve_runtime_root(load_registry(path), None, registry_path=path) - reconcile_settled_turn_journal(root, registry_path=path, - settlement_identity=completed["settlement_identity"]) + reconcile_settled_turn_journal( + root, + registry_path=path, + settlement_identity=completed["settlement_identity"], + ) return output def review_task_vision( - self, todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, + self, + todo_id: str, + agent_id: str, + agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", first_delivery: bool = False, @@ -384,10 +498,13 @@ def review_task_vision( return identity_error turn_id = self._original_work_turn(todo_id) request = HostTodoSettlementRequest( - goal_id=goal_id, agent_id=agent_id, todo_id=todo_id, + goal_id=goal_id, + agent_id=agent_id, + todo_id=todo_id, runtime_profile=self.config.runtime_profile, legacy_host_surface=self.config.legacy_host_surface, - scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, + scheduler_owner=self.config.scheduler_owner, + execution_mode=self.config.execution_mode, completion_args=(), checkpoint_read_context_id=read_context_id or None, first_delivery=first_delivery, @@ -427,6 +544,7 @@ async def call_tool(self, name: str, arguments: dict[str, Any]): server = GoalModeServer(config.server_name) if config.legacy_host_surface == "claude_code": + @server.tool() def host_prompt() -> str: """Read current Claude Goal execution rules for this server's bound identity.""" @@ -453,7 +571,9 @@ def claim_task(todo_id: str, agent_id: str) -> str: @server.tool() def review_task_vision( - todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, + todo_id: str, + agent_id: str, + agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", first_delivery: bool = False, @@ -470,7 +590,24 @@ def review_task_vision( An unchanged reason requires an existing valid vision. Recheck should_run; checkpoint success alone does not certify Goal completion or clear gates. """ - return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason, read_context_id, first_delivery) + return control.review_task_vision( + todo_id, + agent_id, + agent_vision, + vision_unchanged_reason, + read_context_id, + first_delivery, + ) + + @server.tool() + def wait_task(todo_id: str, agent_id: str, reason: str) -> str: + """Pause unfinished admitted work without completing it, judging vision + or spending. Read should_run first; the original Turn is restored + automatically. On current authority refusal follow that refusal. + Resume through should_run when the dependency is ready and revalidate + the retained candidate if its result basis changes. + """ + return control.wait_task(todo_id, agent_id, reason) @server.tool() def complete_task( diff --git a/tests/control_plane_ts/terminal_source_conformance.ts b/tests/control_plane_ts/terminal_source_conformance.ts index 29320dc981..9dd8fc14ab 100644 --- a/tests/control_plane_ts/terminal_source_conformance.ts +++ b/tests/control_plane_ts/terminal_source_conformance.ts @@ -68,22 +68,22 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut const current = await loaded(store); const check = (receipt: unknown, key: string) => async (head: AuthorityStoreHead) => evaluate({phase: "check", purpose: "delivery_result", identity, facts: facts(head), receipt, read_context_id: key}); - const rejected = await execute(store, request, async () => true, undefined, check(original.receipt, "original-read")); + const rejected = await execute(store, request, async () => true, null, undefined, check(original.receipt, "original-read")); assert.equal(rejected.reason_code, "checkpoint_read_context_stale", JSON.stringify(rejected)); assert.deepEqual(await loaded(store), current); const fresh = read(current, "revalidated-read"); const revalidated = {...request, delivery_read_context_id: "revalidated-read", validation_source_provider_revision: current.provider_revision}; - assert.equal((await execute(store, revalidated, async () => true, undefined, check(fresh.receipt, "revalidated-read"))).status, "applied"); + assert.equal((await execute(store, revalidated, async () => true, null, undefined, check(fresh.receipt, "revalidated-read"))).status, "applied"); const saved = await loaded(store); assert.equal((await execute(contender, revalidated, async () => {throw new Error("receipt before authority");}, - async () => {throw new Error("receipt before lifecycle settlement");}, + null, async () => {throw new Error("receipt before lifecycle settlement");}, async () => {throw new Error("receipt before freshness");})).status, "replayed"); const withoutReadKey = {...revalidated, delivery_read_context_id: undefined}; assert.equal((await execute(contender, withoutReadKey, async () => {throw new Error("receipt before authority");}, - async () => {throw new Error("receipt before lifecycle settlement");}, + null, async () => {throw new Error("receipt before lifecycle settlement");}, async () => {throw new Error("receipt before freshness");})).status, "replayed"); assert.equal((await execute(contender, {...withoutReadKey, evidence: "Different intended result"}, - async () => true, undefined, check(fresh.receipt, "revalidated-read"))).reason_code, + async () => true, null, undefined, check(fresh.receipt, "revalidated-read"))).reason_code, "coordination_operation_identity_mismatch"); assert.deepEqual(await loaded(store), saved); }); @@ -121,7 +121,7 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut validation_declaration_sha256: canonicalAuthoritySha256(declaration), completion_policy_request: null, dry_run: false, now: new Date(String(fixture.semantic_cases.terminal_source!.observed_at)), delivery_read_context_id: "result-read", validation_source_provider_revision: before.provider_revision}; - const result = await execute(store, request, async () => true, undefined, check); + const result = await execute(store, request, async () => true, null, undefined, check); assert.equal(result.status, "applied", JSON.stringify(result)); const committed = await loaded(store); const todo = (committed.head.todos as JsonObject[]).find(row => row.todo_id === request.todo_id)!; @@ -130,10 +130,10 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut assert.equal(todo.completion_recovery ?? null, null); assert.equal(todo.completion_requested_continuation, "no_followup"); assert.equal(todo.no_followup, false); - assert.equal((await execute(contender, request, async () => true, undefined, check)).status, "replayed"); + assert.equal((await execute(contender, request, async () => true, null, undefined, check)).status, "replayed"); const closeout = {...request, defer_no_followup: false, validation_receipt: null, validation_source_provider_revision: committed.provider_revision}; - const changed = await execute(store, {...closeout, evidence: "Changed result"}, async () => true, undefined, check); + const changed = await execute(store, {...closeout, evidence: "Changed result"}, async () => true, null, undefined, check); assert.equal(changed.reason_code, "coordination_operation_identity_mismatch", JSON.stringify(changed)); assert.deepEqual(await loaded(store), committed); const lifecycle: CoordinationTodoTerminalLifecycleInput = {...closeout, @@ -147,11 +147,11 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut const lifecycleBasis = async () => {throw new Error("Reentry must not admit new RESULT work");}; if (closeoutSource === "lifecycle") { const rejected = await execute(store, lifecycle, async () => true, - async () => {throw new Error("Original writeback/spend not settled");}, lifecycleBasis); + null, async () => {throw new Error("Original writeback/spend not settled");}, lifecycleBasis); assert.equal(rejected.status, "failed"); assert.deepEqual(await loaded(store), committed); } - const closed = await execute(contender, closedRequest, async () => true, settlement, + const closed = await execute(contender, closedRequest, async () => true, null, settlement, closeoutSource === "turn" ? check : lifecycleBasis); assert.equal(closed.status, "applied", JSON.stringify(closed)); assert.deepEqual(closed.validation_receipt, closeoutSource === "turn" ? validation : null); @@ -163,14 +163,14 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut ? "same_turn_terminal_closeout" : "lifecycle_reentry_terminal_closeout"); assert.equal(terminal.no_followup, true); assert.equal((await execute(store, closedRequest, async () => {throw new Error("Historical closeout");}, - async () => {throw new Error("No repeated settlement verification");}, check)).status, "replayed"); + null, async () => {throw new Error("No repeated settlement verification");}, check)).status, "replayed"); // After lifecycle closeout the exact accepted Turn RESULT remains historical; // changing its actor, evidence or lease cannot borrow that receipt. assert.equal((await execute(store, closeout, async () => {throw new Error("Historical RESULT");}, - undefined, check)).status, "replayed"); + null, undefined, check)).status, "replayed"); for (const patch of [{evidence: "Changed result"}, {actor_agent_id: "agent-b"}, {lease_expected_version: 999}]) { - assert.equal((await execute(store, {...closeout, ...patch}, async () => true, undefined, check)).status, "failed"); + assert.equal((await execute(store, {...closeout, ...patch}, async () => true, null, undefined, check)).status, "failed"); assert.deepEqual(await loaded(store), after); } assert.deepEqual(await loaded(store), after); @@ -217,19 +217,19 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut const member = Reflect.get(target, key); return typeof member === "function" ? member.bind(target) : member; }}); - const rejected = await execute(racing, request, async () => true, undefined, checkBasis); + const rejected = await execute(racing, request, async () => true, null, undefined, checkBasis); assert.equal(rejected.conflict_kind, "provider_revision_mismatch", JSON.stringify(rejected)); assert.ok(attemptedId); assert.equal((await store.readReceipt(attemptedId)).status, "missing"); const current = await loaded(store); assert.equal((current.head.todos as JsonObject[]).find(todo => todo.todo_id === request.todo_id)!.status, "open"); - const completed = await execute(store, request, async () => true, undefined, checkBasis); + const completed = await execute(store, request, async () => true, null, undefined, checkBasis); assert.equal(completed.status, "applied", JSON.stringify(completed)); assert.deepEqual([...new Set(checkedHeads)], [before.provider_revision, current.provider_revision]); const after = await loaded(store); assert.equal((after.head.todos as JsonObject[]).find(todo => todo.todo_id === peer.todo_id)!.note, "Peer update survives"); const replay = await execute(contender, request, async () => {throw new Error("history before authority");}, - async () => {throw new Error("history before lifecycle settlement");}, + null, async () => {throw new Error("history before lifecycle settlement");}, async () => {throw new Error("history before freshness");}); assert.equal(replay.status, "replayed"); assert.deepEqual(await loaded(store), after); diff --git a/tests/test_goal_mode_mcp_wait.py b/tests/test_goal_mode_mcp_wait.py new file mode 100644 index 0000000000..767d97aeab --- /dev/null +++ b/tests/test_goal_mode_mcp_wait.py @@ -0,0 +1,155 @@ +from __future__ import annotations +import copy +import json +from dataclasses import replace +import pytest +from loopx.control_plane.host_adapter_settlement import ( + HostTodoSettlementRequest, + _request_payload, + _runtime_reduction, + settle_host_todo_wait, +) +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane + + +def request(): + return HostTodoSettlementRequest( + goal_id="wait-goal", + agent_id="claude", + todo_id="todo_wait01", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + scheduler_owner="agent_cli_loop", + execution_mode="interactive", + completion_args=(), + turn_instance_id="original-work", + ) + + +def guard(): + r = request() + identity = { + "schema_version": "quota_settlement_identity_v0", + "goal_id": r.goal_id, + "agent_id": r.agent_id, + "todo_id": r.todo_id, + "turn_instance_id": r.turn_instance_id, + "effect_id": "wait-goal:claude:todo_wait01:original-work", + } + return { + "ok": True, + "should_run": True, + "normal_delivery_allowed": True, + "work_input_restored": True, + "selected_todo": {"todo_id": r.todo_id, "status": "open"}, + "heartbeat_receipt": {"settlement_identity": identity}, + } + + +def test_native_wait_executes_only_current_original_guard(): + calls = [] + + def cli(args, **kwargs): + calls.append(args) + assert args[:2] == ["quota", "should-run"] and "original-work" in args + assert "spend-slot" not in args and "complete" not in args + return json.dumps(guard()) + + r = json.loads(settle_host_todo_wait(request(), "Dependency pending.", run_cli=cli)) + assert ( + len(calls) == 1 + and r["ok"] + and not r["completed"] + and not r["settlement_complete"] + and r["result_kind"] == "wait" + ) + assert r["effects"] == { + "host_invoked": False, + "todo_completed": False, + "state_written": False, + "quota_spent": False, + } + assert ( + r["task_validation"]["status"] == "not_required" + and r["settlement_identity"]["turn_instance_id"] == "original-work" + ) + + +@pytest.mark.parametrize( + "fault", + [ + "stop", + "permission", + "unrestored", + "done", + "foreign_todo", + "foreign_turn", + "foreign_agent", + "malformed", + ], +) +def test_native_wait_refuses_unadmitted_or_replaced_provider_facts(fault): + g = copy.deepcopy(guard()) + if fault == "stop": + g["should_run"] = False + elif fault == "permission": + g["normal_delivery_allowed"] = False + elif fault == "unrestored": + g["work_input_restored"] = False + elif fault == "done": + g["selected_todo"]["status"] = "done" + elif fault == "foreign_todo": + g["selected_todo"]["todo_id"] = "todo_other01" + elif fault in ["foreign_turn", "foreign_agent"]: + i = g["heartbeat_receipt"]["settlement_identity"] + i["turn_instance_id" if fault == "foreign_turn" else "agent_id"] = "other" + i["effect_id"] = ":".join( + i[k] for k in ["goal_id", "agent_id", "todo_id", "turn_instance_id"] + ) + p = { + **_request_payload(request(), phase="wait"), + "wait_reason": "Dependency pending.", + "guard_output": "broken JSON" if fault == "malformed" else json.dumps(g), + } + assert _runtime_reduction(p, phase="wait")["result"]["ok"] is False + + +@pytest.mark.parametrize( + "field,value", + [ + ("completion_args", ["todo", "complete"]), + ("no_follow_up", True), + ("vision_unchanged_reason", "Old route."), + ("wait_reason", "x" * 241), + ], +) +def test_wait_cannot_smuggle_completion_or_judgment(field, value): + p = {**_request_payload(request(), phase="wait"), "wait_reason": "Pending."} + p[field] = value + with pytest.raises(ValueError): + _runtime_reduction(p, phase="wait") + + +def test_wait_does_not_allocate_a_turn_or_cross_cli_without_admission(): + r = request() + r = replace(r, turn_instance_id=None) + + def cli(*a, **kw): + pytest.fail("unadmitted wait invoked CLI") + + assert not json.loads(settle_host_todo_wait(r, "Pending.", run_cli=cli))["ok"] + + +def test_wait_keeps_bound_agent_fence_before_transport(monkeypatch): + c = GoalModeMCPControlPlane( + GoalModeMCPConfig( + server_name="wait", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + ), + lambda: {"goal_id": "wait-goal", "registry": "unused", "agent_id": "claude"}, + ) + monkeypatch.setattr( + c, "run_cli", lambda *a, **kw: pytest.fail("wrong Agent invoked CLI") + ) + assert not json.loads(c.wait_task("todo_wait01", "foreign", "Pending."))["ok"] From 9684991c4799be830df5ca3c3b71a7ce0c303dd2 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 13:45:11 +0800 Subject: [PATCH 08/80] docs(mcp): explain unfinished wait and result recovery Signed-off-by: Tartar --- .../reference/protocols/goal-vision-replan-contract-v0.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 0105d21791..0e966b6bfb 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -999,6 +999,14 @@ remains a lifecycle-only profile under its existing runtime admission restrictio No capability, provider or actor write authority is added. Required checkpoints continue the current Agent after the durable result; typed exemptions add no direction call. +For unfinished work already admitted through ordinary MCP `should_run`, use +`wait_task(todo_id, agent_id, reason)` for a quiet typed wait. It restores the +original Turn and checks its current guard, leaves the Todo open, and performs +no direction call, lifecycle completion, writeback or quota spend. It does not +repair a completed result's pending direction; use `review_task_vision` and the +original completion intent for that recovery. Resume unfinished work through +`should_run` when ready and revalidate a retained candidate on changed facts. + Default entrypoint qualification currently covers local-registry Legacy Markdown, File and SQLite. PostgreSQL has native result transaction/head/CAS conformance; the ordinary Python CLI/MCP/managed runtime does not supply the existing From ecf86912df989bdfd79709a4da19f059b5eb1f36 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 16:07:13 +0800 Subject: [PATCH 09/80] fix: replay exact deferred completion receipt after settlement Signed-off-by: Tartar --- .../coordination/todo_terminal_lifecycle.ts | 10 +++ .../control_plane/test_settled_mcp_replay.py | 85 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/control_plane/test_settled_mcp_replay.py diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 5592eeb3b8..9b07bb63a6 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1337,6 +1337,16 @@ export async function executeCoordinationTodoTerminalLifecycle( } } } + if (replay?.reason_code === "coordination_operation_identity_mismatch" && + normalized.operation_identity.kind === "completion_turn" && + !normalized.requested_no_followup && normalized.defer_no_followup !== true) { + // The derived checkpoint phase disappears after settlement. Recover only + // the exact original RESULT request for an ordinary continuation; every + // supplied intent, actor, lease, read identity and validation stays bound. + const originalInput = {...input, defer_no_followup: true}; + const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); + if (original?.status === "replayed") return original; + } if (replay !== null) return replay; if (normalized.operation_identity.kind === "completion_turn" && normalized.requested_no_followup && normalized.defer_no_followup !== true) { // Older adapters used the ordinary id for an already accepted closeout. diff --git a/tests/control_plane/test_settled_mcp_replay.py b/tests/control_plane/test_settled_mcp_replay.py new file mode 100644 index 0000000000..067b53ed5a --- /dev/null +++ b/tests/control_plane/test_settled_mcp_replay.py @@ -0,0 +1,85 @@ +"""Settled MCP retries recover their original native completion fingerprint.""" + +from __future__ import annotations + +import json + +import pytest + +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import AGENT_ID, GOAL_ID, _control, _run_cli + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("repeat_decision", [False, True]) +def test_settled_mcp_completion_replays_original_request( + provider, repeat_decision, tmp_path, monkeypatch +): + registry, state, todo_id = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + guard = json.loads(control.should_run()) + identity = guard["delivery_result_context"]["settlement_identity"] + intent = {"next_agent_todo": "Verify remaining fixture work."} + first = json.loads( + control.complete_task(todo_id, AGENT_ID, "Candidate verified.", **intent) + ) + assert first["stage"] == "direction_pending" + decision = { + "read_context_id": first["context"]["read_context_id"], + "vision_unchanged_reason": "The verified candidate leaves remaining fixture work open.", + } + settled = json.loads( + _control(registry).complete_task( + todo_id, AGENT_ID, "Candidate verified.", **intent, **decision + ) + ) + assert settled["ok"] and settled["settlement_complete"] + assert settled["settlement_identity"] == identity + _, before = _run_cli( + registry, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo_id + ) + index = tmp_path / "runtime" / "goals" / GOAL_ID / "runs" / "index.jsonl" + original_index = index.read_bytes() + replay = json.loads( + _control(registry).complete_task( + todo_id, + AGENT_ID, + "Candidate verified.", + **intent, + **(decision if repeat_decision else {}), + ) + ) + assert replay.get("ok") and replay.get("settlement_complete"), replay + assert replay["settlement_identity"] == identity + _, after = _run_cli( + registry, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo_id + ) + assert after["todo"] == before["todo"] + assert index.read_bytes() == original_index + assert ( + sum( + json.loads(line).get("classification") == "quota_slot_spent" + for line in index.read_text().splitlines() + ) + == 1 + ) + foreign = json.loads( + _control(registry).complete_task( + todo_id, "foreign-agent", "Candidate verified.", **intent, **decision + ) + ) + assert foreign.get("ok") is False + assert index.read_bytes() == original_index + if provider != "legacy": + changed = json.loads( + _control(registry).complete_task( + todo_id, AGENT_ID, "Changed completion evidence.", **intent, **decision + ) + ) + assert changed.get("ok") is False + assert changed.get("error_code") == "coordination_operation_identity_mismatch" + assert index.read_bytes() == original_index + _, unchanged = _run_cli( + registry, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo_id + ) + assert unchanged["todo"] == before["todo"] From 02b3e8c1f70989dd069ad8f991ad656760355480 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 19:34:33 +0800 Subject: [PATCH 10/80] fix(chat): exclude lock diagnostics from request journals Signed-off-by: Tartar --- .../extensions/lark/private_conversations.py | 7 +- tests/test_external_request_sidecars.py | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/test_external_request_sidecars.py diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index b168443b41..dc8cf076dd 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -221,7 +221,8 @@ def profiles(self) -> dict[str, dict[str, str]]: def health(self) -> dict[str, dict[str, int]]: health: dict[str, dict[str, int]] = {} - for path in [*self.root.glob("*.json"), *self.root.glob("web-projections/*.json")]: + for path in [*self.root.glob("[0-9a-f]" * 24 + ".json"), + *self.root.glob("web-projections/" + "[0-9a-f]" * 24 + ".json")]: row = _read_json(path) entry = health.setdefault(row["binding_id"], {"pending_count": 0, "recovery_count": 0}) if row["status"] != "delivered": @@ -1272,7 +1273,7 @@ def inbox() -> Path: def pending_delivery_paths(self) -> list[Path]: """The durable transport store is the queue; no in-memory admission.""" paths = [] - for path in sorted(self.root.glob("*.json")): + for path in sorted(self.root.glob("[0-9a-f]" * 24 + ".json")): row = _read_json(path) if row["status"] != "delivered": paths.append(path) @@ -1300,7 +1301,7 @@ def _web_projection_paths(self) -> list[Path]: enabled = {row["binding_id"] for row in self.bindings.read()["bindings"] if row.get("enabled") is True and row.get("audience", "owner") == "owner"} sources: dict[str, dict[str, Any]] = {} - for path in sorted(self.root.glob("*.json")): + for path in sorted(self.root.glob("[0-9a-f]" * 24 + ".json")): row = _read_json(path) if (row.get("binding_id") in enabled and row.get("session_id") and row.get("event", {}).get("chat_type") == "p2p" diff --git a/tests/test_external_request_sidecars.py b/tests/test_external_request_sidecars.py new file mode 100644 index 0000000000..13fb578902 --- /dev/null +++ b/tests/test_external_request_sidecars.py @@ -0,0 +1,77 @@ +"""Request journals must not parse durable lock diagnostics as business rows.""" + +import json +import pytest +from loopx.capabilities.native_chat.external_conversations import ( + ChatExternalConversations, +) +from loopx.extensions.lark.private_conversations import LarkPrivateConversations +from loopx.file_lock import exclusive_file_lock +from test_chat_ordinary_project import ordinary # noqa: F401 +from test_lark_private_conversations import connect + + +@pytest.mark.parametrize("reader", ["core", "delivery", "health"]) +def test_request_readers_ignore_lock_diagnostics_and_keep_business_rows( + tmp_path, reader +): + request = tmp_path / ("a" * 24 + ".json") + row = { + "request_ref": "a" * 24, + "binding_id": "b" * 24, + "status": "accepted", + "deliveries": {}, + } + request.write_text(json.dumps(row), encoding="utf-8") + # A retained Windows sidecar is also readable on a later POSIX checkout. + request.with_name(request.name + ".lock.holder.json").write_text( + json.dumps({"schema_version": "file_lock_holder_v0", "pid": 1}), + encoding="utf-8", + ) + transport = object.__new__(LarkPrivateConversations) + transport.root = tmp_path + core = object.__new__(ChatExternalConversations) + core.root = tmp_path + with exclusive_file_lock(request, operation="request_journal_regression"): + if reader == "core": + assert core.pending() == [row] + elif reader == "delivery": + assert transport.pending_delivery_paths() == [request] + else: + assert transport.health() == { + "b" * 24: {"pending_count": 1, "recovery_count": 0} + } + assert ( + request.exists() + and request.with_name(request.name + ".lock.holder.json").exists() + ) + + +def test_private_status_admission_reconciles_original_native_request_with_sidecars( + ordinary, # noqa: F811 +): # noqa: F811 + store, runtime, provider, transport = connect(ordinary) + try: + event = provider.event("notes-app", "sidecar_status", "/status") + assert transport.admit("steward-app", event)["status"] == "audience_rejected" + assert transport.core.pending() == [] + admitted = transport.admit("notes-app", event) + assert admitted["status"] == "command_recorded" + requests = transport.core.pending() + assert len(requests) == 1 and requests[0]["command"] == "status" + original = requests[0]["request_ref"] + assert transport.health()[requests[0]["binding_id"]]["pending_count"] == 1 + assert transport.reconcile() == 1 + assert transport.pending_delivery_paths() == [] + assert ( + len(transport.core.pending()) == 1 + and transport.core.pending()[0]["request_ref"] == original + ) + assert store.list_sessions() == [] + before = list(provider.writes) + assert transport.reconcile() == 0 and provider.writes == before + assert not provider.calls or all( + "app-server" not in call for call in provider.calls + ) + finally: + runtime.close() From 7c484effe8831df8fe47240dd199316e2e562d3e Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 14 Oct 2026 21:01:28 +0800 Subject: [PATCH 11/80] fix(lark): support Windows private JSON long paths Reuse the existing extended-path helper for private atomic JSON writes. Cover long temporary and destination paths plus interrupted replacement, and launch app-server test fixtures through Python on Windows while retaining their assertions. Signed-off-by: Tartar --- .../test_lark_goal_topic_runtime.py | 531 +++++++++++++----- tests/test_chat_ordinary_project.py | 385 ++++++++++--- tests/test_windows_atomic_writes.py | 62 +- 3 files changed, 748 insertions(+), 230 deletions(-) diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index 667102e1b6..e7da10552c 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -178,10 +178,14 @@ def run(args: list[str]) -> dict[str, Any]: if "--content" in args: state["reply_content"] = args[args.index("--content") + 1] state["reply_type"] = "post" - state["reply_text"] = json.loads(state["reply_content"])["zh_cn"]["content"][0][0]["text"] + state["reply_text"] = json.loads(state["reply_content"])["zh_cn"][ + "content" + ][0][0]["text"] else: state["reply_text"] = args[args.index("--text") + 1] - state["reply_content"] = json.dumps({"text": state["reply_text"]}, ensure_ascii=False) + state["reply_content"] = json.dumps( + {"text": state["reply_text"]}, ensure_ascii=False + ) state["reply_type"] = "text" payload = ( { @@ -189,7 +193,7 @@ def run(args: list[str]) -> dict[str, Any]: { "body": { "msg_type": state["reply_type"], - "content": state["reply_content"] + "content": state["reply_content"], } } ] @@ -352,9 +356,7 @@ def answer(route: Any, text: str) -> str: common = { "target_payload": read_goal_channel_targets(target_path), - "binding_payloads": { - "goal-alpha": read_goal_channel_binding(binding_path) - }, + "binding_payloads": {"goal-alpha": read_goal_channel_binding(binding_path)}, "runtime_root": tmp_path / "runtime", "answer": answer, } @@ -433,9 +435,12 @@ def answer(route: Any, text: str) -> str: "sender_id": "ou_owner_fixture", } ] - assert inspect_lark_event_inbox( - project=common["runtime_root"], config_path=replied["inbox_config_ref"] - )["pending_count"] == 0 + assert ( + inspect_lark_event_inbox( + project=common["runtime_root"], config_path=replied["inbox_config_ref"] + )["pending_count"] + == 0 + ) @pytest.mark.parametrize("authority_mode", [None, "future_mode"]) @@ -562,9 +567,7 @@ def answer(route: Mapping[str, Any], _text: str) -> str: result = runtime.process_lark_goal_topic_event( target_payload=read_goal_channel_targets(target_path), - binding_payloads={ - "goal-alpha": read_goal_channel_binding(binding_path) - }, + binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, event={ "event_id": "evt_current_authorized", "message_id": "om_current_authorized", @@ -590,9 +593,7 @@ def answer(route: Mapping[str, Any], _text: str) -> str: ) assert provider_calls assert all("reactions" not in call for call in provider_calls) - config = json.loads( - (tmp_path / "runtime" / result["inbox_config_ref"]).read_text() - ) + config = json.loads((tmp_path / "runtime" / result["inbox_config_ref"]).read_text()) assert config["topic_root_message_id"] == "om_topic_alpha" projection = inspect_lark_event_inbox( project=tmp_path / "runtime", @@ -715,7 +716,10 @@ def answer(route: dict[str, Any], _text: str) -> dict[str, Any]: assert result["ok"] is True assert result["status"] == "replied_and_acknowledged", result - assert state["reply_text"] == "work completed\n\n• Evidence verified\n• Next step recorded" + assert ( + state["reply_text"] + == "work completed\n\n• Evidence verified\n• Next step recorded" + ) projection = inspect_lark_event_inbox( project=tmp_path / "runtime", config_path=Path(result["inbox_config_ref"]), @@ -1271,7 +1275,9 @@ def test_alias_consumer_routes_all_chats_of_its_bot_app() -> None: "oc_first", "oc_second", ] - snapshot["target_payload"]["targets"]["canonical"]["channel"]["chat_id"] = "oc_second" + snapshot["target_payload"]["targets"]["canonical"]["channel"]["chat_id"] = ( + "oc_second" + ) for profile in ("canonical", "alias"): snapshot["binding_payloads"][profile]["bindings"][profile]["topic"] = { "root_message_id": f"om_{profile}" @@ -1723,7 +1729,7 @@ def test_profile_stream_dispatches_only_team_plan_callbacks_for_bound_chats( "target_ref": "mew-second", } } - } + }, }, } callback = { @@ -1783,7 +1789,9 @@ def handle_callback(event: Mapping[str, Any]) -> dict[str, bool]: assert result["ok"] is True assert len(captured_args) == 2 - callback_args = next(args for args in captured_args if "card.action.trigger" in args) + callback_args = next( + args for args in captured_args if "card.action.trigger" in args + ) assert ( 'select(.chat_id == "oc_public_fixture" or ' '.chat_id == "oc_second_fixture")' in callback_args @@ -1888,12 +1896,21 @@ def kill(self): @pytest.mark.parametrize( "exit_reason,stop_requested,returncode", - [("timeout", False, 0), ("limit", False, 0), ("signal", False, 0), - ("unknown", False, 0), ("", False, 0), ("signal", True, 0), - ("timeout", False, 1)], + [ + ("timeout", False, 0), + ("limit", False, 0), + ("signal", False, 0), + ("unknown", False, 0), + ("", False, 0), + ("signal", True, 0), + ("timeout", False, 1), + ], ) def test_profile_stream_waits_for_provider_ready_before_reporting_listening( - tmp_path: Path, exit_reason: str, stop_requested: bool, returncode: int, + tmp_path: Path, + exit_reason: str, + stop_requested: bool, + returncode: int, ) -> None: from loopx.extensions.lark.goal_topic_runtime import stream_lark_goal_topic_profile @@ -1964,15 +1981,23 @@ def kill(self) -> None: health_sink=lambda update: health.append(dict(update)), ) - planned = stop_requested or (exit_reason in {"timeout", "limit"} and returncode == 0) + planned = stop_requested or ( + exit_reason in {"timeout", "limit"} and returncode == 0 + ) assert result == { "ok": planned, - "status": "stopped" if stop_requested else "stream_ended" if planned else "source_disconnected", + "status": "stopped" + if stop_requested + else "stream_ended" + if planned + else "source_disconnected", **({} if planned else {"error_code": "lark_event_source_disconnected"}), "event_count": 0, "replied_count": 0, } - assert [item["status"] for item in health] == (["starting"] if stop_requested else ["starting", "listening"]) + assert [item["status"] for item in health] == ( + ["starting"] if stop_requested else ["starting", "listening"] + ) def test_profile_poll_routes_provider_event_through_existing_reply_path( @@ -2071,7 +2096,10 @@ def provider_runner( assert state["consume_args"][:3] == ["fake-lark", "--profile", "mew"] assert "event" in state["consume_args"] projection = state["consume_args"][state["consume_args"].index("--jq") + 1] - assert "message_id:(.message_id // .message.message_id // .event.message.message_id // .id)" in projection + assert ( + "message_id:(.message_id // .message.message_id // .event.message.message_id // .id)" + in projection + ) assert "root_id:(.root_id // .message.root_id" in projection assert "parent_id:(.parent_id // .reply_to" in projection assert "thread_id" in projection @@ -2448,11 +2476,15 @@ def answer_spy(_route: Any, _text: Any) -> str: @pytest.mark.parametrize("reaction_ok", [True, False, None]) -def test_manager_receives_reaction_before_answer_and_preserves_sender(tmp_path, monkeypatch, reaction_ok): +def test_manager_receives_reaction_before_answer_and_preserves_sender( + tmp_path, monkeypatch, reaction_ok +): from loopx.extensions.lark import goal_topic_runtime as runtime + target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" _seed_legacy_topic(target_path, binding_path) original_decide = runtime.decide_lark_topic_event + def manager_decision(**kw): result = original_decide(**kw) result["route"] = { @@ -2462,35 +2494,65 @@ def manager_decision(**kw): "ingress_mode": "session_queue", } return result + monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) state = {} runner = _reply_runner(state) stages = [] if reaction_ok is None: + def unavailable(**kw): raise OSError("private receipt unavailable") - monkeypatch.setattr(runtime, "ensure_lark_event_inbox_received_reaction", unavailable) + + monkeypatch.setattr( + runtime, "ensure_lark_event_inbox_received_reaction", unavailable + ) + def with_reactions(args): if args[3:6] == ["im", "reactions", "create"]: stages.append("received") assert args[args.index("--message-id") + 1] == "om_incoming" - assert json.loads(args[args.index("--data") + 1])["reaction_type"]["emoji_type"] == "Get" - return {"returncode": 0 if reaction_ok else 1, "stdout": json.dumps({"ok": reaction_ok, "data": {"reaction_id": "reaction_fixture"}})} + assert ( + json.loads(args[args.index("--data") + 1])["reaction_type"][ + "emoji_type" + ] + == "Get" + ) + return { + "returncode": 0 if reaction_ok else 1, + "stdout": json.dumps( + {"ok": reaction_ok, "data": {"reaction_id": "reaction_fixture"}} + ), + } if args[3:6] == ["im", "reactions", "delete"]: stages.append("cleanup") return {"returncode": 0, "stdout": json.dumps({"ok": True})} return runner(args) + def answer(route, text): stages.append("answer") assert stages == (["answer"] if reaction_ok is None else ["received", "answer"]) assert route["source_sender_id"] == "ou_owner_fixture" return "Received the original intent." - kwargs = dict(target_payload=read_goal_channel_targets(target_path), - binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - event={"event_id": "evt_incoming", "message_id": "om_incoming", "chat_id": "oc_public_fixture", - "root_id": "om_topic_alpha", "create_time": "2026-08-14T21:00:00Z", - "content": "@linkmacbot forward this", "mentioned": True, "sender_type": "user", "sender_id": "ou_owner_fixture"}, - runtime_root=tmp_path / "runtime", answer=answer, reply_runner=with_reactions) + + kwargs = dict( + target_payload=read_goal_channel_targets(target_path), + binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, + event={ + "event_id": "evt_incoming", + "message_id": "om_incoming", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot forward this", + "mentioned": True, + "sender_type": "user", + "sender_id": "ou_owner_fixture", + }, + runtime_root=tmp_path / "runtime", + answer=answer, + reply_runner=with_reactions, + ) result = runtime.process_lark_goal_topic_event(**kwargs) assert result["ok"], result assert result["status"] == "replied_and_acknowledged" @@ -2498,17 +2560,28 @@ def answer(route, text): if reaction_ok: from loopx.extensions.lark.event_inbox import load_lark_event_inbox_config from loopx.extensions.lark.inbox_reactions import lark_inbox_reaction_receipts + config = load_lark_event_inbox_config( - project=kwargs["runtime_root"], config_path=result["inbox_config_ref"]) + project=kwargs["runtime_root"], config_path=result["inbox_config_ref"] + ) assert config["reply"]["received_reaction_policy"] == "retain" - assert lark_inbox_reaction_receipts( - inbox=config["inbox_path"], message_id="om_incoming")["received"]["emoji_type"] == "Get" + assert ( + lark_inbox_reaction_receipts( + inbox=config["inbox_path"], message_id="om_incoming" + )["received"]["emoji_type"] + == "Get" + ) before = list(stages) - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert stages == before -def test_concurrent_manager_delivery_answers_one_source_message_once(tmp_path, monkeypatch): +def test_concurrent_manager_delivery_answers_one_source_message_once( + tmp_path, monkeypatch +): from loopx.extensions.lark import goal_topic_runtime as runtime target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" @@ -2527,7 +2600,8 @@ def manager_decision(**kwargs): monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) monkeypatch.setattr( - runtime, "ensure_lark_event_inbox_received_reaction", + runtime, + "ensure_lark_event_inbox_received_reaction", lambda **_kwargs: {"ok": True, "status": "already_received"}, ) entered, release = threading.Event(), threading.Event() @@ -2544,10 +2618,14 @@ def answer(_route, _text): target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, event={ - "event_id": "evt_one_source", "message_id": "om_one_source", - "chat_id": "oc_public_fixture", "root_id": "om_topic_alpha", - "create_time": "2026-08-14T21:00:00Z", "content": "@linkmacbot question", - "sender_type": "user", "sender_id": "ou_owner_fixture", + "event_id": "evt_one_source", + "message_id": "om_one_source", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot question", + "sender_type": "user", + "sender_id": "ou_owner_fixture", }, runtime_root=tmp_path / "runtime", answer=answer, @@ -2566,72 +2644,103 @@ def answer(_route, _text): assert second.result(timeout=5)["status"] == "already_acknowledged" assert answers == ["answer"] sends = [ - call for call in state["calls"] + call + for call in state["calls"] if "+messages-reply" in call and "--dry-run" not in call ] assert len(sends) == 1 -@pytest.mark.parametrize("error_code,label", [ - ("cyber_policy", "安全策略拦截"), - ("rate_limit_exceeded", "请求频率限制"), - ("server_overloaded", "当前模型繁忙"), - ("runtime_unavailable", "本地运行环境初始化失败"), - ("resume_failed", "原 Agent 会话恢复失败"), - ("private-upstream-detail", "管家处理失败"), -]) +@pytest.mark.parametrize( + "error_code,label", + [ + ("cyber_policy", "安全策略拦截"), + ("rate_limit_exceeded", "请求频率限制"), + ("server_overloaded", "当前模型繁忙"), + ("runtime_unavailable", "本地运行环境初始化失败"), + ("resume_failed", "原 Agent 会话恢复失败"), + ("private-upstream-detail", "管家处理失败"), + ], +) @pytest.mark.parametrize("reply_ok", [True, False]) def test_manager_terminal_failure_replies_once_before_ack( - tmp_path, monkeypatch, error_code, label, reply_ok, + tmp_path, + monkeypatch, + error_code, + label, + reply_ok, ): from loopx.extensions.lark import goal_topic_runtime as runtime target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" _seed_legacy_topic(target_path, binding_path) original_decide = runtime.decide_lark_topic_event + def manager_decision(**kw): result = original_decide(**kw) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kw["event"]["event_id"], connector={"response_policy": "topic_reply"}, ) return result + monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) - monkeypatch.setattr(runtime, "ensure_lark_event_inbox_received_reaction", - lambda **kw: {"ok": True, "status": "already_received"}) + monkeypatch.setattr( + runtime, + "ensure_lark_event_inbox_received_reaction", + lambda **kw: {"ok": True, "status": "already_received"}, + ) state = {} runner = _reply_runner(state) calls = [] + def answer(route, text): calls.append(text) - raise runtime.LarkGoalTopicTurnFailed(error_code, runtime._session_turn_effect(route)) + raise runtime.LarkGoalTopicTurnFailed( + error_code, runtime._session_turn_effect(route) + ) + def reply(args): if not reply_ok and "+messages-reply" in args and "--dry-run" not in args: return {"returncode": 1, "stdout": "", "stderr": "private-transport-detail"} return runner(args) + kwargs = dict( target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - event={"event_id": "evt_incoming", "message_id": "om_incoming", - "chat_id": "oc_public_fixture", "root_id": "om_topic_alpha", - "create_time": "2026-08-14T21:00:00Z", "content": "@linkmacbot report", - "mentioned": True, "sender_type": "user"}, - runtime_root=tmp_path / "runtime", answer=answer, reply_runner=reply, + event={ + "event_id": "evt_incoming", + "message_id": "om_incoming", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot report", + "mentioned": True, + "sender_type": "user", + }, + runtime_root=tmp_path / "runtime", + answer=answer, + reply_runner=reply, ) result = runtime.process_lark_goal_topic_event(**kwargs) assert result["ok"] is False assert label in state["reply_text"] assert "private-" not in state["reply_text"] + str(result) assert "不会自动重放" in state["reply_text"] - pending = inspect_lark_event_inbox(project=kwargs["runtime_root"], - config_path=Path(result["inbox_config_ref"])) + pending = inspect_lark_event_inbox( + project=kwargs["runtime_root"], config_path=Path(result["inbox_config_ref"]) + ) if reply_ok: assert result["status"] == "processing_failed" assert result["failure_reply_verified"] and result["source_acknowledged"] assert pending["items"] == [] - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert len(calls) == 1 else: assert any(x["message_id"] == "om_incoming" for x in pending["items"]) @@ -2640,7 +2749,9 @@ def reply(args): @pytest.mark.parametrize("answer_value", ["raise", "empty"]) def test_manager_untyped_or_empty_answer_gets_bounded_failure_receipt( - tmp_path, monkeypatch, answer_value, + tmp_path, + monkeypatch, + answer_value, ): from loopx.extensions.lark import goal_topic_runtime as runtime @@ -2651,7 +2762,8 @@ def test_manager_untyped_or_empty_answer_gets_bounded_failure_receipt( def manager_decision(**kw): result = original_decide(**kw) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kw["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -2704,7 +2816,10 @@ def answer(route, text): config_path=Path(result["inbox_config_ref"]), ) assert pending["items"] == [] - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert len(answer_calls) == 1 @@ -2729,7 +2844,10 @@ def answer(route, text): ) @pytest.mark.parametrize("reply_ok", [True, False]) def test_manager_report_delivery_recovers_safe_format_and_keeps_pending_body( - tmp_path, monkeypatch, body, reply_ok, + tmp_path, + monkeypatch, + body, + reply_ok, ): from loopx.extensions.lark import goal_topic_runtime as runtime from loopx.extensions.lark.inbox_reply import send_lark_inbox_message @@ -2742,7 +2860,8 @@ def test_manager_report_delivery_recovers_safe_format_and_keeps_pending_body( def manager_decision(**kw): result = original_decide(**kw) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kw["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -2750,14 +2869,20 @@ def manager_decision(**kw): return result monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) - monkeypatch.setattr(runtime, "ensure_lark_event_inbox_received_reaction", - lambda **kw: {"ok": True, "status": "already_received"}) + monkeypatch.setattr( + runtime, + "ensure_lark_event_inbox_received_reaction", + lambda **kw: {"ok": True, "status": "already_received"}, + ) state, answered = {}, [] runner = _reply_runner(state) def answer(route, text): answered.append(text) - return {"response_text": body, "effect_receipt": runtime._session_turn_effect(route)} + return { + "response_text": body, + "effect_receipt": runtime._session_turn_effect(route), + } def reply(args): if not reply_ok and "+messages-reply" in args and "--dry-run" not in args: @@ -2767,11 +2892,19 @@ def reply(args): kwargs = dict( target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - event={"event_id": "evt_incoming", "message_id": "om_incoming", - "chat_id": "oc_public_fixture", "root_id": "om_topic_alpha", - "create_time": "2026-08-14T21:00:00Z", "content": "@linkmacbot report", - "mentioned": True, "sender_type": "user"}, - runtime_root=tmp_path / "runtime", answer=answer, reply_runner=reply, + event={ + "event_id": "evt_incoming", + "message_id": "om_incoming", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", + "content": "@linkmacbot report", + "mentioned": True, + "sender_type": "user", + }, + runtime_root=tmp_path / "runtime", + answer=answer, + reply_runner=reply, ) result = runtime.process_lark_goal_topic_event(**kwargs) if body == r"private\nformat": @@ -2811,7 +2944,10 @@ def reply(args): config_path=Path(result["inbox_config_ref"])) if reply_ok: assert pending["items"] == [] - assert runtime.process_lark_goal_topic_event(**kwargs)["status"] == "already_acknowledged" + assert ( + runtime.process_lark_goal_topic_event(**kwargs)["status"] + == "already_acknowledged" + ) assert len(answered) == 1 else: assert any(x["message_id"] == "om_incoming" for x in pending["items"]) @@ -2820,12 +2956,16 @@ def reply(args): assert len(answered) == 1 # Root notifications retain their existing compact limit. with pytest.raises(LarkOutboundTextError): - send_lark_inbox_message(project=kwargs["runtime_root"], - config_path=result["inbox_config_ref"], text="x" * 1201) + send_lark_inbox_message( + project=kwargs["runtime_root"], + config_path=result["inbox_config_ref"], + text="x" * 1201, + ) def test_manager_delivery_reuses_saved_answer_after_transport_restart( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): from loopx.extensions.lark import goal_topic_runtime as runtime @@ -2880,9 +3020,7 @@ def unavailable_runner(args): kwargs = { "target_payload": read_goal_channel_targets(target_path), - "binding_payloads": { - "goal-alpha": read_goal_channel_binding(binding_path) - }, + "binding_payloads": {"goal-alpha": read_goal_channel_binding(binding_path)}, "event": event, "runtime_root": tmp_path / "runtime", "answer": answer, @@ -2918,9 +3056,7 @@ def unavailable_runner(args): assert second_state["reply_text"] == "完整答复\n@LoopX 管家仅为显示文本" assert json.loads(delivery_files[0].read_text())["status"] == "sent_verified" - monkeypatch.setattr( - runtime, "acknowledge_lark_event_inbox", original_acknowledge - ) + monkeypatch.setattr(runtime, "acknowledge_lark_event_inbox", original_acknowledge) third_state = {} third_runner = _reply_runner(third_state) @@ -2939,9 +3075,12 @@ def no_duplicate_send(args): receipt = json.loads(delivery_files[0].read_text()) assert receipt["status"] == "acknowledged" assert "delivery_text" not in receipt - assert inspect_lark_event_inbox( - project=kwargs["runtime_root"], config_path=Path(second["inbox_config_ref"]) - )["items"] == [] + assert ( + inspect_lark_event_inbox( + project=kwargs["runtime_root"], config_path=Path(second["inbox_config_ref"]) + )["items"] + == [] + ) def test_manager_retries_saved_proposal_delivery_before_source_ack( @@ -2989,9 +3128,7 @@ def answer(_route: Mapping[str, Any], text: str) -> dict[str, Any]: delivery_calls: list[tuple[str, ...]] = [] - def deliver( - route: Mapping[str, Any], proposal_ids: list[str] - ) -> dict[str, Any]: + def deliver(route: Mapping[str, Any], proposal_ids: list[str]) -> dict[str, Any]: assert route["source_sender_id"] == "ou_owner_fixture" delivery_calls.append(tuple(proposal_ids)) if len(delivery_calls) == 1: @@ -3010,9 +3147,7 @@ def deliver( first_state: dict[str, Any] = {} kwargs = { "target_payload": read_goal_channel_targets(target_path), - "binding_payloads": { - "goal-alpha": read_goal_channel_binding(binding_path) - }, + "binding_payloads": {"goal-alpha": read_goal_channel_binding(binding_path)}, "event": event, "runtime_root": tmp_path / "runtime", "answer": answer, @@ -3046,7 +3181,8 @@ def no_duplicate_reply(args: list[str]) -> dict[str, Any]: def test_a_fully_sent_part_sequence_settles_after_an_interrupted_receipt_write( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """The reader already has every part; the delivery must stop saying pending. @@ -3069,7 +3205,8 @@ def test_a_fully_sent_part_sequence_settles_after_an_interrupted_receipt_write( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3123,7 +3260,8 @@ def interrupted_receipt_write(path, payload): assert first["source_acknowledged"] is False config_path = Path(first["inbox_config_ref"]) state_path = delivery_path( - project=kwargs["runtime_root"], config_path=config_path, + project=kwargs["runtime_root"], + config_path=config_path, message_id="om_incoming", ) saved = json.loads(state_path.read_text()) @@ -3150,7 +3288,8 @@ def interrupted_receipt_write(path, payload): assert second["saved_response_reused"] is True assert len(answered) == 1 assert [ - call for call in state["calls"] + call + for call in state["calls"] if "+messages-reply" in call and "--dry-run" not in call ] == delivered_once settled = json.loads(state_path.read_text()) @@ -3163,7 +3302,8 @@ def interrupted_receipt_write(path, payload): def test_a_part_whose_readback_failed_is_reconciled_instead_of_sent_twice( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """An ambiguous part send must be confirmed, not repeated. @@ -3184,7 +3324,8 @@ def test_a_part_whose_readback_failed_is_reconciled_instead_of_sent_twice( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3242,7 +3383,8 @@ def ambiguous_runner(args: list[str]) -> dict[str, Any]: assert first["status"] == "reply_delivery_pending" config_path = Path(first["inbox_config_ref"]) state_path = delivery_path( - project=kwargs["runtime_root"], config_path=config_path, + project=kwargs["runtime_root"], + config_path=config_path, message_id="om_incoming", ) saved = json.loads(state_path.read_text()) @@ -3276,7 +3418,8 @@ def ambiguous_runner(args: list[str]) -> dict[str, Any]: def test_a_long_manager_answer_is_delivered_as_one_message( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """A 3000-character steward answer is one message, not a bounded sequence. @@ -3298,7 +3441,8 @@ def test_a_long_manager_answer_is_delivered_as_one_message( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3362,7 +3506,8 @@ def manager_decision(**kwargs): def test_a_stalled_part_sequence_tells_the_reader_what_was_delivered( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): """A partly delivered answer must not look like a complete one. @@ -3382,7 +3527,8 @@ def test_a_stalled_part_sequence_tells_the_reader_what_was_delivered( def manager_decision(**kwargs): result = original_decide(**kwargs) result["route"].update( - conversation_kind="manager", ingress_mode="session_queue", + conversation_kind="manager", + ingress_mode="session_queue", authority_mode="turn_authorized", event_id=kwargs["event"]["event_id"], connector={"response_policy": "topic_reply"}, @@ -3466,7 +3612,8 @@ def delivered_texts() -> list[str]: saved = json.loads( delivery_path( - project=kwargs["runtime_root"], config_path=config_path, + project=kwargs["runtime_root"], + config_path=config_path, message_id="om_incoming", ).read_text() ) @@ -3478,7 +3625,10 @@ def delivered_texts() -> list[str]: @pytest.mark.parametrize("manager", [False, True]) @pytest.mark.parametrize("thread", [False, True]) def test_quoted_reply_reaches_real_chat_store_and_protocol_turn( - tmp_path: Path, manager: bool, thread: bool, + tmp_path: Path, + manager: bool, + thread: bool, + monkeypatch: Any, ) -> None: """Real inbox/controller/store; only the external provider/model are fixtures.""" import runpy @@ -3486,25 +3636,39 @@ def test_quoted_reply_reaches_real_chat_store_and_protocol_turn( from loopx.chat_runtime import ChatRuntimeController from loopx.chat_store import ChatSessionStore - from loopx.extensions.lark.event_collector_runtime import enrich_lark_event_reply_context - from loopx.extensions.lark.goal_topic_runtime import answer_lark_goal_topic, process_lark_goal_topic_event + from loopx.extensions.lark.event_collector_runtime import ( + enrich_lark_event_reply_context, + ) + from loopx.extensions.lark.goal_topic_runtime import ( + answer_lark_goal_topic, + process_lark_goal_topic_event, + ) executable = tmp_path / "codex" - fixture = runpy.run_path(str(Path(__file__).parents[2] / "examples/loopx-chat-runtime-smoke.py")) + fixture = runpy.run_path( + str(Path(__file__).parents[2] / "examples/loopx-chat-runtime-smoke.py") + ) executable.write_text(fixture["FAKE_CODEX"], encoding="utf-8") executable.chmod(executable.stat().st_mode | stat.S_IXUSR) + from test_chat_ordinary_project import _python_app_server_fixture + + _python_app_server_fixture(monkeypatch, executable) store = ChatSessionStore(tmp_path / "chat") controller = ChatRuntimeController(store=store, codex_bin=str(executable)) parent_text = "Dependency review is ready; the draft card has not been approved." messages = { "om_question": { - "message_id": "om_question", "chat_id": "oc_public_fixture", - "content": "Where is the card?", "root_id": "om_topic_alpha", - "parent_id": "om_parent", "mentions": [], + "message_id": "om_question", + "chat_id": "oc_public_fixture", + "content": "Where is the card?", + "root_id": "om_topic_alpha", + "parent_id": "om_parent", + "mentions": [], "sender": {"sender_type": "user", "id": "ou_owner_fixture"}, }, "om_parent": { - "message_id": "om_parent", "chat_id": "oc_public_fixture", + "message_id": "om_parent", + "chat_id": "oc_public_fixture", "body": {"content": json.dumps({"text": parent_text})}, "sender": {"sender_type": "app", "id": "cli_public_fixture"}, }, @@ -3515,27 +3679,53 @@ def test_quoted_reply_reaches_real_chat_store_and_protocol_turn( question = messages["om_question"] question.pop("parent_id") question.pop("root_id") - question.update(thread_id="omt_fixture", thread_message_position="2", - mentions=[{"name": "linkmacbot", "id": "cli_public_fixture"}]) - root = {"message_id": "om_topic_alpha", "chat_id": "oc_public_fixture", - "thread_id": "omt_fixture", "thread_message_position": "-1", - "content": "Review the initial draft."} - revised = {**root, "message_id": "om_revision", "thread_message_position": "1", - "content": parent_text} + question.update( + thread_id="omt_fixture", + thread_message_position="2", + mentions=[{"name": "linkmacbot", "id": "cli_public_fixture"}], + ) + root = { + "message_id": "om_topic_alpha", + "chat_id": "oc_public_fixture", + "thread_id": "omt_fixture", + "thread_message_position": "-1", + "content": "Review the initial draft.", + } + revised = { + **root, + "message_id": "om_revision", + "thread_message_position": "1", + "content": parent_text, + } messages["om_topic_alpha"] = {**root, "thread_replies": [revised, question]} def provider(argv: list[str], **_kwargs: Any): mid = argv[argv.index("--message-ids") + 1] - return subprocess.CompletedProcess(argv, 0, json.dumps({ - "ok": True, "data": {"messages": [messages[mid]]}, - }), "") + return subprocess.CompletedProcess( + argv, + 0, + json.dumps( + { + "ok": True, + "data": {"messages": [messages[mid]]}, + } + ), + "", + ) event = enrich_lark_event_reply_context( - {"schema_version": "lark_event_inbox_event_v0", "message_id": "om_question", - "event_id": "evt_question", "chat_id": "oc_public_fixture", - **({"root_id": "om_topic_alpha"} if thread else {})}, - runner=provider, command_prefix=["lark-cli"], profile="mew", - profile_app_id="cli_public_fixture", configured_chat_id="oc_public_fixture", + { + "schema_version": "lark_event_inbox_event_v0", + "message_id": "om_question", + "event_id": "evt_question", + "chat_id": "oc_public_fixture", + **({"root_id": "om_topic_alpha"} if thread else {}), + }, + runner=provider, + command_prefix=["lark-cli"], + profile="mew", + profile_app_id="cli_public_fixture", + configured_chat_id="oc_public_fixture", ) target_path, binding_path = tmp_path / "targets.json", tmp_path / "binding.json" _seed_legacy_topic(target_path, binding_path) @@ -3544,32 +3734,51 @@ def provider(argv: list[str], **_kwargs: Any): def answer(route: Any, text: str): return answer_lark_goal_topic( route={**route, **({"conversation_kind": "manager"} if manager else {})}, - text=text, work_dir=tmp_path, objective="Inspect dependency review.", + text=text, + work_dir=tmp_path, + objective="Inspect dependency review.", runtime_controller=controller, ) options = dict( target_payload=read_goal_channel_targets(target_path), binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, - runtime_root=tmp_path / "runtime", answer=answer, + runtime_root=tmp_path / "runtime", + answer=answer, reply_runner=_reply_runner(state), ) try: result = process_lark_goal_topic_event(**options, event=event) assert result["status"] == "replied_and_acknowledged" session = store.list_sessions()[0] - user_messages = [r for r in store.messages(session["session_id"]) if r["role"] == "user"] + user_messages = [ + r for r in store.messages(session["session_id"]) if r["role"] == "user" + ] assert len(user_messages) == 1 assert parent_text in user_messages[0]["text"] assert "context only" in user_messages[0]["text"] assert user_messages[0]["text"].endswith("Where is the card?") assert state["reply_text"] == "Runtime response." readback = inspect_lark_event_inbox( - project=tmp_path / "runtime", config_path=result["inbox_config_ref"], limit=0, + project=tmp_path / "runtime", + config_path=result["inbox_config_ref"], + limit=0, ) assert readback["processed_count"] == 1 - assert process_lark_goal_topic_event(**options, event=event)["status"] == "already_acknowledged" - assert len([r for r in store.messages(session["session_id"]) if r["role"] == "user"]) == 1 + assert ( + process_lark_goal_topic_event(**options, event=event)["status"] + == "already_acknowledged" + ) + assert ( + len( + [ + r + for r in store.messages(session["session_id"]) + if r["role"] == "user" + ] + ) + == 1 + ) finally: for session in store.list_sessions(): controller.close_session(session["session_id"]) @@ -3578,18 +3787,42 @@ def answer(route: Any, text: str): def test_message_event_projection_preserves_flat_cli_and_raw_provider_provenance(): import shutil from loopx.extensions.lark.goal_topic_runtime import _EVENT_PROJECTION + jq = shutil.which("jq") if jq is None: pytest.skip("jq runtime is unavailable") - expected = {"event_id": "event_fixture", "message_id": "om_fixture", "sender_id": "ou_fixture", - "sender_type": "user", "chat_id": "oc_fixture", "chat_type": "p2p", "message_type": "text"} + expected = { + "event_id": "event_fixture", + "message_id": "om_fixture", + "sender_id": "ou_fixture", + "sender_type": "user", + "chat_id": "oc_fixture", + "chat_type": "p2p", + "message_type": "text", + } flat = {**expected, "content": "plain rendered text", "create_time": "123"} - nested = {"header": {"event_id": expected["event_id"]}, "event": { - "sender": {"sender_id": {"open_id": expected["sender_id"]}, "sender_type": "user"}, - "message": {key: value for key, value in flat.items() if key not in {"event_id", "sender_id", "sender_type"}}}} + nested = { + "header": {"event_id": expected["event_id"]}, + "event": { + "sender": { + "sender_id": {"open_id": expected["sender_id"]}, + "sender_type": "user", + }, + "message": { + key: value + for key, value in flat.items() + if key not in {"event_id", "sender_id", "sender_type"} + }, + }, + } for payload in [flat, nested]: - completed = subprocess.run([jq, "-c", _EVENT_PROJECTION], input=json.dumps(payload), text=True, - capture_output=True, check=True) + completed = subprocess.run( + [jq, "-c", _EVENT_PROJECTION], + input=json.dumps(payload), + text=True, + capture_output=True, + check=True, + ) row = json.loads(completed.stdout) for key, value in expected.items(): assert row[key] == value diff --git a/tests/test_chat_ordinary_project.py b/tests/test_chat_ordinary_project.py index 316102f0e8..e424820cb6 100644 --- a/tests/test_chat_ordinary_project.py +++ b/tests/test_chat_ordinary_project.py @@ -14,34 +14,70 @@ from loopx.chat_store import ChatSessionStore +def _python_app_server_fixture(monkeypatch, executable): + """Keep the real fixture protocol process runnable on native Windows.""" + import os + import subprocess + import sys + + if os.name != "nt": + return + real_popen = subprocess.Popen + + def popen(command, *args, **kwargs): + if command and str(command[0]) == str(executable): + command = [sys.executable, "-u", str(executable), *command[1:]] + return real_popen(command, *args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", popen) + + @pytest.fixture def ordinary(tmp_path, monkeypatch): workspace = tmp_path / "notes" workspace.mkdir() capture = tmp_path / "requests.jsonl" - source = runpy.run_path(str(Path(__file__).parents[1] / "examples/loopx-chat-runtime-smoke.py"))["FAKE_CODEX"] - source = source.replace(' method = request.get("method")', + source = runpy.run_path( + str(Path(__file__).parents[1] / "examples/loopx-chat-runtime-smoke.py") + )["FAKE_CODEX"] + source = source.replace( + ' method = request.get("method")', f' with open({str(capture)!r}, "a") as output:\n output.write(json.dumps(request) + "\\n")\n' - ' method = request.get("method")') - source = source.replace(' elif method in {"thread/start", "thread/resume"}:', + ' method = request.get("method")', + ) + source = source.replace( + ' elif method in {"thread/start", "thread/resume"}:', ' elif method == "config/read":\n' - ' import pathlib\n' + " import pathlib\n" ' config = pathlib.Path(request["params"]["cwd"]) / "effective-config.json"\n' ' result = {"config": json.loads(config.read_text()) if config.exists() else {}}\n' - ' elif method in {"thread/start", "thread/resume"}:') - source = source.replace(' result = {"thread": {"id": "durable-thread"}}', + ' elif method in {"thread/start", "thread/resume"}:', + ) + source = source.replace( + ' result = {"thread": {"id": "durable-thread"}}', ' result = {"thread": {"id": "durable-thread"},\n' ' "model": request["params"].get("model", "old-model"),\n' - ' "reasoningEffort": request["params"].get("config", {}).get("model_reasoning_effort", "xhigh")}') + ' "reasoningEffort": request["params"].get("config", {}).get("model_reasoning_effort", "xhigh")}', + ) fake = tmp_path / "codex" fake.write_text(source) fake.chmod(0o700) + _python_app_server_fixture(monkeypatch, fake) contexts = ChatProjectContexts([workspace], workspace_grant="workspace_read") store = ChatSessionStore(tmp_path / "runtime") - runtime = ChatRuntimeController(store=store, codex_bin=str(fake), project_contexts=contexts, - registry_path=tmp_path / "no-registry.json") + runtime = ChatRuntimeController( + store=store, + codex_bin=str(fake), + project_contexts=contexts, + registry_path=tmp_path / "no-registry.json", + ) import loopx.chat_manager_context as manager - monkeypatch.setattr(manager, "collect_manager_turn_context", lambda *_, **__: pytest.fail("ordinary Chat must not read portfolio")) + + monkeypatch.setattr( + manager, + "collect_manager_turn_context", + lambda *_, **__: pytest.fail("ordinary Chat must not read portfolio"), + ) server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.chat_store, server.runtime_controller = store, runtime server.runtime_root, server.registry_path = store.root.parent, runtime.registry_path @@ -52,8 +88,12 @@ def ordinary(tmp_path, monkeypatch): def request(path, body=None): connection = http.client.HTTPConnection(*server.server_address, timeout=15) try: - connection.request("GET" if body is None else "POST", path, - None if body is None else json.dumps(body), headers={"Content-Type": "application/json"}) + connection.request( + "GET" if body is None else "POST", + path, + None if body is None else json.dumps(body), + headers={"Content-Type": "application/json"}, + ) response = connection.getresponse() return response.status, json.loads(response.read()) finally: @@ -65,7 +105,9 @@ def request(path, body=None): thread.join(timeout=2) -def test_http_ordinary_project_continues_native_session_without_goal_or_portfolio(ordinary): +def test_http_ordinary_project_continues_native_session_without_goal_or_portfolio( + ordinary, +): store, runtime, contexts, request, capture, fake, workspace = ordinary status, projects = request("/api/chat/projects") assert status == 200 and len(projects["projects"]) == 1 @@ -78,19 +120,46 @@ def test_http_ordinary_project_continues_native_session_without_goal_or_portfoli assert status == 201, opened sid = opened["session_id"] assert opened["goal_id"] is None and opened["session"]["manager_runtime"] is None - assert store.load_session(sid)["project_context"]["workspace_path"] == str(workspace) - for message, rid in [("Name the directory", "first"), ("Repeat the previous name", "follow-up")]: - status, accepted = request(f"/api/chat/sessions/{sid}/turns", {"message": message, "client_turn_id": rid}) + assert store.load_session(sid)["project_context"]["workspace_path"] == str( + workspace + ) + for message, rid in [ + ("Name the directory", "first"), + ("Repeat the previous name", "follow-up"), + ]: + status, accepted = request( + f"/api/chat/sessions/{sid}/turns", + {"message": message, "client_turn_id": rid}, + ) assert status == 202, accepted - assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + assert ( + runtime.wait_for_turn( + session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) assert request("/api/chat/sessions", body)[1]["session_id"] == sid original = store.load_session(sid)["upstream_thread_id"] runtime.close() - restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), codex_bin=str(fake), project_contexts=contexts) + restarted = ChatRuntimeController( + store=ChatSessionStore(store.root.parent), + codex_bin=str(fake), + project_contexts=contexts, + ) try: - resumed, was_resumed = restarted.open_session(goal_id=None, agent_id="codex", work_dir=workspace, - objective="do not substitute caller authority", mode="resume_latest", project_ref=project["project_ref"]) - assert was_resumed and resumed["session_id"] == sid and resumed["upstream_thread_id"] == original + resumed, was_resumed = restarted.open_session( + goal_id=None, + agent_id="codex", + work_dir=workspace, + objective="do not substitute caller authority", + mode="resume_latest", + project_ref=project["project_ref"], + ) + assert ( + was_resumed + and resumed["session_id"] == sid + and resumed["upstream_thread_id"] == original + ) finally: restarted.close() requests = [json.loads(line) for line in capture.read_text().splitlines()] @@ -99,7 +168,10 @@ def test_http_ordinary_project_continues_native_session_without_goal_or_portfoli assert len(resumes) == 1 and resumes[0]["params"]["threadId"] == original assert len([row for row in requests if row.get("method") == "turn/start"]) == 2 assert not any(row.get("method", "").startswith("thread/goal") for row in requests) - assert "Fresh Core evidence" not in capture.read_text() and "None:" not in capture.read_text() + assert ( + "Fresh Core evidence" not in capture.read_text() + and "None:" not in capture.read_text() + ) # The real host context gets answer guidance even on the structured-output # project lane, which does not use the older Chat review prompt. assert "A simple question needs no report template" in capture.read_text() @@ -111,44 +183,92 @@ def test_project_grants_cannot_be_forged_widened_or_reused_after_revocation(ordi store, runtime, contexts, request, _, _, workspace = ordinary ref = contexts.available()[0]["project_ref"] base = {"context_kind": "project", "project_ref": ref} - for forged in [dict(base, goal_id="hidden"), dict(base, goal_id=[]), dict(base, project_ref="b" * 24), - dict(base, workspace_path=str(workspace.parent)), dict(base, grant="write")]: + for forged in [ + dict(base, goal_id="hidden"), + dict(base, goal_id=[]), + dict(base, project_ref="b" * 24), + dict(base, workspace_path=str(workspace.parent)), + dict(base, grant="write"), + ]: assert request("/api/chat/sessions", forged)[0] == 400 sid = request("/api/chat/sessions", base)[1]["session_id"] with pytest.raises(ValueError, match="external audience"): - runtime.enqueue_turn(session_id=sid, client_turn_id="foreign", message="private", work_dir=workspace, - objective="ignored", origin="lark") + runtime.enqueue_turn( + session_id=sid, + client_turn_id="foreign", + message="private", + work_dir=workspace, + objective="ignored", + origin="lark", + ) runtime.project_contexts = ChatProjectContexts([]) - assert request(f"/api/chat/sessions/{sid}/turns", {"message": "read", "client_turn_id": "revoked"})[0] == 400 + assert ( + request( + f"/api/chat/sessions/{sid}/turns", + {"message": "read", "client_turn_id": "revoked"}, + )[0] + == 400 + ) assert store.turn_for_client(sid, "revoked") is None with pytest.raises(ValueError): - store.create_session(goal_id=None, agent_id="codex", adapter_kind="codex_app_server", upstream_thread_id="forged") + store.create_session( + goal_id=None, + agent_id="codex", + adapter_kind="codex_app_server", + upstream_thread_id="forged", + ) def test_project_configuration_change_reaches_resumed_native_turn(ordinary): store, runtime, contexts, request, capture, fake, workspace = ordinary ref = contexts.available()[0]["project_ref"] - _, opened = request("/api/chat/sessions", {"context_kind": "project", "project_ref": ref}) + _, opened = request( + "/api/chat/sessions", {"context_kind": "project", "project_ref": ref} + ) sid = opened["session_id"] original = store.load_session(sid)["upstream_thread_id"] assert runtime.adapters[sid].session.reasoning_effort == "xhigh" runtime.close() # Independent host observation changes between processes. The original # thread still reports xhigh unless the adapter applies that observation. - (workspace / "effective-config.json").write_text(json.dumps({ - "model": "current-project-model", "model_reasoning_effort": "high", - "sandbox_mode": "danger-full-access", - })) - restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), - codex_bin=str(fake), project_contexts=contexts) + (workspace / "effective-config.json").write_text( + json.dumps( + { + "model": "current-project-model", + "model_reasoning_effort": "high", + "sandbox_mode": "danger-full-access", + } + ) + ) + restarted = ChatRuntimeController( + store=ChatSessionStore(store.root.parent), + codex_bin=str(fake), + project_contexts=contexts, + ) try: - resumed, was_resumed = restarted.open_session(goal_id=None, agent_id="codex", - work_dir=workspace, objective="continue", mode="resume_latest", project_ref=ref) + resumed, was_resumed = restarted.open_session( + goal_id=None, + agent_id="codex", + work_dir=workspace, + objective="continue", + mode="resume_latest", + project_ref=ref, + ) assert was_resumed and resumed["session_id"] == sid assert resumed["upstream_thread_id"] == original - turn, _ = restarted.submit_turn(session_id=sid, client_turn_id="after-config-change", - message="Continue the original request.", work_dir=workspace, objective="continue") - assert restarted.wait_for_turn(session_id=sid, turn_id=turn["turn_id"], timeout_sec=10)["status"] == "completed" + turn, _ = restarted.submit_turn( + session_id=sid, + client_turn_id="after-config-change", + message="Continue the original request.", + work_dir=workspace, + objective="continue", + ) + assert ( + restarted.wait_for_turn( + session_id=sid, turn_id=turn["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) finally: restarted.close() requests = [json.loads(line) for line in capture.read_text().splitlines()] @@ -158,26 +278,49 @@ def test_project_configuration_change_reaches_resumed_native_turn(ordinary): turn = next(r for r in requests if r.get("method") == "turn/start")["params"] assert turn["threadId"] == original assert turn["model"] == "current-project-model" and turn["effort"] == "high" - assert len(store.list_sessions()) == 1 and store.load_session(sid)["goal_id"] is None + assert ( + len(store.list_sessions()) == 1 and store.load_session(sid)["goal_id"] is None + ) -@pytest.mark.parametrize("messages", [ - ("Read the linked source and collect it using the project skill.", "Keep the previous source and correct its summary."), - ("Organize this document in the existing notes.", "Preserve the original text and verify your edits."), -]) -def test_writable_project_turns_use_project_skills_without_manager_work_selection(ordinary, messages): +@pytest.mark.parametrize( + "messages", + [ + ( + "Read the linked source and collect it using the project skill.", + "Keep the previous source and correct its summary.", + ), + ( + "Organize this document in the existing notes.", + "Preserve the original text and verify your edits.", + ), + ], +) +def test_writable_project_turns_use_project_skills_without_manager_work_selection( + ordinary, messages +): from loopx.chat_agent import CHAT_REVIEW_CLOSE_TAG, CHAT_REVIEW_OPEN_TAG store, runtime, contexts, request, capture, _, workspace = ordinary contexts.workspace_grant = "workspace_write" ref = contexts.available()[0]["project_ref"] - status, opened = request("/api/chat/sessions", {"context_kind": "project", "project_ref": ref}) + status, opened = request( + "/api/chat/sessions", {"context_kind": "project", "project_ref": ref} + ) assert status == 201 sid = opened["session_id"] for index, message in enumerate(messages): - status, accepted = request(f"/api/chat/sessions/{sid}/turns", {"message": message, "client_turn_id": f"project-{index}"}) + status, accepted = request( + f"/api/chat/sessions/{sid}/turns", + {"message": message, "client_turn_id": f"project-{index}"}, + ) assert status == 202, accepted - assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + assert ( + runtime.wait_for_turn( + session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) requests = [json.loads(line) for line in capture.read_text().splitlines()] starts = [row for row in requests if row.get("method") == "thread/start"] turns = [row for row in requests if row.get("method") == "turn/start"] @@ -194,31 +337,53 @@ def test_writable_project_turns_use_project_skills_without_manager_work_selectio assert "context_delegation catalog" not in prompt assert "A protected_action is only an untrusted proposal" in prompt assert prompt.endswith(f"Operator user message:\n{message}") - envelope = json.loads(prompt.split(CHAT_REVIEW_OPEN_TAG, 1)[1].split(CHAT_REVIEW_CLOSE_TAG, 1)[0]) + envelope = json.loads( + prompt.split(CHAT_REVIEW_OPEN_TAG, 1)[1].split(CHAT_REVIEW_CLOSE_TAG, 1)[0] + ) assert envelope["schema_version"] == "loopx_chat_agent_response_v0" assert envelope["message"] == "" assert envelope["proposals"] == [] - assert all(envelope[field] is None for field in ("protected_action", "goal_draft", "context_handoff", "gate")) + assert all( + envelope[field] is None + for field in ("protected_action", "goal_draft", "context_handoff", "gate") + ) session = store.load_session(sid) assert session["goal_id"] is None and session.get("manager_runtime") is None assert session["project_context"]["grant"] == "workspace_write" assert len(store.list_sessions()) == 1 assert not (workspace / "ACTIVE_GOAL_STATE.md").exists() + + @pytest.mark.parametrize("grant", ["workspace_read", "workspace_write"]) -def test_workspace_only_rejects_other_executor_before_session_creation(ordinary, monkeypatch, grant): +def test_workspace_only_rejects_other_executor_before_session_creation( + ordinary, monkeypatch, grant +): store, runtime, contexts, request, _, _, _ = ordinary contexts.filesystem_scope, contexts.workspace_grant = "workspace_only", grant - monkeypatch.setattr("loopx.chat_endpoint_catalog.shutil.which", - lambda executable: executable if executable == "kiro-cli" else None) - monkeypatch.setattr(runtime, "_start_adapter", lambda **_: pytest.fail("must reject before starting a host")) - status, result = request("/api/chat/sessions", { - "context_kind": "project", "project_ref": contexts.available()[0]["project_ref"], "agent_id": "kiro-cli", - }) + monkeypatch.setattr( + "loopx.chat_endpoint_catalog.shutil.which", + lambda executable: executable if executable == "kiro-cli" else None, + ) + monkeypatch.setattr( + runtime, + "_start_adapter", + lambda **_: pytest.fail("must reject before starting a host"), + ) + status, result = request( + "/api/chat/sessions", + { + "context_kind": "project", + "project_ref": contexts.available()[0]["project_ref"], + "agent_id": "kiro-cli", + }, + ) assert status == 400 and "workspace-only" in result.get("error", "") assert store.list_sessions() == [] -def test_read_only_project_rejects_executor_with_workspace_write_scope(ordinary, monkeypatch): +def test_read_only_project_rejects_executor_with_workspace_write_scope( + ordinary, monkeypatch +): store, runtime, contexts, request, _, _, _ = ordinary class FakeWriteScopedAdapter: @@ -231,23 +396,38 @@ def close_session(self): "loopx.chat_endpoint_catalog.shutil.which", lambda executable: executable if executable == "kiro-cli" else None, ) - kiro_capability = next(row for row in runtime.capabilities() if row["agent_id"] == "kiro-cli") + kiro_capability = next( + row for row in runtime.capabilities() if row["agent_id"] == "kiro-cli" + ) assert kiro_capability["trust_scope"] == "workspace_write" runtime._start_adapter = lambda **_: FakeWriteScopedAdapter() project_ref = contexts.available()[0]["project_ref"] - status, result = request("/api/chat/sessions", { - "context_kind": "project", "project_ref": project_ref, "agent_id": "kiro-cli", - }) + status, result = request( + "/api/chat/sessions", + { + "context_kind": "project", + "project_ref": project_ref, + "agent_id": "kiro-cli", + }, + ) assert status == 400, result assert "read-only" in result.get("error", "").lower() - assert store.latest_session(goal_id=None, agent_id="kiro-cli", channel_id=f"project.{project_ref}") is None + assert ( + store.latest_session( + goal_id=None, agent_id="kiro-cli", channel_id=f"project.{project_ref}" + ) + is None + ) project_context = contexts.available()[0] legacy = store.create_session( - goal_id=None, agent_id="kiro-cli", adapter_kind="acp", + goal_id=None, + agent_id="kiro-cli", + adapter_kind="acp", upstream_thread_id="pre-guard-write-scoped-session", - channel_id=f"project.{project_ref}", project_context=project_context, + channel_id=f"project.{project_ref}", + project_context=project_context, ) status, result = request( f"/api/chat/sessions/{legacy['session_id']}/turns", @@ -272,7 +452,9 @@ def test_retargeted_symlink_does_not_rebind_a_project_grant(tmp_path): def test_default_project_host_grant_is_write_and_read_only_launch_is_enforced(ordinary): - from loopx.capabilities.native_chat.conversation_bindings import ChatConversationBindings + from loopx.capabilities.native_chat.conversation_bindings import ( + ChatConversationBindings, + ) store, runtime, contexts, request, capture, _, workspace = ordinary # The fixture intentionally launched read-only. The ordinary product default @@ -281,7 +463,13 @@ def test_default_project_host_grant_is_write_and_read_only_launch_is_enforced(or contexts.workspace_grant = "workspace_write" status, projects = request("/api/chat/projects") assert status == 200 and projects["projects"][0]["grant"] == "workspace_write" - _, opened = request("/api/chat/sessions", {"context_kind": "project", "project_ref": projects["projects"][0]["project_ref"]}) + _, opened = request( + "/api/chat/sessions", + { + "context_kind": "project", + "project_ref": projects["projects"][0]["project_ref"], + }, + ) assert opened["goal_id"] is None assert runtime.adapters[opened["session_id"]].session.sandbox == "workspace-write" # Native thread naming may follow startup; verify the permission-bearing @@ -292,14 +480,34 @@ def test_default_project_host_grant_is_write_and_read_only_launch_is_enforced(or proof = {"transport_ref": "notes-app", "provider_ref": "c" * 24, "operator_ref": "d" * 24, "verified": True} bindings = ChatConversationBindings(root=store.root, project_contexts=contexts, observe=lambda _: proof) contexts.conversation_bindings = bindings - binding = bindings.configure(transport_ref="notes-app", project_ref=projects["projects"][0]["project_ref"], executor_endpoint_id="codex") + binding = bindings.configure( + transport_ref="notes-app", + project_ref=projects["projects"][0]["project_ref"], + executor_endpoint_id="codex", + ) assert binding["grant"] == "workspace_write" contexts.workspace_grant = "workspace_read" - assert request(f"/api/chat/sessions/{opened['session_id']}/turns", {"message": "edit", "client_turn_id": "host-downgraded"})[0] == 400 + assert ( + request( + f"/api/chat/sessions/{opened['session_id']}/turns", + {"message": "edit", "client_turn_id": "host-downgraded"}, + )[0] + == 400 + ) with pytest.raises(ValueError, match="write grant"): - bindings.resolve(binding_id=binding["binding_id"], source_ref="a" * 24, sender_ref=proof["operator_ref"], private_human_message=True) + bindings.resolve( + binding_id=binding["binding_id"], + source_ref="a" * 24, + sender_ref=proof["operator_ref"], + private_human_message=True, + ) with pytest.raises(ValueError, match="workspace grant"): - bindings.configure(transport_ref="notes-app", project_ref=projects["projects"][0]["project_ref"], executor_endpoint_id="codex", project_grant="workspace_write") + bindings.configure( + transport_ref="notes-app", + project_ref=projects["projects"][0]["project_ref"], + executor_endpoint_id="codex", + project_grant="workspace_write", + ) assert bindings.read()["bindings"][0] == binding runtime.close() @@ -312,7 +520,10 @@ def test_local_scope_opens_new_session_after_host_grant_changes(ordinary): assert status == 201 sessions = [original["session_id"]] try: - for grant, sandbox in [("workspace_write", "workspace-write"), ("workspace_read", "read-only")]: + for grant, sandbox in [ + ("workspace_write", "workspace-write"), + ("workspace_read", "read-only"), + ]: contexts.workspace_grant = grant status, opened = request("/api/chat/sessions", body) assert status == 201, opened @@ -320,18 +531,32 @@ def test_local_scope_opens_new_session_after_host_grant_changes(ordinary): assert sid not in sessions and opened["goal_id"] is None assert runtime.adapters[sid].session.sandbox == sandbox assert store.load_session(sessions[-1])["project_context"]["grant"] != grant - status, denied = request(f"/api/chat/sessions/{sessions[-1]}/turns", - {"message": "old permission", "client_turn_id": "stale-grant"}) + status, denied = request( + f"/api/chat/sessions/{sessions[-1]}/turns", + {"message": "old permission", "client_turn_id": "stale-grant"}, + ) assert status == 400 and "grant changed" in denied["error"] assert store.turn_for_client(sessions[-1], "stale-grant") is None assert request("/api/chat/sessions", body)[1]["session_id"] == sid - status, accepted = request(f"/api/chat/sessions/{sid}/turns", - {"message": "Continue under current permission", "client_turn_id": f"current-{grant}"}) + status, accepted = request( + f"/api/chat/sessions/{sid}/turns", + { + "message": "Continue under current permission", + "client_turn_id": f"current-{grant}", + }, + ) assert status == 202, accepted - assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + assert ( + runtime.wait_for_turn( + session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10 + )["status"] + == "completed" + ) sessions.append(sid) requests = [json.loads(line) for line in capture.read_text().splitlines()] - assert len([row for row in requests if row.get("method") == "thread/start"]) == 3 + assert ( + len([row for row in requests if row.get("method") == "thread/start"]) == 3 + ) assert not any(row.get("method") == "thread/resume" for row in requests) assert len(store.list_sessions()) == 3 assert store.messages(sessions[1]) and store.messages(sessions[2]) diff --git a/tests/test_windows_atomic_writes.py b/tests/test_windows_atomic_writes.py index 1cfc8454ad..683deaed9f 100644 --- a/tests/test_windows_atomic_writes.py +++ b/tests/test_windows_atomic_writes.py @@ -59,7 +59,7 @@ def _write_botmux_binding(path: Path) -> None: def _write_native_isolation(path: Path) -> None: - native_codex_isolation._atomic_write_text(path, "prompt = \"ready\"\n") + native_codex_isolation._atomic_write_text(path, 'prompt = "ready"\n') @pytest.mark.parametrize( @@ -122,3 +122,63 @@ def test_atomic_writers_skip_unsupported_windows_directory_fsync( content = target.read_text(encoding="utf-8") actual = json.loads(content) if isinstance(expected, dict) else content assert actual == expected + + +def _long_private_target(tmp_path: Path, *, parent_length: int) -> Path: + parent = tmp_path + while len(str(parent)) < parent_length: + remaining = parent_length - len(str(parent)) - 1 + assert remaining > 0 + component_length = min(40, remaining) + if remaining == 41: + component_length = 39 + parent /= "p" * component_length + assert len(str(parent)) == parent_length + return parent / ("a" * 64 + ".json") + + +@pytest.mark.skipif(os.name != "nt", reason="Native Windows extended-path IO") +@pytest.mark.parametrize("parent_length", [180, 280], ids=["long-temp", "long-target"]) +def test_lark_private_json_long_paths(tmp_path: Path, parent_length: int) -> None: + from loopx.control_plane.runtime.file_paths import windows_extended_path + + target = _long_private_target(tmp_path, parent_length=parent_length) + assert len(str(target)) + 14 > 260 + if parent_length == 180: + assert len(str(target)) < 260 + else: + assert len(str(target)) > 260 + address = windows_extended_path(target) + private_json.write_private_json_atomic(target, {"status": "初次", "count": 1}) + assert json.loads(address.read_text(encoding="utf-8")) == { + "status": "初次", + "count": 1, + } + private_json.write_private_json_atomic(target, {"status": "更新", "count": 2}) + assert json.loads(address.read_text(encoding="utf-8")) == { + "status": "更新", + "count": 2, + } + assert not list(address.parent.glob("*.tmp")) + + +@pytest.mark.skipif(os.name != "nt", reason="Native Windows extended-path IO") +def test_lark_private_json_long_path_interrupted_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from loopx.control_plane.runtime.file_paths import windows_extended_path + + target = _long_private_target(tmp_path, parent_length=180) + address = windows_extended_path(target) + address.parent.mkdir(parents=True, exist_ok=True) + address.write_text('{"status": "original"}\n', encoding="utf-8") + before = address.read_bytes() + + def interrupted_replace(*args: object) -> None: + raise OSError("interrupted replacement") + + monkeypatch.setattr(private_json.os, "replace", interrupted_replace) + with pytest.raises(OSError, match="interrupted replacement"): + private_json.write_private_json_atomic(target, {"status": "replacement"}) + assert address.read_bytes() == before + assert not list(address.parent.glob("*.tmp")) From 24cc8c4653d90b54543c40ab799c83b02ac5e115 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 15 Oct 2026 13:52:34 +0800 Subject: [PATCH 12/80] fix(runtime): preserve long Windows declaration IO Normalize native filesystem addresses at document publication/readback and private declaration storage, retaining ordinary caller paths and existing atomicity and permissions. Cover a real 293-character temporary path, private/public publication, create-only collision and exact text bytes. Targeted source and isolated-wheel File/SQLite checks pass; broad migration compatibility remains unqualified. Signed-off-by: Tartar --- loopx/control_plane/runtime/document_io.py | 3 +++ .../todos/completion_validation_store.py | 3 ++- tests/control_plane/test_document_io.py | 27 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/runtime/document_io.py b/loopx/control_plane/runtime/document_io.py index b1e7defb65..cbfa0077ad 100644 --- a/loopx/control_plane/runtime/document_io.py +++ b/loopx/control_plane/runtime/document_io.py @@ -10,6 +10,7 @@ import json from typing import Any +from .file_paths import windows_extended_path from .private_permissions import protect_private_path import stat import tempfile @@ -21,6 +22,7 @@ def atomic_write_state_text( ) -> None: """Persist complete UTF-8 state while the caller holds its sibling lock.""" + path = windows_extended_path(path) path.parent.mkdir(parents=True, exist_ok=True) if private: protect_private_path(path.parent, directory=True) @@ -62,6 +64,7 @@ def verify_state_text_durable(path: Path, text: str) -> None: Equality of visible bytes alone does not prove the directory entry persisted. The caller holds the same document lock as the state writer. """ + path = windows_extended_path(path) with path.open("r+" if os.name == "nt" else "r", encoding="utf-8", newline="") as handle: if handle.read() != text: raise RuntimeError("Todo Markdown projection readback mismatch") diff --git a/loopx/control_plane/todos/completion_validation_store.py b/loopx/control_plane/todos/completion_validation_store.py index 0932740a22..1454ac5bf9 100644 --- a/loopx/control_plane/todos/completion_validation_store.py +++ b/loopx/control_plane/todos/completion_validation_store.py @@ -10,6 +10,7 @@ from ...registry import read_json from ...file_lock import exclusive_cross_runtime_file_lock from ..runtime.document_io import atomic_write_private_json as atomic_write_json, fsync_state_directory +from ..runtime.file_paths import windows_extended_path from ..runtime.private_permissions import protect_private_path from .completion_validation_projection import ( completion_validation_declaration, @@ -31,7 +32,7 @@ def _require_public_id(value: str, label: str) -> str: def completion_validation_declaration_path( *, runtime_root: Path, goal_id: str, todo_id: str ) -> Path: - return ( + return windows_extended_path( runtime_root.expanduser().resolve(strict=False) / "goals" / _require_public_id(goal_id, "goal_id") diff --git a/tests/control_plane/test_document_io.py b/tests/control_plane/test_document_io.py index 8a4f8e590d..f809534d66 100644 --- a/tests/control_plane/test_document_io.py +++ b/tests/control_plane/test_document_io.py @@ -63,6 +63,33 @@ def test_durable_readback_never_repairs_mismatched_bytes(tmp_path): assert path.read_bytes() == b"original\r\n" +@pytest.mark.skipif(os.name != "nt", reason="Windows ordinary MAX_PATH boundary") +@pytest.mark.parametrize("private", [False, True]) +def test_ordinary_windows_long_path_publish_preserves_bytes_and_create_only(tmp_path, private): + # Include the real .json extension and eight random mkstemp characters. + name = "a" * 64 + ".json" + temporary_name = "." + name + ".12345678.tmp" + padding = 293 - len(str(tmp_path / temporary_name)) - 1 + assert 1 <= padding <= 200 + path = tmp_path / ("g" * padding) / name + assert len(str(path.parent / temporary_name)) == 293 + assert not str(path).startswith(chr(92) * 2 + "?" + chr(92)) + # Inspect the same physical file through Win32's supported long address; + # the writer and durability reader still receive the ordinary caller path. + address = Path(chr(92) * 2 + "?" + chr(92) + str(path)) + original = "私有\r\nfirst\n" + io.atomic_write_state_text(path, original, create_only=True, private=private) + assert address.read_bytes() == original.encode("utf-8") + with pytest.raises(FileExistsError): + io.atomic_write_state_text(path, "loser", create_only=True, private=private) + assert address.read_bytes() == original.encode("utf-8") + updated = original + "tail\r\n" + io.atomic_write_state_text(path, updated, private=private) + io.verify_state_text_durable(path, updated) + assert address.read_bytes() == updated.encode("utf-8") + assert list(address.parent.iterdir()) == [address] + + @pytest.mark.skipif(os.name != "posix", reason="POSIX directory fsync boundary") def test_directory_sync_closes_descriptor_on_failure(tmp_path, monkeypatch): closed = [] From d285e422d18fdabffa2638cc596930e9385370fd Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 15 Oct 2026 14:14:44 +0800 Subject: [PATCH 13/80] fix(migration): preserve long Windows backup IO Use supported native filesystem addresses for hashing and backup copies while retaining logical routes for redirect policy and receipt identity. Recheck native reparse ancestors before copying. Original File/SQLite migration and rollback-safety tests pass in source and a byte-identical isolated wheel, with 32 failure/recovery/junction cases passing in each mode. Full compatibility qualification remains open. Signed-off-by: Tartar --- .../runtime/local_state_migration.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/loopx/control_plane/runtime/local_state_migration.py b/loopx/control_plane/runtime/local_state_migration.py index 4ac75cdb52..863c594331 100644 --- a/loopx/control_plane/runtime/local_state_migration.py +++ b/loopx/control_plane/runtime/local_state_migration.py @@ -28,6 +28,7 @@ _is_redirected_path, ) from ...runtime import validate_goal_id_path_segment +from .file_paths import windows_extended_path LOCAL_STATE_MIGRATION_SCHEMA = "loopx_local_state_migration_v1" @@ -127,19 +128,20 @@ def _write_project_registry(path: Path, payload: dict[str, Any]) -> None: def _digest(path: Path) -> str: """Hash source bytes without traversing redirected files or directories.""" + address = windows_extended_path(path) digest = hashlib.sha256() - if _is_redirected_path(path): + if _is_redirected_path(path) or (address != path and _is_redirected_path(address)): raise ValueError(f"migration source contains a symlink or junction: {path}") - if path.is_file(): + if address.is_file(): digest.update(b"file\0") - with path.open("rb") as stream: + with address.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) - elif path.is_dir(): + elif address.is_dir(): digest.update(b"dir\0") - for child in sorted(path.iterdir(), key=lambda item: item.name): + for child in sorted(address.iterdir(), key=lambda item: item.name): digest.update(child.name.encode("utf-8") + b"\0") - digest.update(_digest(child).encode("ascii")) + digest.update(_digest(path / child.name).encode("ascii")) else: raise FileNotFoundError(path) return digest.hexdigest() @@ -318,6 +320,14 @@ def _copy(source: Path, target: Path) -> None: _require_backup_path(target.parent) target.parent.mkdir(parents=True, exist_ok=True) _require_backup_path(target.parent) + if target.exists() or _is_redirected_path(target): + raise FileExistsError(f"backup snapshot target already exists: {target}") + source, target = windows_extended_path(source), windows_extended_path(target) + # Check the supported native address too, including long reparse ancestors. + _require_unlinked_directory_chain(source.parent, label="migration source") + if _is_redirected_path(source): + raise ValueError(f"migration source is a symlink or junction: {source}") + _require_backup_path(target.parent) if target.exists() or _is_redirected_path(target): raise FileExistsError(f"backup snapshot target already exists: {target}") if source.is_dir(): From 6134895f685d26833585380f6488100bffc04417 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 15 Oct 2026 14:38:12 +0800 Subject: [PATCH 14/80] fix(coordination): support long Windows shadow journal paths Signed-off-by: Tartar --- .../coordination/local_authority_shadow_history.py | 13 +++++++------ .../control_plane/coordination/shadow_management.py | 5 +++-- loopx/file_lock.py | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_shadow_history.py b/loopx/control_plane/coordination/local_authority_shadow_history.py index 73f591a1f6..71b1012243 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_history.py +++ b/loopx/control_plane/coordination/local_authority_shadow_history.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Any +from ..runtime.file_paths import windows_extended_path from ..content_digest import ENVELOPED_SHA256_PATTERN from .local_authority_shadow_projection import PARTITIONS, sha256_digest, text_digest from .coordination_state_contract_generated import ( @@ -94,7 +95,7 @@ def _as_object(value: object) -> dict[str, Any]: def _load_json(path: Path) -> dict[str, Any]: try: - raw = json.loads(path.read_text(encoding="utf-8")) + raw = json.loads(windows_extended_path(path).read_text(encoding="utf-8")) except (ValueError, UnicodeError) as error: raise OutboxError( "outbox_file_invalid", f"{path.name} is not valid JSON" @@ -158,7 +159,7 @@ def _index_entry_files( prepared: dict[_EntryKey, Path] = {} committed: dict[_EntryKey, Path] = {} try: - paths = list(directory.iterdir()) + paths = [directory / path.name for path in windows_extended_path(directory).iterdir()] except FileNotFoundError: return prepared, committed except OSError as error: @@ -166,10 +167,10 @@ def _index_entry_files( "outbox_file_unavailable", "outbox inventory cannot be read" ) from error for path in paths: - if path.name == "drain-cursor.json" and not path.is_symlink(): + if path.name == "drain-cursor.json" and not windows_extended_path(path).is_symlink(): continue match = _ENTRY_FILE.fullmatch(path.name) - if match is None or path.is_symlink() or not path.is_file(): + if match is None or windows_extended_path(path).is_symlink() or not windows_extended_path(path).is_file(): raise OutboxError( "outbox_file_invalid", "outbox contains unclassified evidence" ) @@ -282,7 +283,7 @@ def retired_residue(directory: Path) -> list[Path]: an exact receipt proof before it can be reclaimed. """ - if not directory.is_dir(): + if not windows_extended_path(directory).is_dir(): return [] watermark = _retired_watermark(directory) prepared, committed = _index_entry_files(directory) @@ -357,7 +358,7 @@ def cursor_path(directory: Path) -> Path: def read_cursor(directory: Path) -> dict[str, Any] | None: path = cursor_path(directory) - if path.is_symlink(): + if windows_extended_path(path).is_symlink(): raise OutboxError( "outbox_file_invalid", "cursor must belong to its own partition" ) diff --git a/loopx/control_plane/coordination/shadow_management.py b/loopx/control_plane/coordination/shadow_management.py index c64707a6d6..ae84e4b08f 100644 --- a/loopx/control_plane/coordination/shadow_management.py +++ b/loopx/control_plane/coordination/shadow_management.py @@ -21,6 +21,7 @@ ) from .local_authority_shadow_projection import sha256_digest from ..content_digest import ENVELOPED_SHA256_PATTERN +from ..runtime.file_paths import windows_extended_path SHADOW_CAPTURE_PROFILE = "file_outbox_v1" _DIGEST = ENVELOPED_SHA256_PATTERN @@ -107,7 +108,7 @@ def _binding( def read_shadow_management_state(runtime_root: Path, goal_id: str) -> dict[str, Any] | None: """Validate the closed local journal shape without performing any writes.""" try: - raw = shadow_management_state_path(runtime_root, goal_id).read_text(encoding="utf-8") + raw = windows_extended_path(shadow_management_state_path(runtime_root, goal_id)).read_text(encoding="utf-8") except FileNotFoundError: return None except UnicodeError as exc: @@ -192,7 +193,7 @@ def read_shadow_bootstrap_source_path( directory = hashlib.sha256(operation_id.encode("utf-8")).hexdigest() path = shadow_management_directory(runtime_root, goal_id) / "operations" / directory / "manifest.json" try: - manifest = json.loads(path.read_text(encoding="utf-8")) + manifest = json.loads(windows_extended_path(path).read_text(encoding="utf-8")) expected_manifest_schema = ( SHADOW_MANAGEMENT_EXACT_MANIFEST_SCHEMA if state["schema_version"] == SHADOW_MANAGEMENT_EXACT_STATE_SCHEMA diff --git a/loopx/file_lock.py b/loopx/file_lock.py index 66ca2e1d17..8f301a5997 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -52,7 +52,7 @@ def write_bytes_atomic( untouched; an explicit `mode` is applied with chmod. `preserve_mode` keeps an existing destination's mode and wins over both. """ - destination = Path(path).expanduser() + destination = windows_extended_path(Path(path).expanduser()) destination.parent.mkdir(parents=True, exist_ok=True, mode=directory_mode or 0o777) if preserve_mode and destination.exists(): mode = destination.stat().st_mode & 0o777 From e9daf309dfcf085a3727e7c77de9cc90f93720b5 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 15 Oct 2026 15:10:26 +0800 Subject: [PATCH 15/80] fix(packaging): include retained adapter and runtime assets Signed-off-by: Tartar --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 10eaf4049f..5cf9cf3b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,10 @@ include = ["loopx*"] "loopx.semantics" = ["project_registry_io_manifest_v1.json"] [tool.setuptools.package-data] +"loopx.canary" = ["module_metric_baseline.json"] +"loopx.capabilities.reward_memory" = ["OUTBOUND.md", "OUTBOUND.zh-CN.md"] +"loopx.claude_goal_mode" = [".claude-plugin/plugin.json", "commands/loopx.md", "hooks/hooks.json", "settings.example.json"] +"loopx.control_plane.testing" = ["shadow_drain_fault_process.ts"] "loopx" = ["web/chat/*.html", "web/chat/*.json", "web/chat/assets/*", "web/chat/manifest.webmanifest", "web/chat/pwa/*"] "loopx.control_plane" = ["*.json", "*.ts", "presentation/*.ts"] "loopx.semantics" = ["*.json"] From d10bba09599671d5cfbcb53a4f4026a4c3c445ba Mon Sep 17 00:00:00 2001 From: Tartar Date: Sun, 18 Oct 2026 17:20:19 +0800 Subject: [PATCH 16/80] fix: preserve recovery identities and portable local paths Keep original completion and queued-chat operation identities during recovery. Preserve logical path declarations while using extended Windows paths for filesystem operations, including exact migration rollback. Retain focused identity, queued-write and long-path regression coverage. Validated the current candidate through the frozen source/installed qualification suites and complete compatibility gate. Signed-off-by: Tartar --- loopx/chat_loopx_mode.py | 19 + loopx/chat_store.py | 2 + loopx/cli_commands/todo.py | 1 + .../goals/checkpoint_read_context.ts | 12 +- .../control_plane/projects/registry_codec.py | 30 +- .../runtime/local_state_migration.py | 28 +- loopx/file_lock.py | 15 +- loopx/project_prompt.py | 4 +- .../test_completion_original_identity.py | 361 ++++++++++++++++++ .../test_default_checkpoint_continuation.py | 4 +- .../test_project_registry_long_paths.py | 85 +++++ tests/test_chat_loopx_mode.py | 8 +- tests/test_chat_queued_write_recovery.py | 214 +++++++++++ tests/test_local_state_migration.py | 35 +- tests/test_local_state_migration_path_text.py | 73 ++++ 15 files changed, 841 insertions(+), 50 deletions(-) create mode 100644 tests/control_plane/test_completion_original_identity.py create mode 100644 tests/control_plane/test_project_registry_long_paths.py create mode 100644 tests/test_chat_queued_write_recovery.py create mode 100644 tests/test_local_state_migration_path_text.py diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py index 5bf7b63906..98d89fa50f 100644 --- a/loopx/chat_loopx_mode.py +++ b/loopx/chat_loopx_mode.py @@ -414,6 +414,25 @@ def apply(self, session_id, body, *, work_dir, objective): ) ): raise ValueError("execution operation identity conflict") + if (existing.get("status") == "queued" + and mode.get("enabled") is True + and mode.get("paused") is not True): + # Acceptance is not dispatch. Re-enter the existing + # typed owner with the exact stored request after a + # failed start write; never reconstruct a new Turn. + if not goal_accepts_collaboration(self._goal(session)): + raise ValueError("Goal is stopped or unavailable") + self._execution(session, prior_settings) + self.controller.submit_turn( + session_id=session_id, + client_turn_id=operation_id, + message=existing["message"], + attachments=[], + work_dir=work_dir, + objective=objective, + loopx_execution=True, + loopx_request=prior_request, + ) return {**self.snapshot(session_id), "turn_id": existing["turn_id"]} if operation in {"pause", "exit"}: effect_runtime_result( diff --git a/loopx/chat_store.py b/loopx/chat_store.py index fababacc89..3dab5ba5de 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -10,6 +10,7 @@ from pathlib import Path import re import threading +import time from typing import Any import uuid from weakref import WeakValueDictionary @@ -133,6 +134,7 @@ def _atomic_write_json(path: Path, payload: dict[str, Any], *, preserve_mode: bo directory_mode=0o700, separators=(",", ":"), indent=None, + replace_retry_seconds=0.5, ) diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index c71ffde202..8a914e4f1b 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -687,6 +687,7 @@ def handle_todo_command( "next_task_class", "next_action_kind", "next_task_repository", "next_required_capabilities", "next_continuation_policy", "self_merged", "dry_run", "next_excluded_agents", "next_user_task_class", "decision_outcome", "role", + "task_lease_idempotency_key", "task_lease_expected_version", )} | {"no_followup": bool(args.no_follow_up)}, ) completion_turn_key = original_completion["completion_turn_key"] diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index da6879bc22..11724f16c2 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -293,11 +293,15 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { const todo = requireJsonObject(request.todo, "completion Todo"); // Managed journals store the raw Turn id; interactive callers store its // scoped effect id. Preserve history only for this already committed result. - const managedReplay = todo.status === "done" && todo.todo_id === identity.todo_id && - todo.claimed_by === identity.agent_id && todo.completion_turn_key === identity.turn_instance_id; - const key = managedReplay ? identity.turn_instance_id : identity.effect_id; + const originalReplay = todo.status === "done" && todo.todo_id === identity.todo_id && + todo.claimed_by === identity.agent_id && + (todo.completion_turn_key === identity.turn_instance_id || todo.completion_turn_key === identity.effect_id); + const key = originalReplay ? String(todo.completion_turn_key) : identity.effect_id; const candidate = jsonObject(request.candidate); const unchanged = candidate !== null && + // A done projection cannot prove a supplied lease matches the original + // request. Keep that proof with the canonical receipt owner after release. + candidate.task_lease_idempotency_key == null && candidate.task_lease_expected_version == null && ["evidence", "note", "reason"].every(field => candidate[field] == null || candidate[field] === todo[field]) && ["result_file", "next_agent_todo", "next_user_todo", "next_claimed_by", "next_task_class", "next_action_kind", "next_task_repository", "next_required_capabilities", "next_continuation_policy", @@ -306,7 +310,7 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { (candidate.successor_todo_ids == null || (Array.isArray(candidate.successor_todo_ids) && candidate.successor_todo_ids.length === 0)) && candidate.clear_claim !== true && candidate.self_merged !== true && (candidate.role == null || candidate.role === todo.role); - if (managedReplay && unchanged) { + if (originalReplay && unchanged) { const fence = evaluateTodoCompletionFence(decodeTodoCompletionFenceRequest({ schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, projection_source: "materialized", todo, goal_id: identity.goal_id, todo_id: identity.todo_id, diff --git a/loopx/control_plane/projects/registry_codec.py b/loopx/control_plane/projects/registry_codec.py index f05952425a..ab7d1673a4 100644 --- a/loopx/control_plane/projects/registry_codec.py +++ b/loopx/control_plane/projects/registry_codec.py @@ -13,6 +13,7 @@ from typing import Any, TypeVar from ..content_digest import ENVELOPED_SHA256_PATTERN +from ..runtime.file_paths import windows_extended_path from ...file_lock import exclusive_cross_runtime_file_lock from ...paths import GLOBAL_REGISTRY_FILENAME @@ -180,7 +181,7 @@ def _decode_document(raw_bytes: bytes) -> _ProjectRegistryDocument: def _read_document(path: Path) -> _ProjectRegistryDocument: - return _decode_document(path.read_bytes()) + return _decode_document(windows_extended_path(path).read_bytes()) def load_project_registry(path: Path) -> dict[str, Any]: @@ -217,6 +218,7 @@ def load_registry( """Load a project registry or an object-only global registry.""" expanded = path.expanduser() + address = windows_extended_path(expanded) if source_snapshots is not None: # Capture callers must decode the same bytes that supply their IO # witness. Retain absence too; rereading could conceal A -> B -> A. @@ -225,7 +227,7 @@ def load_registry( source = expanded.resolve() if source not in source_snapshots: try: - source_snapshots[source] = source.read_bytes() + source_snapshots[source] = windows_extended_path(source).read_bytes() except FileNotFoundError: source_snapshots[source] = None # Witness the invoked alias too. Its saved link can change while @@ -233,9 +235,9 @@ def load_registry( source_snapshots[route] = source_snapshots[source] raw = source_snapshots[route] return {} if raw is None else decode_registry_snapshot(expanded, raw) - if not expanded.exists(): + if not address.exists(): return {} - return decode_registry_snapshot(expanded, expanded.read_bytes()) + return decode_registry_snapshot(expanded, address.read_bytes()) def require_runtime_compatible_project_registry( @@ -290,6 +292,7 @@ def _encode_document( def _atomic_write_bytes(path: Path, payload: bytes, *, mode: int | None) -> None: + path = windows_extended_path(path) path.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary = tempfile.mkstemp( prefix=f".{path.name}.", @@ -407,11 +410,12 @@ def commit(self, payload: dict[str, Any]) -> bool: def remove(self) -> bool: if self._finished: raise RuntimeError("project registry transaction is already finished") - if not self._path.exists(): + address = windows_extended_path(self._path) + if not address.exists(): self._finished = True return False - self._path.unlink() - if self._path.exists(): + address.unlink() + if address.exists(): raise ProjectRegistryMutationError( "project registry removal readback failed" ) @@ -419,19 +423,20 @@ def remove(self) -> bool: return True def restore(self) -> None: + address = windows_extended_path(self._path) if self._existed: _atomic_write_bytes( self._path, self._document.raw_bytes, mode=self._mode, ) - if self._path.read_bytes() != self._document.raw_bytes: + if address.read_bytes() != self._document.raw_bytes: raise ProjectRegistryRestoreError( "project registry exact-byte restore did not verify" ) else: - self._path.unlink(missing_ok=True) - if self._path.exists(): + address.unlink(missing_ok=True) + if address.exists(): raise ProjectRegistryRestoreError( "new project registry could not be removed during restore" ) @@ -453,10 +458,11 @@ def _registry_transaction( agent_id=agent_id, operation=operation, ): - existed = expanded.exists() + address = windows_extended_path(expanded) + existed = address.exists() if existed: document = _read_document(expanded) - mode = expanded.stat().st_mode & 0o777 + mode = address.stat().st_mode & 0o777 else: if create_document is None: raise FileNotFoundError( diff --git a/loopx/control_plane/runtime/local_state_migration.py b/loopx/control_plane/runtime/local_state_migration.py index 863c594331..af0498a282 100644 --- a/loopx/control_plane/runtime/local_state_migration.py +++ b/loopx/control_plane/runtime/local_state_migration.py @@ -106,14 +106,14 @@ def _write_registry(path: Path, payload: dict[str, Any]) -> None: _require_unlinked_directory_chain(path.parent, label="migration write") if _is_redirected_path(path): raise ValueError(f"migration write is a symlink or junction: {path}") - descriptor, name = tempfile.mkstemp(prefix=".loopx-migration-", dir=path.parent.parent) + descriptor, name = tempfile.mkstemp(prefix=".loopx-migration-", dir=windows_extended_path(path.parent.parent)) temporary = Path(name) try: with os.fdopen(descriptor, "wb") as stream: stream.write(_registry_bytes(payload)) stream.flush() os.fsync(stream.fileno()) - temporary.replace(path) + temporary.replace(windows_extended_path(path)) finally: temporary.unlink(missing_ok=True) @@ -221,7 +221,7 @@ def _rewrite_registry( source, target = route declared = Path(str(goal["state_file"])).expanduser() goal["state_file"] = ( - str(target) if declared.is_absolute() else str(DEFAULT_PROJECT_GOALS / target.parent.name / target.name) + str(target) if declared.is_absolute() else (DEFAULT_PROJECT_GOALS / target.parent.name / target.name).as_posix() ) return updated @@ -318,7 +318,7 @@ def _copy(source: Path, target: Path) -> None: if _is_redirected_path(source): raise ValueError(f"migration source is a symlink or junction: {source}") _require_backup_path(target.parent) - target.parent.mkdir(parents=True, exist_ok=True) + windows_extended_path(target.parent).mkdir(parents=True, exist_ok=True) _require_backup_path(target.parent) if target.exists() or _is_redirected_path(target): raise FileExistsError(f"backup snapshot target already exists: {target}") @@ -348,7 +348,7 @@ def _write_verified_backup(plan: dict[str, Any]) -> None: backup = Path(plan["backup_dir"]) entries = plan["entries"] _require_backup_path(backup, must_be_absent=True) - backup.mkdir(mode=0o700, parents=True, exist_ok=False) + windows_extended_path(backup).mkdir(mode=0o700, parents=True, exist_ok=False) _require_backup_path(backup) try: for index, entry in enumerate(entries): @@ -365,7 +365,7 @@ def _write_verified_backup(plan: dict[str, Any]) -> None: if _digest(copied) != entry["digest"]: raise ValueError(f"backup verification failed for {original}") _require_backup_path(backup) - (backup / "plan.json").write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + windows_extended_path(backup / "plan.json").write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") except Exception: # Keep any partial backup for inspection. No authoritative state moved. raise @@ -394,7 +394,7 @@ def _verify_migrated_state(plan: dict[str, Any], writes: _MigrationWrites) -> No if legacy.exists() or _is_redirected_path(legacy): raise ValueError(f"legacy authority reappeared during migration: {legacy}") before_runtime = backup / "snapshot" / "0" - before_children = {child.name for child in before_runtime.iterdir()} + before_children = {child.name for child in windows_extended_path(before_runtime).iterdir()} after_children = {child.name for child in target.iterdir()} if before_children != after_children: raise ValueError("runtime files changed during migration") @@ -551,7 +551,7 @@ def _read_recovery_receipt(receipt_path: Path) -> dict[str, Any]: _require_backup_path(receipt_path.parent) if _is_redirected_path(receipt_path): raise ValueError(f"backup receipt is a symlink or junction: {receipt_path}") - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt = json.loads(windows_extended_path(receipt_path).read_text(encoding="utf-8")) if ( not isinstance(receipt, dict) or receipt.get("schema_version") != LOCAL_STATE_MIGRATION_SCHEMA @@ -563,7 +563,7 @@ def _read_recovery_receipt(receipt_path: Path) -> dict[str, Any]: plan_path = backup / "plan.json" if _is_redirected_path(plan_path): raise ValueError("migration backup plan is redirected") - plan = json.loads(plan_path.read_text(encoding="utf-8")) + plan = json.loads(windows_extended_path(plan_path).read_text(encoding="utf-8")) entries = receipt.get("entries") if not isinstance(entries, list) or not isinstance(plan, dict): raise ValueError("migration receipt has no entries") @@ -588,9 +588,9 @@ def _read_recovery_receipt(receipt_path: Path) -> dict[str, Any]: def _require_unchanged_runtime(receipt: dict[str, Any], active: Path) -> None: before = Path(receipt["backup_dir"]) / "snapshot" / "0" - if {child.name for child in before.iterdir()} != {child.name for child in active.iterdir()}: + if {child.name for child in windows_extended_path(before).iterdir()} != {child.name for child in active.iterdir()}: raise ValueError(f"migrated state changed; automatic rollback is unsafe: {active}") - for child in before.iterdir(): + for child in windows_extended_path(before).iterdir(): current = active / child.name if child.name != GLOBAL_REGISTRY_FILENAME: if _digest(current) != _digest(child): @@ -659,11 +659,11 @@ def _restore_snapshot_file(snapshot: Path, destination: Path, *, expected_digest if _digest(snapshot) != expected_digest: raise ValueError(f"migration backup changed: {snapshot}") before = _digest(destination) - descriptor, name = tempfile.mkstemp(prefix=".loopx-rollback-", dir=destination.parent.parent) + descriptor, name = tempfile.mkstemp(prefix=".loopx-rollback-", dir=windows_extended_path(destination.parent.parent)) os.close(descriptor) temporary = Path(name) try: - shutil.copy2(snapshot, temporary) + shutil.copy2(windows_extended_path(snapshot), temporary) if _digest(temporary) != expected_digest or _digest(snapshot) != expected_digest: raise ValueError(f"registry recovery copy changed: {snapshot}") with temporary.open("r+b") as stream: @@ -673,7 +673,7 @@ def _restore_snapshot_file(snapshot: Path, destination: Path, *, expected_digest raise ValueError("registry recovery destination is redirected") if _digest(destination) != before: raise ValueError(f"registry changed during recovery: {destination}") - temporary.replace(destination) + temporary.replace(windows_extended_path(destination)) finally: temporary.unlink(missing_ok=True) diff --git a/loopx/file_lock.py b/loopx/file_lock.py index 8f301a5997..8b021c0bfb 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -40,6 +40,7 @@ def write_bytes_atomic( preserve_mode: bool = False, directory_mode: int | None = None, fsync_directory: bool = False, + replace_retry_seconds: float = 0, ) -> None: """Replace one file with `payload` through a same-directory temp file. @@ -69,7 +70,17 @@ def write_bytes_atomic( os.fsync(handle.fileno()) if mode is not None: os.chmod(temporary, mode) - os.replace(temporary, destination) + deadline = time.monotonic() + replace_retry_seconds + while True: + try: + os.replace(temporary, destination) + break + except OSError as error: + remaining = deadline - time.monotonic() + if (os.name != "nt" or getattr(error, "winerror", None) != 5 + or remaining <= 0): + raise + time.sleep(min(0.05, remaining)) if fsync_directory and os.name == "posix": directory_fd = os.open( destination.parent, @@ -91,6 +102,7 @@ def write_json_atomic( preserve_mode: bool = False, directory_mode: int | None = None, fsync_directory: bool = False, + replace_retry_seconds: float = 0, indent: int | None = 2, sort_keys: bool = False, separators: tuple[str, str] | None = None, @@ -116,6 +128,7 @@ def write_json_atomic( preserve_mode=preserve_mode, directory_mode=directory_mode, fsync_directory=fsync_directory, + replace_retry_seconds=replace_retry_seconds, ) diff --git a/loopx/project_prompt.py b/loopx/project_prompt.py index 026f00b135..a96edde69b 100644 --- a/loopx/project_prompt.py +++ b/loopx/project_prompt.py @@ -446,10 +446,10 @@ def build_new_project_prompt( if isinstance(existing_registry, dict) and existing_registry.get("common_runtime_root") else None ) - active_state_path = rel_or_abs( + active_state_path = Path(rel_or_abs( registered_goal_state_file(project.expanduser(), resolved_goal_id, existing_registry), project.expanduser(), - ) + )).as_posix() allowed_domains = allowed_domains or [] write_scope = write_scope or [] connect_command = render_connect_command( diff --git a/tests/control_plane/test_completion_original_identity.py b/tests/control_plane/test_completion_original_identity.py new file mode 100644 index 0000000000..094fdb09a0 --- /dev/null +++ b/tests/control_plane/test_completion_original_identity.py @@ -0,0 +1,361 @@ +"""Replay a committed original completion without recreating its successor intent.""" + +from __future__ import annotations +import json +import subprocess +import sys +import pytest +from loopx.control_plane.goals.checkpoint_context_io import ( + completion_identity_for_original_turn, +) +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.todos import add_goal_todo, list_goal_todos +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID + + +@pytest.fixture(autouse=True) +def _release_test_runtime(_isolated_default_runtime_routes, monkeypatch): + # The default-route fixture gives every case a different runtime owner. + # Stop that case's server before its HOME/TEMP patches are restored. + yield + from loopx.control_plane.effect_runtime import restart_effect_runtime + + restart_effect_runtime(timeout=5) + + +def _identity_fixture(key_kind): + identity = SettlementIdentity( + goal_id="replay-fixture", + agent_id="agent-a", + todo_id="todo_abc123", + turn_instance_id="original-turn", + ) + key = identity.turn_instance_id if key_kind == "managed" else identity.effect_id + todo = dict( + todo_id=identity.todo_id, + status="done", + claimed_by=identity.agent_id, + completion_turn_key=key, + completion_continuation="successor", + successor_todo_ids=["todo_def456"], + evidence="Candidate verified.", + role="agent", + completion_receipt_id="original-committed-receipt", + ) + return identity, todo + + +def _resolve(identity, todo, candidate=None): + return completion_identity_for_original_turn( + todo=todo, + identity=identity, + goal_id=identity.goal_id, + agent_id=identity.agent_id, + todo_id=identity.todo_id, + candidate={"evidence": "Candidate verified.", **(candidate or {})}, + ) + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize("continuation", ["successor", "active_goal", "no_followup"]) +def test_original_completion_read_only_replay(key_kind, continuation): + identity, todo = _identity_fixture(key_kind) + todo["completion_continuation"] = continuation + todo["no_followup"] = continuation == "no_followup" + if continuation != "successor": + todo["successor_todo_ids"] = [] + result = _resolve(identity, todo) + assert result["completion_turn_key"] == todo["completion_turn_key"] + assert result["replay"]["idempotent_replay"] is True + assert result["replay"]["changed"] is False + assert result["replay"]["completion_receipt_id"] == todo["completion_receipt_id"] + assert result["replay"]["completion_continuation"] == continuation + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize( + "candidate", + [ + {"evidence": "Changed evidence."}, + {"note": "Changed note."}, + {"reason": "Changed reason."}, + {"next_agent_todo": "Changed successor."}, + {"next_user_todo": "Changed user obligation."}, + {"successor_todo_ids": ["todo_new789"]}, + {"result_file": "different.json"}, + {"next_claimed_by": "different-agent"}, + {"next_task_class": "maintenance"}, + {"next_action_kind": "review"}, + {"next_task_repository": "different-repo"}, + {"next_required_capabilities": "network"}, + {"next_continuation_policy": "different"}, + {"next_excluded_agents": "different-agent"}, + {"next_user_task_class": "approval"}, + {"decision_outcome": "different"}, + {"clear_claim": True}, + {"self_merged": True}, + {"role": "user"}, + ], +) +def test_changed_completion_is_never_read_only_replay(key_kind, candidate): + identity, todo = _identity_fixture(key_kind) + assert _resolve(identity, todo, candidate)["replay"] is None + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize( + "field,value", + [ + ("status", "open"), + ("claimed_by", "other-agent"), + ("todo_id", "todo_different"), + ("completion_turn_key", "different-turn"), + ("completion_continuation", None), + ], +) +def test_wrong_or_incomplete_original_completion_cannot_replay(key_kind, field, value): + identity, todo = _identity_fixture(key_kind) + todo[field] = value + assert _resolve(identity, todo)["replay"] is None + + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +def test_terminal_upgrade_cannot_remove_committed_successor(key_kind): + identity, todo = _identity_fixture(key_kind) + with pytest.raises(ValueError, match="successor"): + _resolve(identity, todo, {"no_followup": True}) + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("intent", ["generated", "linked", "terminal"]) +def test_coordinator_resumes_original_completion_intent( + provider, intent, tmp_path, monkeypatch +): + project = tmp_path + candidate = project / "candidate.txt" + validator = project / "verify.py" + validator.write_text( + "from pathlib import Path\np=Path(__file__).parent\nassert (p/'candidate.txt').read_text()=='valid'\nwith (p/'validation.txt').open('a') as f: f.write('validated\\n')\n", + encoding="utf-8", + ) + candidate.write_text("broken", encoding="utf-8") + assert ( + subprocess.run([sys.executable, str(validator)], capture_output=True).returncode + != 0 + ) + assert not (project / "validation.txt").exists() + registry, state, todo_id = _fixture( + provider, + tmp_path, + monkeypatch, + validation_command_json=json.dumps([sys.executable, str(validator)]), + ) + candidate.write_text("valid", encoding="utf-8") + if intent == "linked": + successor = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Verify linked remaining work.", + task_class="advancement_task", + ) + options = {"successor_todo_ids": [successor["todo_id"]]} + elif intent == "generated": + options = {"next_agent_todo": "Verify generated remaining work."} + else: + options = {"no_follow_up": True} + control = CoordinatorCheckpoints( + registry, tmp_path / "runtime", GOAL_ID, AGENT_ID + ).control + guard = json.loads(control.should_run(todo_id)) + assert guard["delivery_result_context"]["settlement_identity"]["todo_id"] == todo_id + first = json.loads( + control.complete_task(todo_id, AGENT_ID, "Candidate verified.", **options) + ) + assert first.get("stage") == "direction_pending", first + before = list_goal_todos( + registry_path=registry, + runtime_root_arg=str(tmp_path / "runtime"), + goal_id=GOAL_ID, + todo_id=todo_id, + )["todo"] + original_identity = first["settlement_identity"] + assert ( + first["context"]["basis"]["todo"]["completion_receipt_id"] + == before["completion_receipt_id"] + ) + event_log = tmp_path / "runtime/goals" / GOAL_ID / "rollout-event-log.jsonl" + original_events = event_log.read_bytes() + assert original_events + coordinator = CoordinatorCheckpoints( + registry, tmp_path / "runtime", GOAL_ID, AGENT_ID + ) + old = coordinator.read(todo_id) + state.write_text( + state.read_text(encoding="utf-8") + + "\n## Acceptance\nA new current obligation.\n", + encoding="utf-8", + ) + stale = coordinator.resume( + todo_id, + { + "read_context_id": old["read_context_id"], + "vision_unchanged_reason": "The old acceptance still applies.", + }, + ) + assert stale.get("ok") is False, stale + fresh = coordinator.read(todo_id) + assert fresh["read_context_id"] != old["read_context_id"] + assert "new current obligation" in json.dumps(fresh["basis"]) + context_dir = tmp_path / "runtime/goals" / GOAL_ID / "checkpoint-contexts" + bound_basis_fields = ( + "identity", + "work_basis", + "decision_basis", + "versions", + "provider_revision", + ) + original_bases = {} + for path in context_dir.glob("*.json"): + record = json.loads(path.read_text(encoding="utf-8")) + if "read_context_id" in record: + original_bases[path] = { + field: record.get(field) for field in bound_basis_fields + } + assert original_bases + result = coordinator.resume( + todo_id, + { + "read_context_id": fresh["read_context_id"], + "vision_unchanged_reason": "The validated candidate and current remaining obligations preserve the route.", + }, + ) + (tmp_path / "resume-result.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" + ) + assert result.get("ok") is True and result.get("settlement_complete") is True, ( + json.dumps(result) + ) + assert result["settlement_identity"] == original_identity + after = list_goal_todos( + registry_path=registry, + runtime_root_arg=str(tmp_path / "runtime"), + goal_id=GOAL_ID, + todo_id=todo_id, + )["todo"] + assert after["completion_turn_key"] == before["completion_turn_key"] + assert after.get("successor_todo_ids") == before.get("successor_todo_ids") + assert event_log.read_bytes().startswith(original_events) + for path, basis in original_bases.items(): + current = json.loads(path.read_text(encoding="utf-8")) + assert {field: current.get(field) for field in bound_basis_fields} == basis + if intent == "terminal": + assert before["completion_continuation"] == "active_goal" + assert ( + after["completion_continuation"] == "no_followup" + and after["no_followup"] is True + ) + assert after["completion_recovery"] == "same_turn_terminal_closeout" + assert after["completion_receipt_id"] != before["completion_receipt_id"] + else: + assert after["completion_receipt_id"] == before["completion_receipt_id"] + assert candidate.read_text() == "valid" + assert (project / "validation.txt").read_text().splitlines() == ["validated"] + index = tmp_path / "runtime/goals" / GOAL_ID / "runs/index.jsonl" + rows = [json.loads(line) for line in index.read_text(encoding="utf-8").splitlines()] + assert sum(row.get("classification") == "quota_slot_spent" for row in rows) == 1 + snapshot = index.read_bytes() + cold = CoordinatorCheckpoints(registry, tmp_path / "runtime", GOAL_ID, AGENT_ID) + again = cold.read(todo_id) + (tmp_path / "cold-read.json").write_text( + json.dumps(again, indent=2), encoding="utf-8" + ) + assert again["ok"] is True and again["replayed"] is True + assert again["status"] == "committed" and "read_context_id" not in again + assert again["context"]["read_context_id"] == fresh["read_context_id"] + assert again["settlement_identity"] == original_identity + replay = cold.resume( + todo_id, + { + "read_context_id": fresh["read_context_id"], + "vision_unchanged_reason": "The validated candidate and current remaining obligations preserve the route.", + }, + ) + assert replay.get("settlement_complete") is True, replay + assert replay["settlement_identity"] == original_identity + assert index.read_bytes() == snapshot + assert (project / "validation.txt").read_text().splitlines() == ["validated"] + +@pytest.mark.parametrize("key_kind", ["managed", "interactive"]) +@pytest.mark.parametrize( + "candidate", + [ + {"task_lease_idempotency_key": "original-lease"}, + {"task_lease_idempotency_key": "replacement-lease"}, + {"task_lease_idempotency_key": ""}, + {"task_lease_expected_version": 1}, + {"task_lease_expected_version": 999}, + {"task_lease_expected_version": 0}, + {"task_lease_expected_version": False}, + {"task_lease_idempotency_key": "original-lease", "task_lease_expected_version": 1}, + ], +) +def test_supplied_lease_requires_canonical_request_verification(key_kind, candidate): + identity, todo = _identity_fixture(key_kind) + # The Todo projection cannot prove a supplied lease belongs to the original + # request. Even plausible values must reach the canonical receipt owner. + assert _resolve(identity, todo, candidate)["replay"] is None + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("closeout_source", ["turn", "lifecycle"]) +def test_original_terminal_cold_replay_keeps_lease_fingerprint( + tmp_path, monkeypatch, provider, closeout_source +): + from tests.control_plane import test_declared_terminal_settlement_cli as terminal + + fixture = terminal._fixture + captured = [] + + def capture(*args, **kwargs): + result = fixture(*args, **kwargs) + captured.append(result) + return result + + monkeypatch.setattr(terminal, "_fixture", capture) + # Execute the four existing frozen native journeys without changing their + # assertions, including RESULT, new direction, one debit and cold replay. + terminal.test_default_declared_terminal_intent_recovers_original_result( + tmp_path, monkeypatch, provider, closeout_source + ) + _, _, runtime, _, run, lease, marker, _, _ = captured[0] + cli = terminal.cli + command = ( + "todo", "complete", "--goal-id", cli.GOAL_ID, + "--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID, *lease, "--no-follow-up", + "--evidence", "Original declared deliverable, then current post-result direction.", + ) + code, before = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) + assert code == 0, before + ledger = runtime / "goals" / cli.GOAL_ID / "runs" / "index.jsonl" + original_ledger, original_validation = ledger.read_bytes(), marker.read_bytes() + for field, value in ( + ("--task-lease-idempotency-key", "replacement-lease"), + ("--task-lease-expected-version", "999"), + ): + changed = list(command) + changed[changed.index(field) + 1] = value + code, rejected = run(*changed) + assert code == 1 and rejected.get("ok") is False, rejected + code, after = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) + assert code == 0 and after["todo"] == before["todo"], after + assert ledger.read_bytes() == original_ledger + assert marker.read_bytes() == original_validation + code, exact = run(*command) + assert code == 0 and exact["idempotent_replay"] is True, exact + assert ledger.read_bytes() == original_ledger + assert marker.read_bytes() == original_validation + diff --git a/tests/control_plane/test_default_checkpoint_continuation.py b/tests/control_plane/test_default_checkpoint_continuation.py index 28095ad404..a085c4ee24 100644 --- a/tests/control_plane/test_default_checkpoint_continuation.py +++ b/tests/control_plane/test_default_checkpoint_continuation.py @@ -17,7 +17,7 @@ from tests.test_goal_mode_mcp_settlement import _write_fixture, _control, GOAL_ID, AGENT_ID, _run_cli -def _fixture(provider, tmp_path, monkeypatch): +def _fixture(provider, tmp_path, monkeypatch, *, validation_command_json=None): isolate_sqlite_runtime(tmp_path, monkeypatch) registry, state = _write_fixture(tmp_path) doc = json.loads(registry.read_text()) @@ -29,7 +29,7 @@ def _fixture(provider, tmp_path, monkeypatch): assert rc == 0, baseline todo = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', text='Verify one candidate.', task_class='advancement_task', claimed_by=AGENT_ID, - continuation_policy='same_agent_non_delivery') + continuation_policy='same_agent_non_delivery', validation_command_json=validation_command_json) if provider != 'legacy': todos = parse_active_state_todos(state.read_text(encoding='utf-8'))['agent_todos']['items'] initialize_canonical_authority(tmp_path / 'runtime', GOAL_ID, diff --git a/tests/control_plane/test_project_registry_long_paths.py b/tests/control_plane/test_project_registry_long_paths.py new file mode 100644 index 0000000000..8a47f1fb25 --- /dev/null +++ b/tests/control_plane/test_project_registry_long_paths.py @@ -0,0 +1,85 @@ +"""Logical registry identities retain real native long-path IO semantics.""" + +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.projects import registry_codec as codec +from loopx.control_plane.runtime.file_paths import windows_extended_path +from tests.control_plane.test_project_registry_codec import _strict + + +def _target(root): + while 293 - len(str(root / "registry.json")) > 121: + root = root / ("p" * 100) + root = root / ("p" * (293 - len(str(root / "registry.json")) - 1)) + path = root / "registry.json" + assert len(str(path)) == 293 and not str(path).startswith("\\\\?\\") + return path + + +def _seed(path, strict): + payload = {"goals": [], "value": "before", "logical_registry": str(path)} + raw = (json.dumps(_strict(payload) if strict else payload, indent=2) + "\n").encode() + address = windows_extended_path(path) + address.parent.mkdir(parents=True) + address.write_bytes(raw) + return payload, raw + + +@pytest.mark.parametrize("strict", [False, True]) +def test_long_registry_commit_keeps_logical_path_and_wire_format(tmp_path, strict): + path = _target(tmp_path) + before, _ = _seed(path, strict) + mode = windows_extended_path(path).stat().st_mode & 0o777 + codec.mutate_project_registry(path, operation="long_commit", reducer=lambda p: p.update(value="after")) + assert codec.load_project_registry(path) == dict(before, value="after") + address = windows_extended_path(path) + assert isinstance(json.loads(address.read_bytes()), list) == strict + assert address.stat().st_mode & 0o777 == mode + assert not list(address.parent.glob(".registry.json.*.tmp")) + + +@pytest.mark.parametrize("strict", [False, True]) +def test_long_registry_failed_readback_restores_original_bytes(tmp_path, monkeypatch, strict): + path = _target(tmp_path) + before, raw = _seed(path, strict) + original = codec._read_document + calls = 0 + + def fail_once(candidate): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("injected post-write readback failure") + return original(candidate) + + monkeypatch.setattr(codec, "_read_document", fail_once) + with pytest.raises(codec.ProjectRegistryMutationError, match="exact preimage restored"): + codec.mutate_project_registry(path, operation="long_restore", reducer=lambda p: p.update(value="unaccepted")) + assert windows_extended_path(path).read_bytes() == raw + assert codec.load_project_registry(path) == before + assert not list(windows_extended_path(path.parent).glob(".registry.json.*.tmp")) + + +def test_long_registry_creation_and_removal(tmp_path): + path = _target(tmp_path) + payload = {"goals": [], "logical_registry": str(path)} + with codec.project_registry_transaction(path, operation="long_create", create=lambda: payload) as tx: + assert tx.commit(tx.payload_copy()) + assert codec.load_project_registry(path) == payload + with codec.project_registry_transaction(path, operation="long_remove") as tx: + assert tx.remove() + assert not windows_extended_path(path).exists() + + +def test_long_registry_unknown_protocol_rejects_without_changes(tmp_path): + path = _target(tmp_path) + address = windows_extended_path(path) + address.parent.mkdir(parents=True) + raw = (json.dumps(_strict({"goals": []}, protocol="future_writer")) + "\n").encode() + address.write_bytes(raw) + with pytest.raises(codec.ProjectRegistryProtocolError): + codec.mutate_project_registry(path, operation="long_reject", reducer=lambda p: p.update(value="forbidden")) + assert address.read_bytes() == raw diff --git a/tests/test_chat_loopx_mode.py b/tests/test_chat_loopx_mode.py index 270c387811..6539754300 100644 --- a/tests/test_chat_loopx_mode.py +++ b/tests/test_chat_loopx_mode.py @@ -101,14 +101,18 @@ def test_start_is_explicit_and_replays_original_request_even_after_settings_chan assert not calls and not service.snapshot(sid)["enabled"] first = apply(mode, "start") second = apply(mode, "start") - assert first["turn_id"] == second["turn_id"] and len(calls) == 1 + # The fixture only accepts a queued Turn. Its retry re-enters acceptance, + # while a real worker's single-flight guard prevents duplicate dispatch. + assert first["turn_id"] == second["turn_id"] and len(calls) == 2 + assert calls[0] == calls[1] + assert len(service.store.messages(sid)) == 1 with pytest.raises(ValueError, match="identity conflict"): apply(mode, "resume") with pytest.raises(ValueError, match="identity conflict"): apply(mode, "start", settings={**settings, "token_budget": 20000}) with pytest.raises(Exception, match="current conversation turn"): apply(mode, "start", operation_id="another-run") - assert len(calls) == 1 + assert len(calls) == 2 def test_unfinished_goal_pins_identity_and_configuration_digest(mode): diff --git a/tests/test_chat_queued_write_recovery.py b/tests/test_chat_queued_write_recovery.py new file mode 100644 index 0000000000..9dda79f5c3 --- /dev/null +++ b/tests/test_chat_queued_write_recovery.py @@ -0,0 +1,214 @@ +from contextlib import contextmanager +import errno +import json +import os +from pathlib import Path +import threading +import time +from types import SimpleNamespace + +import pytest +import loopx.chat_store as chat_store +from loopx.chat_runtime import ChatRuntimeController +from loopx.control_plane.effect_runtime import restart_effect_runtime +from test_chat_loopx_mode import mode, project # noqa: F401 +from test_file_lock_windows_atomic import reader_without_delete_share + + +@pytest.fixture(autouse=True) +def cleanup_runtime(monkeypatch): + yield + restart_effect_runtime(timeout=5) + + +def finish(store, sid, tid): + store.update_turn(sid, tid, status="completed", response={"text": "finished"}) + store.release_active_turn(sid, tid, last_activity_at="2026-10-15T00:00:00Z", last_error_code=None) + + +def request(service, sid, repo, operation, **body): + return service.apply(sid, {"operation": operation, "operation_id": "original", **body}, + work_dir=repo, objective="Original bounded request") + + +@contextmanager +def transient_reader(path, monkeypatch): + observed = threading.Event() + replace = os.replace + attempts = [] + + def observed_replace(src, dst): + if Path(dst) == path: + attempts.append(1) + observed.set() + return replace(src, dst) + + with reader_without_delete_share(path) as close: + def release(): + assert observed.wait(2) + time.sleep(0.1) + close() + worker = threading.Thread(target=release) + worker.start() + monkeypatch.setattr(chat_store.os, "replace", observed_replace) + try: + yield attempts + finally: + observed.set() + worker.join(3) + assert not worker.is_alive() + + +def test_transient_windows_reader_preserves_complete_atomic_chat_state(tmp_path, monkeypatch): + path = tmp_path / "turn.json" + path.write_text('{"status":"queued"}') + with transient_reader(path, monkeypatch) as attempts: + chat_store._atomic_write_json(path, {"status": "starting"}, preserve_mode=True) + assert len(attempts) >= 2 + assert json.loads(path.read_text()) == {"status": "starting"} + assert not list(tmp_path.glob("*.tmp")) + + +def test_persistent_windows_reader_keeps_original_and_cleans_temporary(tmp_path): + path = tmp_path / "turn.json" + path.write_text('{"status":"queued"}') + before = path.read_bytes() + with reader_without_delete_share(path): + started = time.monotonic() + with pytest.raises(OSError) as failure: + chat_store._atomic_write_json(path, {"status": "starting"}, preserve_mode=True) + assert failure.value.winerror == 5 + assert time.monotonic() - started < 1.2 + assert path.read_bytes() == before + assert not list(tmp_path.glob("*.tmp")) + + +def test_unrelated_replace_failure_propagates_without_retry(tmp_path, monkeypatch): + attempts = [] + def fail(src, dst): + attempts.append(1) + raise FileNotFoundError(errno.ENOENT, "missing source") + monkeypatch.setattr(chat_store.os, "replace", fail) + with pytest.raises(FileNotFoundError): + chat_store._atomic_write_json(tmp_path / "turn.json", {"status": "queued"}) + assert attempts == [1] and not list(tmp_path.glob("*.tmp")) + + +def test_posix_permission_error_is_not_a_windows_retry(tmp_path, monkeypatch): + attempts = [] + def fail(src, dst): + attempts.append(1) + error = PermissionError(errno.EACCES, "denied") + error.winerror = 5 + raise error + monkeypatch.setattr(chat_store, "os", SimpleNamespace(name="posix", fsync=os.fsync, chmod=os.chmod, replace=fail)) + with pytest.raises(PermissionError): + chat_store._atomic_write_json(tmp_path / "turn.json", {"status": "queued"}) + assert attempts == [1] and not list(tmp_path.glob("*.tmp")) + + +def test_successful_chat_write_preserves_permissions_without_sleep(tmp_path, monkeypatch): + path = tmp_path / "turn.json" + path.write_text('{}') + before = path.stat().st_mode + monkeypatch.setattr(time, "sleep", lambda _: pytest.fail("successful write must not wait")) + chat_store._atomic_write_json(path, {"status": "queued"}, preserve_mode=True) + assert path.stat().st_mode == before + assert json.loads(path.read_text()) == {"status": "queued"} + + +def test_json_serialization_failure_cleans_temporary(tmp_path): + path = tmp_path / "turn.json" + path.write_text('{}') + with pytest.raises(TypeError): + chat_store._atomic_write_json(path, {"bad": object()}, preserve_mode=True) + assert path.read_text() == '{}' and not list(tmp_path.glob("*.tmp")) + + +@pytest.mark.parametrize("operation", ["start", "resume"]) +@pytest.mark.parametrize("cold", [False, True], ids=["same-controller", "cold-controller"]) +def test_original_operation_recovers_queued_after_failed_start_fact(mode, monkeypatch, operation, cold): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, _ = mode + store = service.store + # The real failing HTTP Session already had its LoopX tools activated. + store.update_session(sid, loopx_tools=True) + controller = service.controller + # Persist the real request capsule/display message; hold only scheduling. + monkeypatch.setattr(controller, "submit_turn", ChatRuntimeController.submit_turn.__get__(controller)) + monkeypatch.setattr(controller, "_check_codex_home", lambda _: None) + monkeypatch.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: SimpleNamespace()) + monkeypatch.setattr(controller, "_start_accepted_turn_worker", lambda **k: False) + if operation == "resume": + store.update_session(sid, native_goal={"status": "paused", "tokensUsed": 400}) + first = request(service, sid, repo, operation, settings=settings) + tid = first["turn_id"] + original = store.load_turn(sid, tid) + messages = store.messages(sid) + events = store.events_after(sid, tid, None) + assert original["status"] == "queued" and original["upstream_turn_id"] is None + controller = service.controller + done = threading.Event() + controller.turn_done_events[(sid, tid)] = done + with reader_without_delete_share(store._turn_path(sid, tid)): + with pytest.raises(OSError) as failure: + controller._run_turn(session_id=sid, turn_id=tid, message=original["message"], + attachments=[], adapter=SimpleNamespace(), done_event=done, loopx_execution=True) + assert failure.value.winerror == 5 and done.is_set() + assert (sid, tid) not in controller.turn_done_events + assert store.load_turn(sid, tid) == original + if cold: + controller = ChatRuntimeController(store=type(store)(store.root.parent), codex_bin="unused", registry_path=controller.registry_path) + service = controller.loopx_mode + store = service.store + monkeypatch.setattr(controller, "submit_turn", ChatRuntimeController.submit_turn.__get__(controller)) + monkeypatch.setattr(controller, "_check_codex_home", lambda _: None) + monkeypatch.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: SimpleNamespace()) + monkeypatch.setattr(controller, "_start_accepted_turn_worker", ChatRuntimeController._start_accepted_turn_worker.__get__(controller)) + dispatches = [] + def provider(**kwargs): + dispatches.append((kwargs["session_id"], kwargs["turn_id"], kwargs["message"])) + finish(store, sid, tid) + monkeypatch.setattr(controller, "_run_started_turn", provider) + replay = request(service, sid, repo, operation) + current = controller.wait_for_turn(session_id=sid, turn_id=tid, timeout_sec=5) + assert replay["turn_id"] == tid and current["status"] == "completed", current + assert dispatches == [(sid, tid, original["message"])] + assert store.messages(sid) == messages + assert [e for e in store.events_after(sid, tid, None) if e["kind"] == "turn.queued"] == [e for e in events if e["kind"] == "turn.queued"] + assert current["loopx_request"] == original["loopx_request"] + assert request(service, sid, repo, operation)["turn_id"] == tid + assert len(dispatches) == 1 + controller.close() + + +@pytest.mark.parametrize("operation", ["start", "resume"]) +def test_changed_settings_cannot_dispatch_original_queued_request(mode, operation): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, calls = mode + if operation == "resume": + service.store.update_session(sid, native_goal={"status": "paused", "tokensUsed": 400}) + first = request(service, sid, repo, operation, settings=settings) + before = service.store.load_turn(sid, first["turn_id"]) + with pytest.raises(ValueError, match="identity conflict"): + request(service, sid, repo, operation, settings={**settings, "token_budget": settings["token_budget"] + 1}) + assert len(calls) == 1 and service.store.load_turn(sid, first["turn_id"]) == before + + +def test_stopped_goal_cannot_dispatch_previously_queued_operation(mode): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, calls = mode + first = request(service, sid, repo, "start", settings=settings) + registry = service.controller.registry_path + data = json.loads(registry.read_text()) + goal = next(g for g in data["goals"] if g["id"] == "research") + goal["activation"] = {"schema_version": "loopx_goal_activation_v1", "state": "stopped", "updated_at": "2026-10-15T00:00:00Z", "reason": "Owner stopped"} + registry.write_text(json.dumps(data)) + with pytest.raises(Exception, match="stopped"): + request(service, sid, repo, "start") + assert len(calls) == 1 and service.store.load_turn(sid, first["turn_id"])["status"] == "queued" + + +def test_terminal_original_operation_replay_never_dispatches(mode): # noqa: F811 - imported pytest fixture + service, sid, repo, settings, calls = mode + first = request(service, sid, repo, "start", settings=settings) + finish(service.store, sid, first["turn_id"]) + assert request(service, sid, repo, "start")["turn_id"] == first["turn_id"] + assert len(calls) == 1 diff --git a/tests/test_local_state_migration.py b/tests/test_local_state_migration.py index a110f904a0..ea7d7018fc 100644 --- a/tests/test_local_state_migration.py +++ b/tests/test_local_state_migration.py @@ -4,6 +4,7 @@ import hashlib import json import os +import shlex import stat import subprocess import sys @@ -24,6 +25,13 @@ rollback_local_state_migration, ) from loopx.project_prompt import build_new_project_prompt +from loopx.control_plane.runtime.file_paths import windows_extended_path + + +def _runtime_root_argument(command: str) -> str: + argv = shlex.split(command) + assert argv.count("--runtime-root") == 1 + return argv[argv.index("--runtime-root") + 1] def _write_json(path: Path, payload: dict[str, object]) -> None: @@ -386,7 +394,7 @@ def test_doctor_reads_legacy_capture_hosts_from_selected_runtime( tmp_path: Path, ) -> None: source, _target, _projects = _fixture(tmp_path, projects=1) - env = dict(os.environ, HOME=str(source.parents[1]), LOOPX_USAGE_PING="0") + env = dict(os.environ, HOME=str(source.parents[1]), USERPROFILE=str(source.parents[1]), LOOPX_USAGE_PING="0") env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1]) completed = subprocess.run( [sys.executable, "-m", "loopx.cli", "--format", "json", "doctor"], @@ -460,9 +468,9 @@ def test_existing_project_prompt_keeps_registered_goal_and_runtime_routes(tmp_pa allowed_domains=None, write_scope=None, ) - assert f"--runtime-root {source}" in payload["quota_guard_command"] - assert f"--runtime-root {source}" in payload["quota_spend_command"] - assert f"--runtime-root {source}" in payload["connect_command"] + assert _runtime_root_argument(payload["quota_guard_command"]) == str(source) + assert _runtime_root_argument(payload["quota_spend_command"]) == str(source) + assert _runtime_root_argument(payload["connect_command"]) == str(source) assert ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md" in payload["prompt"] @@ -545,7 +553,7 @@ def test_strict_project_registry_keeps_legacy_route_and_wire_format(tmp_path: Pa adapter_kind="read_only_project_map_v0", adapter_status="connected-read-only", next_probe=None, spawn_allowed=False, allowed_domains=None, write_scope=None, ) - assert f"--runtime-root {source}" in prompt["quota_guard_command"] + assert _runtime_root_argument(prompt["quota_guard_command"]) == str(source) assert ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md" in prompt["prompt"] preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) @@ -849,7 +857,7 @@ def test_preview_execute_and_verified_rollback_cover_all_registered_projects(tmp execute=True, ) receipt_path = Path(receipt["backup_dir"]) / RECEIPT_NAME - assert receipt_path.exists() + assert windows_extended_path(receipt_path).is_file() assert not source.exists() assert _read(target / "registry.global.json")["common_runtime_root"] == str(target) for index, project in enumerate(projects): @@ -932,7 +940,7 @@ def fail_rename_once(self: Path, destination: Path) -> Path: def fail_copy_once(original: Path, destination: Path, **kwargs: object) -> Path: nonlocal failed selected = backup / "snapshot" / ("0/registry.global.json" if failed_kind == "global-registry" else "1") - if not failed and failed_kind in {"registry", "global-registry"} and Path(original) == selected: + if not failed and failed_kind in {"registry", "global-registry"} and migration.windows_extended_path(Path(original)) == migration.windows_extended_path(selected): failed = True raise OSError(5, "synthetic rollback I/O failure") return original_copy(original, destination, **kwargs) @@ -989,17 +997,17 @@ def test_hard_interruption_has_a_recoverable_receipt( interrupt = Path(sys.argv.pop(1)) def rename(self, destination): result = original(self, destination) - if self == interrupt: + if migration.windows_extended_path(self) == migration.windows_extended_path(interrupt): os._exit(137) return result def replace(self, destination): result = original_replace(self, destination) - if Path(destination) == interrupt: + if migration.windows_extended_path(Path(destination)) == migration.windows_extended_path(interrupt): os._exit(137) return result def project_write(path, payload): original_project_write(path, payload) - if path == interrupt: + if migration.windows_extended_path(path) == migration.windows_extended_path(interrupt): os._exit(137) Path.rename = rename Path.replace = replace @@ -1079,7 +1087,7 @@ def test_registry_recovery_rechecks_backup_before_replacement( original_copy = migration.shutil.copy2 def corrupt_before_copy(original: Path, destination: Path, **kwargs: object) -> Path: - if Path(original) == snapshot: + if migration.windows_extended_path(Path(original)) == migration.windows_extended_path(snapshot): snapshot.write_text("corrupted backup\n", encoding="utf-8") return original_copy(original, destination, **kwargs) @@ -1263,7 +1271,7 @@ def inject_after_backup_dir( ) -> None: nonlocal redirected original_mkdir(self, mode=mode, parents=parents, exist_ok=exist_ok) - if self == backup: + if migration.windows_extended_path(self) == migration.windows_extended_path(backup): redirected = True monkeypatch.setattr(migration, "_is_redirected_path", classify) @@ -1613,6 +1621,7 @@ def test_custom_state_keeps_its_declared_file_through_migration_and_rollback( source, target, project, custom = _custom_state_fixture(tmp_path) local_registry = project / ".loopx" / "registry.json" before = local_registry.read_bytes() + declared = load_project_registry(local_registry)["goals"][0]["state_file"] preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) assert preview["goal_directory_count"] == 0 receipt = migrate_local_state( @@ -1621,7 +1630,7 @@ def test_custom_state_keeps_its_declared_file_through_migration_and_rollback( ) migrated = load_project_registry(local_registry) assert migrated["common_runtime_root"] == str(target) - assert migrated["goals"][0]["state_file"] == "custom/STATE.md" + assert migrated["goals"][0]["state_file"] == declared assert custom.read_text(encoding="utf-8") == "custom state\n" rollback_local_state_migration(Path(receipt["backup_dir"]) / RECEIPT_NAME, execute=True) assert local_registry.read_bytes() == before diff --git a/tests/test_local_state_migration_path_text.py b/tests/test_local_state_migration_path_text.py new file mode 100644 index 0000000000..c1f278e2f0 --- /dev/null +++ b/tests/test_local_state_migration_path_text.py @@ -0,0 +1,73 @@ +"""Migration changes owned routes while preserving declared path text.""" + +import json +from pathlib import Path + +import pytest + +from tests import test_local_state_migration as migration + + +def _set_declaration(source, project, text, strict): + local = project / ".loopx" / "registry.json" + for registry in (local, source / "registry.global.json"): + payload = migration._read(registry) + payload["goals"][0]["state_file"] = text + if registry == local and strict: + migration._write_strict_project_registry(registry, payload) + else: + migration._write_json(registry, payload) + return local + + +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("declaration", ["portable-relative", "native-relative", "absolute"]) +def test_owned_migration_path_text_and_exact_rollback(tmp_path, strict, declaration): + source, target, projects = migration._fixture(tmp_path, projects=1) + project = projects[0] + relative = Path(".codex") / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md" + text = { + "portable-relative": ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md", + "native-relative": str(relative), + "absolute": str(project / relative), + }[declaration] + local = _set_declaration(source, project, text, strict) + original_local, original_global = local.read_bytes(), (source / "registry.global.json").read_bytes() + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert local.read_bytes() == original_local and (source / "registry.global.json").read_bytes() == original_global + receipt = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + expected = str(project / ".loopx" / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md") if declaration == "absolute" else ".loopx/goals/goal-0/ACTIVE_GOAL_STATE.md" + for registry in (local, target / "registry.global.json"): + payload = migration.load_project_registry(registry) + assert payload["goals"][0]["state_file"] == expected + assert payload["common_runtime_root"] == str(target) + assert isinstance(json.loads(local.read_text()), list) == strict + assert (project / ".loopx" / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md").read_text() == "# goal-0\n" + migration.rollback_local_state_migration(Path(receipt["backup_dir"]) / migration.RECEIPT_NAME, execute=True) + assert local.read_bytes() == original_local + assert (source / "registry.global.json").read_bytes() == original_global + assert (project / relative).read_text() == "# goal-0\n" and not target.exists() + + +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("native_text", [False, True]) +def test_custom_path_keeps_exact_declared_text(tmp_path, strict, native_text): + source, target, project, custom = migration._custom_state_fixture(tmp_path) + text = str(Path("custom") / "STATE.md") if native_text else "custom/STATE.md" + local = _set_declaration(source, project, text, strict) + original_local, original_global = local.read_bytes(), (source / "registry.global.json").read_bytes() + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert preview["goal_directory_count"] == 0 + receipt = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + for registry in (local, target / "registry.global.json"): + assert migration.load_project_registry(registry)["goals"][0]["state_file"] == text + assert custom.read_text() == "custom state\n" + migration.rollback_local_state_migration(Path(receipt["backup_dir"]) / migration.RECEIPT_NAME, execute=True) + assert local.read_bytes() == original_local and (source / "registry.global.json").read_bytes() == original_global + assert custom.read_text() == "custom state\n" and not target.exists() From 417562fc6825eb5e03703eb9e84b50d2204dcd3a Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 19 Oct 2026 22:39:42 +0800 Subject: [PATCH 17/80] Retain original lifecycle source through heartbeat recovery Persist the bounded original obligation and native cadence with guard receipts. Use the existing TypeScript lifecycle owner to recognize exact completed-Todo closeout without replacing a recovered obligation or repeating settlement. Retain terminal reentry time while preserving original completion identity. Validated locally on the original dd4b candidate: 315 core and 16 lifecycle cases passed without skips; TypeScript checking and pinned evidence gates passed. Public regressions, documentation, packaged product operation and final installed qualification follow separately. No remote publication or main integration. Signed-off-by: Tartar --- .../control_plane/quota/heartbeat_receipt.py | 101 ++++++++++++++++-- loopx/control_plane/quota/live_decision.py | 6 +- loopx/control_plane/quota/settlement_cli.py | 14 +++ .../control_plane/quota/should_run_prepare.py | 8 +- .../work_items/semantic_replan_writeback.py | 19 ++++ loopx/state_refresh.py | 7 ++ 6 files changed, 144 insertions(+), 11 deletions(-) diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index 7a04e77250..5683131926 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -186,15 +186,15 @@ def find_heartbeat_receipt( goal_ref: Mapping[str, object] | None = None, ) -> dict[str, object] | None: events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id)) - return _effective_heartbeat_receipt( - _heartbeat_receipt_events( - events, - goal_id=goal_id, - agent_id=agent_id, - turn_instance_id=turn_instance_id, - goal_ref=goal_ref, - ) - ) + event = _effective_heartbeat_receipt(_heartbeat_receipt_events(events, + goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, goal_ref=goal_ref)) + if event: + details = event.get("details") or {} + ref = details.get("semantic_replan_source_ref") + if ref: + event = {**event, "details": {**details, + "semantic_replan_source": read_guard_source_blob(runtime_root, goal_id, ref)}} + return event def ensure_turn_heartbeat_settlement_receipt( @@ -664,6 +664,8 @@ def heartbeat_receipt_view( "gap_id": details.get("semantic_replan_gap_id"), "frontier_revision": details.get("semantic_replan_frontier_revision"), } + if details.get("semantic_replan_source") is not None: + receipt["semantic_replan_source"] = validate_guard_source(details["semantic_replan_source"], semantic_replan_obligation_id) pending_action_todo_id = heartbeat_receipt_pending_action_todo_id(event) if pending_action_todo_id: receipt["pending_action_selection"] = { @@ -728,3 +730,84 @@ def fail_heartbeat_receipt( }, } ) + + +def _guard_source_digest(value): + import hashlib, json + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, + separators=(",", ":")).encode("utf-8")).hexdigest() + + +def guard_cadence(registry_path, runtime_root, goal_id): + from ...agent_registry import load_goal_from_registry + from ...capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal + goal = load_goal_from_registry(Path(registry_path), goal_id) or {"id": goal_id} + profile = resolve_todo_replan_cadence_goal(goal, Path(runtime_root)).get("execution_profile") or {} + return {key: profile[key] for key in ("replan_after_completed_todos", "replan_after_effective_turns") if key in profile} + + +def bounded_guard_source(obligation, cadence): + import copy, json + from ..work_items.autonomous_replan_obligation import ensure_replan_novelty_policy + if not isinstance(obligation, dict) or not obligation.get("obligation_id"): + return None + value = copy.deepcopy(obligation) + context = value.pop("replan_context", None) + # The current native history remains the novelty ledger. Receipt source + # stores the complete obligation identity/triggers, not a partial ledger schema. + if ensure_replan_novelty_policy(value)["obligation_id"] != value["obligation_id"]: + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: obligation codec mismatch") + if len(json.dumps(value, ensure_ascii=False).encode()) > 65536: + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: bounded source exceeds 64KiB") + return {"schema_version": "semantic_replan_source_v1", "obligation": value, + "sha256": _guard_source_digest(value), "cadence": dict(cadence)} + + +def validate_guard_source(source, obligation_id): + from ..work_items.autonomous_replan_obligation import ensure_replan_novelty_policy + if not isinstance(source, dict) or source.get("schema_version") != "semantic_replan_source_v1": + raise HeartbeatReceiptIdentityConflictError("guard_source_missing: owner must adopt the exact original guard packet; do not rerun with a new obligation ID") + value = source.get("obligation") + if (not isinstance(value, dict) or value.get("obligation_id") != obligation_id or + _guard_source_digest(value) != source.get("sha256") or + ensure_replan_novelty_policy(value)["obligation_id"] != obligation_id): + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: exact source digest/obligation mismatch") + return source + + +def guard_source_for_turn(runtime_root, *, goal_id, agent_id, turn_instance_id, goal_ref=None): + event = find_heartbeat_receipt(Path(runtime_root), goal_id=goal_id, agent_id=agent_id, + turn_instance_id=turn_instance_id, goal_ref=goal_ref) + if not event: + return None + oid = heartbeat_receipt_semantic_replan_obligation_id(event) + if not oid: + return None + return validate_guard_source((event.get("details") or {}).get("semantic_replan_source"), oid) + + + + +def store_guard_source_blob(runtime_root,goal_id,source): + import os,json,hashlib + raw=json.dumps(source,ensure_ascii=False,sort_keys=True,separators=(",",":")).encode("utf-8") + digest=hashlib.sha256(raw).hexdigest() + path=rollout_event_log_path(Path(runtime_root),goal_id).parent/"heartbeat-guard-sources"/(digest+".json") + path.parent.mkdir(parents=True,exist_ok=True) + try: + with path.open("xb") as f:f.write(raw);f.flush();os.fsync(f.fileno()) + except FileExistsError: + if path.read_bytes()!=raw:raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: immutable blob conflict") + return digest + + +def read_guard_source_blob(runtime_root,goal_id,ref): + import re,json,hashlib + if not isinstance(ref,str) or not re.fullmatch(r"[0-9a-f]{64}",ref): + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: malformed source ref") + path=rollout_event_log_path(Path(runtime_root),goal_id).parent/"heartbeat-guard-sources"/(ref+".json") + try:raw=path.read_bytes() + except OSError:raise HeartbeatReceiptIdentityConflictError("guard_source_missing: immutable source blob unavailable") from None + if hashlib.sha256(raw).hexdigest()!=ref: + raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: stored source bytes changed") + return json.loads(raw) diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index c8af6e877a..579de517ff 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -653,7 +653,11 @@ def build_live_quota_should_run_decision( goal_id=goal_id, agent_id=agent_id, ) - payload: dict[str, Any] = build_quota_should_run( + from .heartbeat_receipt import guard_source_for_turn + retained_source = guard_source_for_turn(runtime_root, goal_id=goal_id, agent_id=agent_id, + turn_instance_id=turn_instance_id, goal_ref=goal_ref) if agent_id and turn_instance_id else None + decision_status_payload = {**decision_status_payload, "_receipt_bound_semantic_source": (retained_source or {}).get("obligation")} + payload = build_quota_should_run( decision_status_payload, goal_id=goal_id, agent_id=agent_id, diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index ea9fa08664..acb6d3df82 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -158,6 +158,11 @@ def reconcile_existing_heartbeat_receipt( if upgraded: receipt_status = "upgraded" receipt_appended = True + from .heartbeat_receipt import find_heartbeat_receipt + refreshed = find_heartbeat_receipt(runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, + turn_instance_id=turn_instance_id, goal_ref=goal_ref) + if refreshed and refreshed.get("event_id") == receipt.get("event_id"): + receipt = refreshed details_value = receipt.get("details") details: Mapping[str, object] = ( details_value if isinstance(details_value, Mapping) else {} @@ -451,6 +456,15 @@ def quota_rollout_details( details["semantic_replan_capability_id"] = capability_guard["capability_id"] details["semantic_replan_gap_id"] = capability_guard["gap_id"] details["semantic_replan_frontier_revision"] = capability_guard["frontier_revision"] + if semantic_replan_obligation_id: + from .heartbeat_receipt import bounded_guard_source, guard_cadence, store_guard_source_blob + source_obligation = payload.get("autonomous_replan_obligation") + if (isinstance(source_obligation, dict) and source_obligation.get("obligation_id") == semantic_replan_obligation_id + and not source_obligation.get("settlement_action_packet") + and not (payload.get("replan_action_packet") or {}).get("settlement_only")): + source = bounded_guard_source(source_obligation, + guard_cadence(payload["registry"], payload["runtime_root"], args.goal_id)) + details["semantic_replan_source_ref"] = store_guard_source_blob(payload["runtime_root"],args.goal_id,source) cli_channel = interaction.get("cli_channel") if isinstance(cli_channel, Mapping) and cli_channel.get("quota_spend_source"): details["quota_spend_source"] = cli_channel["quota_spend_source"] diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index 3b4fd8036e..1cb92da105 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -275,11 +275,17 @@ def _prepare_quota_replan_context( receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, ) + retained_semantic_source = status_payload.get("_receipt_bound_semantic_source") + if isinstance(retained_semantic_source, dict): + from ..work_items.progress_observation import build_replan_context + retained_semantic_source = {**retained_semantic_source, "replan_context": build_replan_context( + retained_semantic_source, goal_id=safe_goal_id, agent_id=agent_frontier_id, + newest_first_runs=_goal_latest_runs(status_payload, goal_id=safe_goal_id), source_status=status_payload)} replan_obligation = ( None if receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLED else _preserve_receipt_bound_replan_obligation( - goal_frontier_context.get("replan_obligation"), + retained_semantic_source or goal_frontier_context.get("replan_obligation"), receipt_bound_replan_obligation_id, guard_scoped=receipt_bound_replan_guard_scoped, agent_id=agent_frontier_id, diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index 7a5165230b..c12a04eb86 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -233,6 +233,7 @@ def qualify_replan_writeback( receipt_bound_replan_review: Mapping[str, Any] | None = None, replan_source_route: Mapping[str, Any] | None = None, replan_explore_reader: Callable[[str, str | None], Mapping[str, Any] | None] | None = None, + receipt_bound_source: dict[str, Any] | None = None, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Return the shared open obligation and the writeback's typed delta. @@ -374,6 +375,20 @@ def qualify_replan_writeback( context.get("replan_transition_candidates", []) if isinstance(candidate.get("obligation"), dict) and candidate["obligation"].get("obligation_id") == guard_semantic_replan_obligation_id), None) + if receipt_bound_source is not None and guard_scoped: + if receipt_bound_source.get("obligation_id") != guard_semantic_replan_obligation_id: + raise ValueError("guard_source_invalid: writeback source identity differs") + if not lifecycle: + obligation = receipt_bound_source + if (receipt_bound_source.get("resolution_mode") == "todo_lifecycle_settlement" + and receipt_bound_guard_recorded_at and agent_todo_source_items): + from ..effect_runtime import effect_runtime_result + obligation = effect_runtime_result("work_item.replan_semantics.project", { + "operation": "lifecycle_obligation", "obligation": receipt_bound_source, + "selected_obligation_id": guard_semantic_replan_obligation_id, + "guard_recorded_at": receipt_bound_guard_recorded_at, + "agent_id": safe_agent_id, "items": agent_todo_source_items, + })["obligation"] # Every selected replan retains its exact receipt-bound source. A missing # reconstruction must not fall through as an unselected future obligation # and commit an unqualified writeback. Plain outcome reviews additionally @@ -452,6 +467,7 @@ def enforce_open_replan_writeback( guard_semantic_replan_obligation_id: str | None = None, receipt_bound_guard_recorded_at: str | None = None, receipt_bound_replan_review: Mapping[str, Any] | None = None, + receipt_bound_source: dict[str, Any] | None = None, todo_fields: dict[str, Any] | None = None, external_progress_review: Mapping[str, Any] | None = None, capability_evidence: CapabilityReplanEvidence | None = None, @@ -489,6 +505,7 @@ def enforce_open_replan_writeback( receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, receipt_bound_replan_review=receipt_bound_replan_review, replan_source_route=replan_source_route, replan_explore_reader=replan_explore_reader, + receipt_bound_source=receipt_bound_source, ) if not obligation: if isinstance(semantic_delta, dict) and semantic_delta.get("accepted") is True: @@ -536,6 +553,7 @@ def qualify_refresh_replan_writeback( settlement_guard_semantic_replan_obligation_id: str | None, receipt_bound_guard_recorded_at: str | None = None, receipt_bound_replan_review: Mapping[str, Any] | None = None, + receipt_bound_source: dict[str, Any] | None = None, newest_first_runs: list[dict[str, Any]], state_text: str, goal_id: str, @@ -623,6 +641,7 @@ def qualify_refresh_replan_writeback( receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, receipt_bound_replan_review=receipt_bound_replan_review, replan_source_route=replan_source_route, replan_explore_reader=replan_explore_reader, + receipt_bound_source=receipt_bound_source, ) if semantic_delta: if semantic_delta.get("retirement") and delivery_outcome != "outcome_gap": diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 34014865a8..62af1c025f 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1143,6 +1143,11 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): review_pages=((agent_vision or {}).get("stride_decision") or {}).get( "explore_review", {}).get("result_pages")) + from .control_plane.quota.heartbeat_receipt import guard_source_for_turn, guard_cadence + retained_source = guard_source_for_turn(runtime_root, goal_id=safe_goal_id, + agent_id=normalized_agent_id, turn_instance_id=turn_instance_id, goal_ref=goal_ref) if settlement_readback else None + # Retained identity cannot override a later explicit policy revision. + usable_source = retained_source if retained_source and retained_source.get("cadence") == guard_cadence(registry_path,runtime_root,safe_goal_id) else None from .control_plane.goals.checkpoint_context_io import _checkpoint_effect replan_stage = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { "phase": "replan_stage", "heartbeat_receipt": settlement_readback.heartbeat_receipt if settlement_readback else None, @@ -1157,6 +1162,8 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): capability_evidence=capability_evidence, guard_capability=settlement_replan_guard.get("selected_capability_guard"), todo_fields=todo_fields, + receipt_bound_source=(usable_source or {}).get("obligation"), + receipt_bound_guard_recorded_at=(settlement_readback.heartbeat_receipt or {}).get("recorded_at") if settlement_readback else None, autonomous_replan_recorded=replan_stage["qualify"], requested_delta_kinds=normalized_repair_delta_kinds, active_state_next_action_update=None, From 0794acb824ac2989060f1fb391bbd7a1564fc5c5 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 19 Oct 2026 23:25:07 +0800 Subject: [PATCH 18/80] Keep committed monitor polls out of unbound Turn debt diagnostics Signed-off-by: Tartar --- .../prior_monitor_closeout.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/control_plane_ts/prior_monitor_closeout.test.ts diff --git a/tests/control_plane_ts/prior_monitor_closeout.test.ts b/tests/control_plane_ts/prior_monitor_closeout.test.ts new file mode 100644 index 0000000000..a6fd70c7aa --- /dev/null +++ b/tests/control_plane_ts/prior_monitor_closeout.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import {mkdtemp, mkdir, readFile, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; +import {missingCloseoutReceipts} from "../../loopx/control_plane/quota/prior_turn_closeout.ts"; +import {QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA,readQuotaSettlement} from "../../loopx/control_plane/quota/settlement_readback.ts"; + +const missing={result:{value:null,receipts:[],failure:{kind:"writeback_missing"}},payload:{ok:false}}; +const base={found:true,settlement:missing,writeback:missing,spend:missing,monitor_phase:null,replay_phase:"open"}; +const both=["durable_writeback_receipt","quota_spend_receipt"]; + +test("monitor phases cannot bypass the guard bound-Todo verification",()=>{ + assert.deepEqual(missingCloseoutReceipts({...base,monitor_phase:"settled",replay_phase:"settled"}),both); +}); +for(const [name,patch] of [ + ["no monitor",{replay_phase:"settled"}], + ["pending replay",{monitor_phase:"settled",replay_phase:"settlement_pending"}], + ["pending monitor",{monitor_phase:"settlement_pending",replay_phase:"settled"}], + ["truthy monitor",{monitor_phase:true,replay_phase:"settled"}], + ["truthy replay",{monitor_phase:"settled",replay_phase:true}], + ["missing identity",{found:false,monitor_phase:"settled",replay_phase:"settled"}], +] as const) test(`missing monitor proof remains owed: ${name}`,()=>{ + assert.deepEqual(missingCloseoutReceipts({...base,...patch}),both); +}); +test("ordinary validated settlement retains its existing closeout",()=>{ + assert.equal(missingCloseoutReceipts({...base,settlement:{payload:{ok:true}}}),null); +}); +test("malformed ordinary readback does not become an exemption",()=>{ + assert.throws(()=>missingCloseoutReceipts({...base,settlement:{}}),/no settlement result/); +}); + +const goal="monitor-owner-fixture",agent="owner",todo="todo_monitor",old="old-turn",current="current-turn"; +const effect=`quota-monitor-poll:${goal}:${agent}:${old}`; +const variants:[string,Record,boolean][]=[ + ["exact Todo commit",{},true], + ["legacy exact Turn commit",{quota_monitor_poll_commit:{effect_id:effect}},true], + ["missing commit",{quota_monitor_poll_commit:null},false], + ["wrong effect",{quota_monitor_poll_commit:{effect_id:`${effect}:todo:todo_other`}},false], + ["wrong agent",{agent_id:"foreign"},false], + ["wrong Goal",{goal_id:"foreign"},false], + ["wrong Todo",{todo_id:"todo_other"},false], + ["wrong Turn",{turn_instance_id:"foreign"},false], + ["uncommitted observation",{classification:"state_refreshed"},false], +]; +for(const [name,patch,settled] of variants) test(`persisted owning monitor readback: ${name}`,async()=>{ + const root=await mkdtemp(join(tmpdir(),"loopx-neutral-prior-")); + const dir=join(root,"goals",goal),index=join(dir,"runs/index.jsonl"),events=join(dir,"rollout-event-log.jsonl"); + try { + await mkdir(join(dir,"runs"),{recursive:true}); + const rows=[{schema_version:"loopx_rollout_event_v0",event_id:"original-guard",event_kind:"quota_should_run", + goal_id:goal,agent_id:agent,run_id:old,details:{todo_id:todo,closeout_required:true, + settlement_effect_id:`${goal}:${agent}:${todo}:${old}`}}, + {schema_version:"loopx_rollout_event_v0",event_id:"current-guard",event_kind:"quota_should_run", + goal_id:goal,agent_id:agent,run_id:current,details:{}}]; + const poll={classification:"quota_monitor_poll",material_change:false,delivery_outcome:"surface_only", + goal_id:goal,agent_id:agent,todo_id:todo,turn_instance_id:old, + quota_monitor_poll_commit:{effect_id:`${effect}:todo:${todo}`},...patch}; + await writeFile(events,rows.map(row=>JSON.stringify(row)).join("\n")+"\n"); + await writeFile(index,JSON.stringify(poll)+"\n"); + const before=await Promise.all([readFile(events),readFile(index)]); + const request={schema_version:QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA,runtime_root:root, + goal_id:goal,agent_id:agent,todo_id:todo,turn_instance_id:old, + infer_turn_instance_id:false,allow_unbound_binding:false}; + const original=await readQuotaSettlement(request); + assert.equal(original.monitor_phase,settled?"settled":"poll_due"); + assert.equal(original.replay_phase,settled?"settled":"open"); + assert.equal((original.spend as any).payload.ok,false,"A monitor never fabricates a debit receipt"); + assert.equal((original.writeback as any).payload.ok,false,"Observation is not an accountable work writeback"); + assert.deepEqual(missingCloseoutReceipts(original),both,"The guard retains its bound-fact checkpoint"); + const unbound=await readQuotaSettlement({...request,todo_id:"todo_product",turn_instance_id:current}); + const failure=(unbound.settlement as any).result.failure; + assert.equal(failure.kind,"receipt_unbound"); + if(settled){ + assert.equal(failure.details.owed_prior_turn,undefined,"A committed no-spend monitor cannot block replan binding"); + assert.doesNotMatch(failure.reason,/earlier Turn owes/); + }else{ + assert.equal(failure.details.owed_prior_turn.prior_turn_instance_id,old,"Unproved original work must remain owed"); + } + const after=await Promise.all([readFile(events),readFile(index)]); + assert.deepEqual(after,before,"Readback must not rewrite records,acknowledge or spend"); + }finally{await rm(root,{recursive:true,force:true});} +}); From f0025cf2b195cf302169ac968a4bec25102d9778 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 19 Oct 2026 23:46:20 +0800 Subject: [PATCH 19/80] Publish freshness recovery regressions and owning packaged checkpoint smoke Signed-off-by: Tartar --- .../checkpoint-recovery-browser-smoke.mjs | 122 ++++++++++ .../smoke/checkpoint-recovery-http-fixture.py | 157 ++++++++++++ .../test_default_terminal_admission_gates.py | 125 ++++++++++ .../test_default_terminal_frontier.py | 228 ++++++++++++++++++ .../test_receipt_bound_lifecycle_recovery.py | 162 +++++++++++++ 5 files changed, 794 insertions(+) create mode 100644 apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs create mode 100644 apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py create mode 100644 tests/control_plane/test_default_terminal_admission_gates.py create mode 100644 tests/control_plane/test_default_terminal_frontier.py create mode 100644 tests/control_plane/test_receipt_bound_lifecycle_recovery.py diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs new file mode 100644 index 0000000000..739e4a6edc --- /dev/null +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs @@ -0,0 +1,122 @@ +// Actual packaged checkpoint controls + production owning HTTP/store/controller. +// Workspace navigation is synthetic; target /loopx responses are never fabricated. +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {createHash} from "node:crypto"; +import {mkdir, readFile, writeFile} from "node:fs/promises"; +import {resolve} from "node:path"; +import {createInterface} from "node:readline"; +import {createServer} from "node:net"; +import {resolveTestPython} from "../../../../scripts/test-python.mjs"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +const at=process.argv.indexOf("--output"); +assert.ok(at>=0 && process.argv[at+1], "Declare a fresh evidence output directory"); +const out=resolve(process.argv[at+1]); +await mkdir(out,{recursive:true}); +const probe=createServer();probe.listen(0,"127.0.0.1");await once(probe,"listening"); +process.env.LOOPX_PERSONAL_WORKSPACE_PORT=String(probe.address().port); +await new Promise((done,reject)=>probe.close(error=>error?reject(error):done())); +process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED="1"; +const {repoRoot,port,startServer}=await import("../../../../examples/personal-workspace-browser/fixture.mjs"); +const {openWorkspacePage}=await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const fixture=spawn(resolveTestPython({repoRoot}),["-u","apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py",resolve(out,"backend")], + {cwd:repoRoot,stdio:["pipe","pipe","pipe"],windowsHide:true}); +const exited=once(fixture,"exit"); +const lines=createInterface({input:fixture.stdout});const iterator=lines[Symbol.asyncIterator](); +let diagnostic="";fixture.stderr.on("data",chunk=>{diagnostic+=String(chunk);}); +async function next(){const line=await iterator.next();assert.equal(line.done,false,diagnostic);return JSON.parse(line.value);} +async function command(value){fixture.stdin.write(`${value}\n`);return next();} +async function artifact(path){return {path,sha256:createHash("sha256").update(await readFile(path)).digest("hex")};} +let server,browser,workspace; +const requests=[]; +try { + const {origin,session,original_identity}=await next(); + server=await startServer(); + const url=`http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; + await waitForHttp(url); + browser=await launchBrowser(loadPlaywright().chromium); + workspace=await openWorkspacePage({newPage:options=>browser.newPage({locale:"zh-CN",...options})},url,{ + beforeGoto:async(_api,page)=>{ + page.__loopxRuntime.sessions.set(session.session_id,session); + // Only the unrelated workspace/navigation uses the existing synthetic API. + // Every target operation, including reads across reload, uses its owning server. + await page.route(`**/api/chat/sessions/${session.session_id}/loopx`,async route=>{ + const request=route.request(); + const body=request.method()==="POST"?request.postDataJSON():null; + const response=await route.fetch({url:`${origin}/api/chat/sessions/${session.session_id}/loopx`}); + requests.push({method:request.method(),body,status:response.status(),response:await response.json()}); + await route.fulfill({response}); + }); + }, + }); + const {page}=workspace; + await page.locator(".personal-goal-link",{hasText:"Product Release"}).click(); + await page.getByRole("navigation",{name:"Goal 视图"}).getByRole("button",{name:"对话",exact:true}).click(); + const section=page.getByRole("region",{name:"LoopX 运行模式"}); + const retained=page.getByText("LoopX · 结果已保留,待协调员核验",{exact:true}); + await retained.waitFor({timeout:60000}); + const before=await command("inspect"); + assert.deepEqual(before.counts,{result:1,debit:0});assert.equal(before.dispatches.length,0); + await page.reload();await retained.waitFor({timeout:60000}); + const reloaded=await command("inspect"); + assert.deepEqual(reloaded.counts,before.counts);assert.equal(reloaded.dispatches.length,0); + const writes=()=>requests.filter(row=>row.method==="POST" && !["operations","inspect"].includes(row.body?.operation)); + assert.equal(writes().length,0,"Reload must not dispatch or mutate execution"); + const button=page.getByRole("button",{name:"继续核验",exact:true}); + await button.scrollIntoViewIfNeeded(); + const screenshots=[]; + let path=resolve(out,"retained-desktop.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + await button.click(); + let after;const deadline=Date.now()+300000; + while(Date.now()setTimeout(done,500)); + } + assert.deepEqual(after.counts,{result:1,debit:1});assert.equal(after.dispatches.length,1); + assert.equal(after.result_sha256,before.result_sha256); + assert.equal(after.upstream_thread_id,before.upstream_thread_id,"Continue retains the original SDK session"); + const posted=writes(); + assert.equal(posted.length,1);assert.equal(posted[0].body.operation,"resume");assert.equal(posted[0].status,200); + await page.reload(); + await page.getByText("LoopX · 已暂停",{exact:true}).waitFor({timeout:60000}); + assert.equal(await retained.count(),0,"Settled native readback clears the pending-result label"); + const replayed=await command("replay"); + assert.deepEqual(replayed.counts,after.counts);assert.equal(replayed.dispatches.length,1); + assert.deepEqual(replayed.replayed_identity,original_identity); + assert.equal(replayed.result_sha256,before.result_sha256); + await page.setViewportSize({width:390,height:844});await page.emulateMedia({reducedMotion:"reduce"}); + await page.getByRole("button",{name:"恢复推进",exact:true}).scrollIntoViewIfNeeded(); + path=resolve(out,"settled-narrow.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + const saved=await command("save"); + assert.equal(writes().length,1); + assert.equal(workspace.errors.length,0,workspace.errors.join("\n")); + await writeFile(resolve(out,"http-observations.json"),JSON.stringify(requests,null,2)); + const proof={packaged:true,target_http_mocked:false,transport:"deterministic_sdk",paid_calls:0, + non_owning_shell_fixture:"existing personal-workspace navigation/status API; target sessions/loopx uses production owner", + browser_version:browser.version(),clicked_continue_review:true,reloads:2, + original_identity,settled_identity:saved.settled_identity,replayed_identity:replayed.replayed_identity, + result_sha256_before:before.result_sha256,result_sha256_after:after.result_sha256, + business_counts_before:before.counts,business_counts_after:after.counts,business_counts_replayed:replayed.counts, + direction_dispatches:saved.dispatches.length,implementation_dispatches:0, + session_id_before:before.session_id,session_id_after:saved.session_id, + upstream_thread_id_before:before.upstream_thread_id,upstream_thread_id_after:saved.upstream_thread_id, + errors:workspace.errors,screenshots,native_evidence:[]}; + for(const name of ["before.json","first-result.json","inspect.json","replay.json","save.json"]) + proof.native_evidence.push(await artifact(resolve(out,"backend",name))); + proof.native_evidence.push(await artifact(resolve(out,"http-observations.json"))); + await writeFile(resolve(out,"proof.json"),JSON.stringify(proof,null,2)); + console.log("checkpoint-recovery: passed (packaged click/reload, production owning HTTP/controller/native checkpoint, original identity/session/result, one debit and zero replay increments; deterministic SDK)"); +} catch(error){ + await writeFile(resolve(out,"http-observations-failed.json"),JSON.stringify(requests,null,2)); + await writeFile(resolve(out,"failure.txt"),`${String(error)}\n${diagnostic}`); + if(workspace)await writeFile(resolve(out,"failure-body.txt"),await workspace.page.locator("body").innerText()); + throw error; +} finally { + await workspace?.close();await browser?.close();server?.kill("SIGTERM"); + fixture.stdin.end();if(fixture.exitCode===null)await exited;lines.close(); + await writeFile(resolve(out,"backend-stderr.txt"),diagnostic); +} diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py new file mode 100644 index 0000000000..64d6f2ff3a --- /dev/null +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py @@ -0,0 +1,157 @@ +"""Isolated production checkpoint owner with a deterministic SDK transport. + +Invoked from the repository root by the packaged browser smoke. No paid model, +real Lark, active operator Goal or user browser profile is used. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import sys +import threading +from types import SimpleNamespace + +ROOT = Path.cwd() +sys.path[:0] = [str(ROOT), str(ROOT/'tests'), str(ROOT/'tests/control_plane')] +import pytest +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_store import ChatSessionStore +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.todos import add_goal_todo +from tests.control_plane import test_default_checkpoint_continuation as native +from tests import test_goal_mode_mcp_settlement as mcp + + +def digest(value): + return hashlib.sha256(json.dumps(value,sort_keys=True).encode()).hexdigest() + + +def main(): + out=Path(sys.argv[1]).resolve();out.mkdir(parents=True,exist_ok=False) + isolated=out/'native';isolated.mkdir() + patches=pytest.MonkeyPatch() + goal_id,agent_id='product-release','lead' + for module in (native,mcp): + patches.setattr(module,'GOAL_ID',goal_id) + patches.setattr(module,'AGENT_ID',agent_id) + registry,state,todo=native._fixture('file',isolated,patches) + payload=json.loads(registry.read_text());goal=payload['goals'][0] + goal['domain']='Checkpoint recovery demonstration' + goal['coordination']['registered_agents']=[agent_id,'reviewer','codex'] + goal['spawn_policy']={'mode':'multi_subagent','allowed':True,'max_children':2, + 'execution_config':'.loopx/config/delegations.json'} + registry.write_text(json.dumps(payload),encoding='utf-8') + project=state.parent + config=project/'.loopx/config/delegations.json';config.parent.mkdir(parents=True,exist_ok=True) + config.write_text(json.dumps({'schema_version':'loopx_local_delegation_v0','bindings':[{ + 'id':'review','agent_id':'reviewer','todo_id':'review-task','requesters':[agent_id], + 'workspace':str(project),'host_args':['--host','dsh'],'timeout_seconds':60, + 'output_refs':['output.json']}]}),encoding='utf-8') + control=mcp._control(registry) + original_guard=json.loads(control.should_run()) + first=json.loads(control.complete_task(todo,agent_id,'Candidate verified.',no_follow_up=True)) + assert first['stage']=='direction_pending' and first['completed'] is True + original=first['settlement_identity'] + runtime_root=isolated/'runtime' + index=runtime_root/'goals'/goal_id/'runs/index.jsonl' + store=ChatSessionStore(runtime_root) + sid='session-goal-product-release-codex' + store.create_session(goal_id=goal_id,agent_id='codex',channel_id='goal.product-release', + upstream_thread_id='synthetic-original-sdk',session_id=sid, + adapter_kind='codex_app_server',upstream_mode='chat') + settings={'agent_id':agent_id,'token_budget':10000,'execution_config_ref':'.loopx/config/delegations.json', + 'config_digest':hashlib.sha256(config.read_bytes()).hexdigest()} + store.update_session(sid,loopx_tools=True,native_goal={'status':'paused','tokensUsed':0}, + loopx_mode={'enabled':True,'paused':True,'settings':settings}) + controller=ChatRuntimeController(store=store,codex_bin='missing-codex',registry_path=registry) + adapter=SimpleNamespace(goal_driver=SimpleNamespace(stopped=threading.Event()), + session=SimpleNamespace(),upstream_thread_id='synthetic-original-sdk', + close_session=lambda:None,healthcheck=lambda:True) + patches.setattr(controller,'_check_codex_home',lambda _:None) + patches.setattr(controller,'_ensure_adapter_locked',lambda *a,**k:adapter) + controller.adapters[sid]=adapter + dispatches=[]; failures=[]; decisions=[] + + def native_snapshot(): + items=read_canonical_todos_if_promoted(runtime_root=runtime_root,goal_id=goal_id)['todos'] + row=next(item for item in items if item['todo_id']==todo) + facts={key:row.get(key) for key in ('todo_id','status','evidence','completed_at','completion_turn_key')} + events=[json.loads(line) for line in index.read_text(encoding='utf-8').splitlines()] if index.exists() else [] + return {'facts':facts,'result_sha256':digest(facts), + 'counts':{'result':int(row['status']=='done' and row['evidence']=='Candidate verified.'), + 'debit':sum(e.get('classification')=='quota_slot_spent' for e in events)}, + 'session_id':store.load_session(sid)['session_id'], + 'upstream_thread_id':store.load_session(sid)['upstream_thread_id'], + 'native_events':events,'dispatches':list(dispatches),'decisions':list(decisions)} + + def provider(**kwargs): + dispatches.append({'session_id':kwargs['session_id'],'turn_id':kwargs['turn_id'], + 'upstream_thread_id':adapter.upstream_thread_id,'phase':'checkpoint_direction'}) + lock=None + try: + assert kwargs['session_id']==sid and kwargs['loopx_execution'] is True + lock=controller.loopx_mode.prepare(sid,kwargs['turn_id'],adapter,lambda *a:{},lambda *a:None) + read=adapter.session.read_tool_handler('loopx_collaboration',{ + 'action':'read','operation_id':todo,'brief':{'purpose':'first_delivery'}}) + assert read['ok'] and read['settlement_identity']==original,read + decision=adapter.session.read_tool_handler('loopx_collaboration',{ + 'action':'resume','operation_id':todo,'brief':{'purpose':'first_delivery', + 'read_context_id':read['read_context_id'], + 'vision_unchanged_reason':'The retained candidate and freshly observed remaining obligations preserve the route.'}}) + assert decision['ok'] and decision['settlement_complete'] and decision['settlement_identity']==original,decision + decisions.append({'read':read,'settlement':decision}) + store.update_turn(sid,kwargs['turn_id'],status='completed',response={'text':'Original checkpoint settled.'}) + store.release_active_turn(sid,kwargs['turn_id'],last_activity_at='2026-10-19T00:00:00Z',last_error_code=None) + store.update_session(sid,loopx_mode={'enabled':True,'paused':True,'settings':settings}) + except Exception as exc: + failures.append(repr(exc));raise + finally: + if lock:lock.__exit__(None,None,None) + + patches.setattr(controller,'_run_started_turn',provider) + server=ChatHTTPServer(('127.0.0.1',0),ChatRequestHandler) + server.verbose=False;server.registry_path=registry;server.runtime_root=runtime_root + server.chat_store=store;server.runtime_controller=controller + http=[] + send=ChatRequestHandler._send_json + def observed_send(handler,body,*,status=200): + if handler.path.startswith('/api/chat/sessions/') and handler.path.endswith('/loopx'): + http.append({'method':handler.command,'path':handler.path,'status':status,'response':body}) + return send(handler,body,status=status) + patches.setattr(ChatRequestHandler,'_send_json',observed_send) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + baseline=native_snapshot(); assert baseline['counts']=={'result':1,'debit':0} + (out/'before.json').write_text(json.dumps(baseline,indent=2),encoding='utf-8') + (out/'first-result.json').write_text(json.dumps(first,indent=2),encoding='utf-8') + print(json.dumps({'origin':f'http://127.0.0.1:{server.server_port}','session':store.load_session(sid), + 'original_identity':original}),flush=True) + try: + for line in sys.stdin: + command=line.strip() + if command=='inspect': + value=native_snapshot();value['failures']=list(failures) + elif command=='replay': + assert len(decisions)==1 and not failures + from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints + checkpoint=CoordinatorCheckpoints(registry,runtime_root,goal_id,agent_id) + # Replay the exact successful decision, without another SDK dispatch. + decision=decisions[0] + repeated=checkpoint.resume(todo,{'read_context_id':decision['read']['read_context_id'], + 'vision_unchanged_reason':'The retained candidate and freshly observed remaining obligations preserve the route.'}) + assert repeated['ok'] and repeated['settlement_identity']==original,repeated + value={**native_snapshot(),'replayed_identity':repeated['settlement_identity'],'replay':repeated} + elif command=='save': + assert not failures,failures + value={**native_snapshot(),'http':http,'original_identity':original, + 'settled_identity':decisions[0]['settlement']['settlement_identity']} + else:raise ValueError('Unknown fixture command') + (out/(command+'.json')).write_text(json.dumps(value,indent=2),encoding='utf-8') + print(json.dumps(value),flush=True) + finally: + server.shutdown();server.server_close();thread.join(timeout=5) + controller.close();patches.undo() + + +if __name__=='__main__':main() diff --git a/tests/control_plane/test_default_terminal_admission_gates.py b/tests/control_plane/test_default_terminal_admission_gates.py new file mode 100644 index 0000000000..782746123f --- /dev/null +++ b/tests/control_plane/test_default_terminal_admission_gates.py @@ -0,0 +1,125 @@ +"""Native terminal admission gates; all mutations target disposable fixture state.""" +from collections import Counter +from copy import deepcopy +import inspect +import json +from pathlib import Path +import subprocess +import sys +from urllib.parse import quote + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT/'tests/control_plane')) + +from loopx.configure_goal import configure_goal +from tests.control_plane.test_default_turn_recovery import ( + _managed_recovery_call, _business_rows, _work_counts, _original_checkpoint_receipt, +) +from tests.test_goal_mode_mcp_settlement import GOAL_ID +import tests.control_plane.test_declared_terminal_settlement_cli as declared + + +def _assert_preserved(before, after): + assert after == before, 'A denied operation repeated work or successful business effects' + + +def test_negative_counter_fixture_rejected(): + before = {'work': {'implementation.txt': 1}, 'effects': {'quota_slot_spent': 1}} + for key in ('work', 'effects'): + broken = deepcopy(before) + broken[key][next(iter(broken[key]))] += 1 + with pytest.raises(AssertionError): + _assert_preserved(before, broken) + + +def _original(provider, tmp_path, monkeypatch): + state, project, args, injection = _managed_recovery_call(provider, 'result', tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True, timeout=90) + assert lost.returncode == 97, lost.stdout+lost.stderr + runtime = tmp_path/'runtime' + journal_path = next(p for p in (runtime/'goals'/GOAL_ID/'turns').glob('*.json') if not p.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + assert journal['host_result']['result_kind'] == 'validated_completion' + resume = [*args, '--resume-turn-key', journal['turn_key'], '--retry-failed-turn'] + registry = Path(args[args.index('--registry')+1]) + return state, project, runtime, registry, journal_path, journal, resume + + +def _snapshot(project, runtime): + return {'work': _work_counts(project), 'effects': _business_rows(runtime), + 'candidate': (project/'candidate.txt').read_bytes()} + + +def _resume(args): + return subprocess.run([sys.executable, '-m', 'loopx.cli', *args], capture_output=True, text=True, timeout=90) + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('gate', ['pause', 'stop']) +def test_pending_result_respects_explicit_gate_and_original_resume(provider, gate, tmp_path, monkeypatch): + state, project, runtime, registry, journal_path, journal, resume = _original(provider, tmp_path, monkeypatch) + before = _snapshot(project, runtime) + assert before['work'] == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 0} + source_before = state.read_bytes() + registry_before = registry.read_bytes() + if gate == 'pause': + changed = configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=0, execute=True) + assert changed['written'] + else: + doc = json.loads(registry.read_text()) + doc['goals'][0]['activation'] = {'schema_version': 'loopx_goal_activation_v1', 'state': 'stopped', + 'updated_at': '2026-10-11T00:00:00Z', 'reason': 'Fixture owner stops pending execution.'} + registry.write_text(json.dumps(doc), encoding='utf-8') + denied = _resume(resume) + assert denied.returncode != 0, denied.stdout+denied.stderr + _assert_preserved(before, _snapshot(project, runtime)) + retained = json.loads(journal_path.read_text()) + assert retained['turn_key'] == journal['turn_key'] + assert retained['host_result'] == journal['host_result'] + assert state.read_bytes() == source_before + if gate == 'pause': + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=1, execute=True)['written'] + else: + registry.write_bytes(registry_before) + recovered = _resume(resume) + assert recovered.returncode == 0, recovered.stdout+recovered.stderr + value = json.loads(recovered.stdout) + assert value['status'] == 'committed' and value['resume_turn_key'] == journal['turn_key'] + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + assert _business_rows(runtime) == Counter({'candidate_verified': 1, 'quota_slot_spent': 1}) + after = _snapshot(project, runtime) + replay = _resume(resume) + assert replay.returncode == 0, replay.stdout+replay.stderr + _assert_preserved(after, _snapshot(project, runtime)) + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +def test_real_direction_permission_signal_retains_result_and_blocks_retry(provider, tmp_path, monkeypatch): + _, project, runtime, _, journal_path, journal, resume = _original(provider, tmp_path, monkeypatch) + host = project/'host.py' + source = host.read_text() + needle = " c=r['checkpoint_context']; assert c['basis']['todo']['status']=='done'" + assert needle in source + source = source.replace(needle, " count('permission_requests.txt')\n json.dump({'result_kind':'user_action_required'},sys.stdout); sys.exit(0)\n"+needle) + host.write_text(source, encoding='utf-8') + before = _snapshot(project, runtime) + first = _resume(resume) + assert first.returncode != 0, first.stdout+first.stderr + _assert_preserved(before, _snapshot(project, runtime)) + receipt = _original_checkpoint_receipt(runtime) + assert receipt['direction_stopped'] is True + assert 'current_agent_decision' not in receipt + assert receipt['decision_basis']['todo']['status'] == 'done' + assert (project/'permission_requests.txt').read_text() == '1\n' + second = _resume(resume) + assert second.returncode != 0, second.stdout+second.stderr + _assert_preserved(before, _snapshot(project, runtime)) + assert (project/'permission_requests.txt').read_text() == '1\n' + assert json.loads(journal_path.read_text())['host_result'] == journal['host_result'] + + +# The shipped fixture now explicitly covers all three actual providers. +test_native_hard_lease_and_failed_validator_preserve_original_declaration = declared.test_original_lease_and_failed_validator_remain_required_before_accounting diff --git a/tests/control_plane/test_default_terminal_frontier.py b/tests/control_plane/test_default_terminal_frontier.py new file mode 100644 index 0000000000..2a929e4bdc --- /dev/null +++ b/tests/control_plane/test_default_terminal_frontier.py @@ -0,0 +1,228 @@ +"""Default terminal/frontier recovery through disposable owning stores.""" +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / 'tests/control_plane')] +from loopx.todos import add_goal_todo +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.status import parse_active_state_todos +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.control_plane.test_default_turn_recovery import _managed_recovery_call, _business_rows, _work_counts +from tests.test_goal_mode_mcp_settlement import _control, GOAL_ID, AGENT_ID + +SCENARIOS = ['ordinary_successor', 'legal_no_followup', 'changed_during_direction', + 'changed_before_terminal', 'changed_after_terminal', 'terminal_loss_replay'] + +def rows(runtime, state): + canonical = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + return canonical['todos'] if canonical is not None else parse_active_state_todos(state.read_text(encoding='utf-8'))['agent_todos']['items'] + +def result_facts(row): + # A terminal phase has its own receipt pointer; immutable result facts stay. + return {key: row.get(key) for key in ('todo_id', 'status', 'evidence', 'completed_at', 'completion_turn_key')} + +def add_obligation(registry, validator=None): + validation = {} if validator is None else {'validation_command_json': json.dumps([sys.executable, str(validator)]), 'validation_timeout_seconds': 10} + result = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text='Verify the newly delivered dependency before the next product step.', + task_class='advancement_task', claimed_by=AGENT_ID, + continuation_policy='same_agent_non_delivery', **validation) + return result['todo_id'] + +def journal(runtime): + paths = [p for p in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') if not p.name.endswith('.holder.json')] + assert len(paths) == 1 + return json.loads(paths[0].read_text(encoding='utf-8')) + +def resume(args, original, *, retry=False): + argv = [sys.executable, '-m', 'loopx.cli', *args, '--resume-turn-key', original['turn_key']] + if retry: + argv += ['--retry-failed-turn'] + result = subprocess.run(argv, capture_output=True, text=True, timeout=150) + assert result.returncode == 0, result.stdout + result.stderr + value = json.loads(result.stdout) + assert value['status'] == 'committed' and value['resume_turn_key'] == original['turn_key'], value + return value + +def assert_once(project, runtime): + counts = _work_counts(project) + assert counts['implementation.txt'] == 1 and counts['validation.txt'] == 1, counts + effects = _business_rows(runtime) + assert effects['candidate_verified'] == 1 and effects['quota_slot_spent'] == 1, effects + +def business_snapshot(project, runtime): + return {'counts': _work_counts(project), 'effects': dict(_business_rows(runtime)), + 'candidate': (project / 'candidate.txt').read_bytes()} + +def test_negative_oracle_sentinels(tmp_path): + # The actual counter oracle must reject duplicated persisted work, not just a missing file. + candidate = tmp_path / 'candidate.txt' + candidate.write_text('valid', encoding='utf-8') + (tmp_path / 'implementation.txt').write_text('1\n1\n', encoding='utf-8') + (tmp_path / 'validation.txt').write_text('1\n', encoding='utf-8') + with pytest.raises(AssertionError): + counts = _work_counts(tmp_path) + assert counts['implementation.txt'] == counts['validation.txt'] == 1 + before = {'quota_slot_spent': 1, 'candidate_verified': 1} + broken = {**before, 'quota_slot_spent': 2} + with pytest.raises(AssertionError): + assert before == broken + original = {'todo_id': 'fixture-original', 'status': 'done', 'evidence': 'valid', + 'completed_at': '2026-01-01T00:00:00Z', 'completion_turn_key': 'original-native-identity', + 'completion_receipt_id': 'ordinary-phase'} + terminal = {**original, 'completion_receipt_id': 'terminal-phase'} + assert result_facts(original) == result_facts(terminal) + for key in ('evidence', 'completed_at', 'completion_turn_key'): + damaged = {**terminal, key: 'replaced'} + with pytest.raises(AssertionError): + assert result_facts(original) == result_facts(damaged) + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('scenario', SCENARIOS) +def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): + runtime = tmp_path / 'runtime' + if scenario in {'ordinary_successor', 'legal_no_followup', 'changed_during_direction'}: + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + guard = json.loads(control.should_run()) + identity = guard['delivery_result_context']['settlement_identity'] + options = {'next_agent_todo': 'Verify the remaining required dependency.'} if scenario == 'ordinary_successor' else {'no_follow_up': True} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first.get('stage') == 'direction_pending' and first['settlement_identity'] == identity, first + original_result = next(r for r in rows(runtime, state) if r['todo_id'] == todo) + assert original_result['status'] == 'done' + context = first['context'] + if scenario == 'changed_during_direction': + new_todo = add_obligation(registry) + before = (_business_rows(runtime), original_result['completion_receipt_id']) + stale = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], vision_unchanged_reason='The old frontier is unchanged.')) + assert stale.get('ok') is False, stale + current_result = next(r for r in rows(runtime, state) if r['todo_id'] == todo) + assert before == (_business_rows(runtime), current_result['completion_receipt_id']) + context = json.loads(_control(registry).review_task_vision(todo, AGENT_ID)) + assert context['read_context_id'] != first['context']['read_context_id'] + assert new_todo in json.dumps(context['basis']), 'Current Agent must receive the real new obligation' + finished = json.loads(_control(registry).complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], + vision_unchanged_reason='The accepted candidate is preserved; verify all remaining current dependencies.')) + assert finished.get('ok') is True and finished.get('settlement_complete') is True, finished + assert finished['settlement_identity'] == identity + assert finished.get('goal_completion_certified') is not True + assert _business_rows(runtime)['quota_slot_spent'] == 1 + final = rows(runtime, state) + assert result_facts(next(r for r in final if r['todo_id'] == todo)) == result_facts(original_result) + if scenario == 'legal_no_followup': + assert len(final) == 1, 'Legal closeout must not manufacture a successor' + else: + assert any(r['status'] == 'open' for r in final), 'Real remaining work must stay actionable' + return + + fault = 'spend' if scenario == 'changed_before_terminal' else 'terminal' + state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) + lost = subprocess.run([sys.executable, '-c', injection, *args], capture_output=True, text=True, timeout=150) + assert lost.returncode == 97, lost.stdout + lost.stderr + original = journal(runtime) + assert original['host_result']['result_kind'] == 'validated_completion' + registry = Path(args[args.index('--registry') + 1]) + original_rows = rows(runtime, state) + original_todo = next(r['todo_id'] for r in original_rows if r['status'] == 'done') + original_result_facts = result_facts(next(r for r in original_rows if r['todo_id'] == original_todo)) + if scenario != 'terminal_loss_replay': + (project / 'new-dependency.txt').write_text('delivered-v2', encoding='utf-8') + verifier = project / 'verify-new-dependency.py' + verifier.write_text("from pathlib import Path\n" + "assert Path('new-dependency.txt').read_text() == 'delivered-v2'\n" + "assert Path('new-proof.txt').read_text() == 'delivered-v2 verified'\n" + "with Path('new-validation.txt').open('a') as f: f.write('1\\n')\n", encoding='utf-8') + new_todo = add_obligation(registry, verifier) + else: + new_todo = None + before = business_snapshot(project, runtime) + finished = resume(args, original, retry=True) + assert_once(project, runtime) + if scenario == 'changed_before_terminal' and _work_counts(project)['direction.txt'] > before['counts']['direction.txt']: + assert _work_counts(project)['direction.txt'] == before['counts']['direction.txt'] + 1 + inputs = [json.loads(line) for line in (project / 'direction-inputs.jsonl').read_text(encoding='utf-8').splitlines()] + assert new_todo in json.dumps(inputs[-1]['basis']) + else: + assert business_snapshot(project, runtime) == before, 'Historical terminal recovery must not repeat settled effects' + accepted = next(r for r in rows(runtime, state) if r['todo_id'] == original_todo) + assert result_facts(accepted) == original_result_facts + after = business_snapshot(project, runtime) + resume(args, original) + assert business_snapshot(project, runtime) == after + if new_todo: + assert next(r for r in rows(runtime, state) if r['todo_id'] == new_todo)['status'] == 'open' + if new_todo: + caller = _control(registry) + guard = json.loads(caller.should_run()) + assert guard['should_run'] is True and guard['selected_todo']['todo_id'] == new_todo, guard + if guard['interaction_contract']['agent_channel'].get('selection_required') is True: + # A displayed candidate is not admission. Execute the owner-returned + # same-Turn selection contract, never invent another work identity. + assert guard['interaction_contract']['cli_channel']['selection_command']['schema_version'] == 'action_selection_cli_command_v1' + selection_turn = guard['heartbeat_receipt']['turn_instance_id'] + current_args, legacy_args = caller.should_run_args(GOAL_ID, AGENT_ID) + suffix = ['--todo-id', new_todo, '--turn-instance-id', selection_turn] + guard = json.loads(caller.run_cli([*current_args, *suffix], legacy_args=[*legacy_args, *suffix])) + assert guard['heartbeat_receipt']['turn_instance_id'] == selection_turn + assert guard['interaction_contract']['agent_channel']['delivery_allowed'] is True, guard + assert guard['selected_todo']['todo_id'] == new_todo + assert business_snapshot(project, runtime) == after, 'New work admission must not resettle old work' + assert (project / 'new-dependency.txt').read_text() == 'delivered-v2' + assert not (project / 'new-proof.txt').exists() + (project / 'new-proof.txt').write_text('delivered-v2 verified', encoding='utf-8') + first_new = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, + 'The new delivered dependency was actually read and independently validated.', no_follow_up=True)) + assert first_new.get('ok') is True, first_new + new_identity = first_new['settlement_identity'] + assert new_identity['todo_id'] == new_todo and new_identity['turn_instance_id'] != original['turn_key'] + if first_new.get('stage') == 'direction_pending': + current = first_new['context'] + assert new_todo in json.dumps(current['basis']) + # A required vision outcome cannot be discharged by an unchanged + # reason alone, even after the new result's independent validator. + denied = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, + 'The new delivered dependency was actually read and independently validated.', no_follow_up=True, + read_context_id=current['read_context_id'], + vision_unchanged_reason='The latest new dependency is verified; retain prior accepted results and only close this work.')) + assert denied['ok'] is False and denied['completed'] is True, denied + assert denied['settlement']['failed_stage'] == 'durable_writeback' + assert 'typed semantic delta' in denied['settlement']['reason'] + assert _business_rows(runtime)['quota_slot_spent'] == 1, 'Rejected direction cannot spend the new Turn' + import hashlib + proof_refs = [f'todo:{new_todo}'] + [ + f'file:{name}#sha256:{hashlib.sha256((project/name).read_bytes()).hexdigest()}' + for name in ('new-dependency.txt', 'new-proof.txt')] + vision = {'schema_version': 'goal_vision_replan_contract_v0', + 'state': 'vision_patch_proposed', 'vision_patch': { + 'vision_summary': 'Verify the delivered dependency while preserving prior accepted results.', + 'acceptance_summary': 'The new dependency was read and its independent validator passed; retain the original result and settlement identities.'}, + 'path_delta': {'outcome': 'continue', + 'prior_assumption': 'The original terminal route had no delivered follow-up dependency.', + 'observed_reality': 'A new dependency and proof were read, and its native completion validator passed.', + 'retained': ['Prior accepted results and all original settlement identities.'], + 'changed': ['Include the independently verified dependency in the current direction.'], + 'evidence_refs': proof_refs}} + new_finished = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, + 'The new delivered dependency was actually read and independently validated.', no_follow_up=True, + read_context_id=current['read_context_id'], agent_vision=vision)) + else: + new_finished = first_new + assert new_finished.get('ok') is True and new_finished.get('settlement_complete') is True, new_finished + assert new_finished['settlement_identity'] == new_identity + assert (project / 'new-validation.txt').read_text().splitlines() == ['1'] + assert _work_counts(project) == after['counts'] and (project / 'candidate.txt').read_bytes() == after['candidate'] + effects = _business_rows(runtime) + assert effects['candidate_verified'] == 1 and effects['quota_slot_spent'] == 2 + final_rows = rows(runtime, state) + assert result_facts(next(r for r in final_rows if r['todo_id'] == original_todo)) == original_result_facts + assert next(r for r in final_rows if r['todo_id'] == new_todo)['status'] == 'done' + assert new_finished.get('goal_completion_certified') is not True + diff --git a/tests/control_plane/test_receipt_bound_lifecycle_recovery.py b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py new file mode 100644 index 0000000000..8b0d439b62 --- /dev/null +++ b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py @@ -0,0 +1,162 @@ +"""Admission-source identity stays immutable; proven lifecycle recovery may advance it.""" +import copy +import json +from unittest.mock import patch + +import sys +from pathlib import Path +sys.path.insert(0,str(Path(__file__).resolve().parents[2]/'tests/control_plane')) +import pytest +from loopx.control_plane.effect_runtime import EffectRuntimeRejected +from loopx.control_plane.work_items import semantic_replan_writeback as writer + +OID = 'replan-0123456789abcdef' +SOURCE = {'obligation_id': OID, 'required': True, + 'resolution_mode': 'todo_lifecycle_settlement', + 'satisfying_semantic_outcomes': ['new_runnable_successor']} +RECOVERED = {**SOURCE, 'resolution_mode': 'receipt_bound_replan_settlement', + 'satisfying_semantic_outcomes': ['fresh_vision_path_outcome']} + + +@pytest.mark.parametrize('case', ['recovered', 'unsettled', 'wrong_candidate', + 'no_candidate', 'unguarded', 'wrong_source', 'no_source', 'non_boolean']) +def test_selection_boundary(case): + candidate = {'lifecycle_settled': True, 'obligation': copy.deepcopy(RECOVERED)} + source = copy.deepcopy(SOURCE) + context = {'replan_obligation': {'obligation_id': 'replan-fedcba9876543210'}, + 'replan_transition_candidates': [candidate]} + if case == 'unsettled': candidate['lifecycle_settled'] = False + if case == 'non_boolean': candidate['lifecycle_settled'] = 'true' + if case == 'wrong_candidate': candidate['obligation']['obligation_id'] = 'replan-fedcba9876543210' + if case == 'no_candidate': context['replan_transition_candidates'] = [] + if case == 'wrong_source': source['obligation_id'] = 'replan-fedcba9876543210' + if case == 'no_source': source = None + with patch.object(writer, 'build_goal_frontier_projection_context_from_status', return_value=context), \ + patch.object(writer, 'autonomous_replan_obligation_from_runs', return_value=None), \ + patch.object(writer, 'guarded_replan_transition_delta', return_value=None), \ + patch.object(writer, 'semantic_delta_from_writeback', return_value={'outcomes': []}): + call = lambda: writer.qualify_replan_writeback(newest_first_runs=[], state_text='', + agent_id='fixture-agent', goal_id='fixture-goal', todo_fields={}, + guard_scoped=case != 'unguarded', guard_semantic_replan_obligation_id=OID, + receipt_bound_source=source) + if case == 'wrong_source': + with pytest.raises(ValueError, match='source identity differs'): call() + return + chosen, _ = call() + expected = (RECOVERED if case in ['recovered', 'no_source'] else + context['replan_obligation'] if case == 'unguarded' else SOURCE) + assert chosen == expected + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_native_lifecycle_writeback_and_replay(tmp_path, monkeypatch, provider): + import test_quota_settlement_cli as cli + from test_declared_terminal_settlement_cli import _fixture + project, workspace, runtime, registry, run, lease, marker, _, original = _fixture(tmp_path, monkeypatch, provider) + base = ['--goal-id', cli.GOAL_ID, '--agent-id', cli.AGENT_ID] + original_binding = [*base, '--todo-id', cli.TODO_ID, '--turn-instance-id', cli.TURN_ID] + code, guard = run('quota', 'should-run', '--codex-app', *original_binding, + '--scan-path', str(project), '--available-capability', 'shell', '--available-capability', 'filesystem_write') + assert code == 0 and guard['ok'], guard + # Only this isolated fixture seeds the already-supported historical protocol. + cli._historical_turn(runtime) + code, completed = run('todo', 'complete', *original_binding, *lease, + '--project', str(project), '--state-file', str(project / f'.codex/goals/{cli.GOAL_ID}/ACTIVE_GOAL_STATE.md'), + '--evidence', 'Original bounded fixture validator passed.') + assert code == 0 and completed['status'] == 'done', completed + code, refreshed = run('refresh-state', *original_binding, + '--classification', 'declared_deliverable_complete', '--delivery-batch-scale', 'implementation', + '--delivery-outcome', 'outcome_progress', '--vision-state', 'vision_closed', + '--vision-summary', 'The original bounded fixture deliverable passed.', + '--vision-acceptance', 'Retain original acceptance and exact result.', '--no-global-sync', '--suppress-external-sinks') + assert code == 0 and refreshed['ok'], refreshed + code, spent = run('quota', 'spend-slot', *original_binding, '--execute', '--scan-path', str(project)) + assert code == 0 and spent['ok'] and cli._spend_run_count(runtime) == 1, spent + + turn = 'receipt-bound-review' + guardargs = ['quota', 'should-run', '--codex-app', *base, '--turn-instance-id', turn] + code, admitted = run(*guardargs) + assert code == 0 and admitted['ok'], admitted + receipt = admitted['heartbeat_receipt'] + oid = receipt['semantic_replan_obligation_id'] + assert receipt['semantic_replan_source']['obligation']['resolution_mode'] == 'todo_lifecycle_settlement', admitted + source_bytes = json.dumps(receipt['semantic_replan_source'], sort_keys=True) + closeargs = ['todo', 'complete', *base, '--todo-id', cli.TODO_ID, + '--completion-identity-key', completed['completion_identity_key'], '--no-follow-up', + '--evidence', 'Original bounded fixture validator passed.'] + code, closed = run(*closeargs) + assert code == 0 and closed['completion_recovery'] == 'lifecycle_reentry_terminal_closeout', closed + assert closed['completion_receipt_id'] == completed['completion_receipt_id'] + code, recovered = run(*guardargs) + assert code == 0 and recovered['ok'], recovered + assert recovered['autonomous_replan_obligation']['obligation_id'] == oid + assert recovered['autonomous_replan_obligation']['resolution_mode'] == 'receipt_bound_replan_settlement', recovered + assert recovered['heartbeat_receipt']['event_id'] == receipt['event_id'] + assert json.dumps(recovered['heartbeat_receipt']['semantic_replan_source'], sort_keys=True) == source_bytes + vision = {'schema_version': 'goal_vision_replan_contract_v0', 'state': 'vision_patch_proposed', + 'vision_patch': {'acceptance_summary': 'Original deliverable is verified; retain acceptance and evidence.', + 'advancement_policy': 'repeat_until_closed', 'replan_trigger_summary': 'Audit any later scope independently.'}, + 'path_delta': {'schema_version': 'goal_path_delta_v0', 'outcome': 'replan', + 'prior_assumption': 'The completed Todo still needed lifecycle closeout.', + 'observed_reality': 'Native original-identity terminal reentry is now proven.', + 'retained': ['Original acceptance and immutable admitted source.'], + 'evidence_refs': ['native:' + closed['completion_receipt_id']]}} + vp = tmp_path / 'vision173.json'; vp.write_text(json.dumps(vision), encoding='utf-8') + binding = [*base, '--turn-instance-id', turn, '--replan-obligation-id', oid] + refresh = ['refresh-state', *binding, '--classification', 'receipt-bound-review', + '--progress-scope', 'agent_lane', '--delivery-batch-scale', 'single_surface', + '--delivery-outcome', 'outcome_gap', '--progress-result-class', 'blocked', + '--progress-blocker-id', 'blocker:later-independent-scope', + '--progress-evidence-id', 'native:' + closed['completion_receipt_id'], + '--agent-vision-json', str(vp), '--no-global-sync', '--suppress-external-sinks'] + code, written = run(*refresh) + assert code == 0 and written['ok'], written + delta = written['autonomous_replan_ack']['semantic_delta'] + assert delta['accepted'] is True and delta['obligation_id'] == oid, written + assert cli._spend_run_count(runtime) == 1 + spend = ['quota', 'spend-slot', *binding, '--execute', '--scan-path', str(project)] + code, settled = run(*spend) + assert code == 0 and settled['ok'] and settled['settlement_progress']['state'] == 'settled', settled + assert cli._spend_run_count(runtime) == 2 + for args in (refresh, spend, closeargs): + code, replay = run(*args) + assert code == 0 and replay['idempotent_replay'] is True, replay + assert cli._spend_run_count(runtime) == 2 and marker.read_text() == 'run\n' + code, current = run('quota', 'should-run', '--codex-app', *base, '--turn-instance-id', 'lifecycle173-later') + assert code == 0 and current['ok'], current + assert current['heartbeat_receipt'].get('semantic_replan_obligation_id') != oid + + +@pytest.mark.parametrize('case', ['matching', 'wrong_turn', 'wrong_actor', 'unsettled', 'closed_before_guard', 'wrong_todo']) +def test_original_source_cardinality(case): + items = [{'todo_id':f'todo_{i}', 'index':i, 'status':'done', 'done':True, + 'task_class':'advancement_task', 'claimed_by':'fixture-agent', 'last_actor_agent_id':'fixture-agent', + 'completed_at':f'2026-08-13T04:0{9-i}:00Z', 'updated_at':'2026-08-13T05:01:00Z', + 'no_followup':i<3, 'completion_continuation':'no_followup' if i<3 else 'active_goal', + 'completion_recovery':'lifecycle_reentry_terminal_closeout' if i<3 else None, + 'completion_turn_key':f'original-turn-{i}'} for i in range(7)] + source = {**SOURCE, 'agent_id':'fixture-agent', 'trigger_count':5, + 'triggers':[{'kind':'completed_advancement_without_successor','agent_id':'fixture-agent', + 'todo_id':f'todo_{i}','completion_turn_key':f'original-turn-{i}'} for i in range(3)]} + if case=='wrong_turn':source['triggers'][0]['completion_turn_key']='wrong' + if case=='wrong_actor':items[0]['last_actor_agent_id']='peer' + if case=='unsettled':items[0]['no_followup']=False + if case=='closed_before_guard':items[0]['updated_at']='2026-08-13T04:50:00Z' + if case=='wrong_todo':source['triggers'][0]['todo_id']='wrong' + with patch.object(writer,'build_goal_frontier_projection_context_from_status',return_value={}), \ + patch.object(writer,'autonomous_replan_obligation_from_runs',return_value=None), \ + patch.object(writer,'guarded_replan_transition_delta',return_value=None), \ + patch.object(writer,'select_planning_inventory_source_items',return_value=items), \ + patch.object(writer,'semantic_delta_from_writeback',return_value={'outcomes':[]}): + call=lambda: writer.qualify_replan_writeback(newest_first_runs=[],state_text='',agent_id='fixture-agent', + goal_id='fixture-goal',todo_fields={},guard_scoped=True,guard_semantic_replan_obligation_id=OID, + receipt_bound_guard_recorded_at='2026-08-13T05:00:00Z',receipt_bound_source=source) + if case!='matching': + with pytest.raises(EffectRuntimeRejected,match='lifecycle source does not match'):call() + return + chosen,_=call() + assert chosen['obligation_id']==OID + if case=='matching': + assert chosen['resolution_mode']=='receipt_bound_replan_settlement' + assert 'fresh_vision_path_outcome' in chosen['satisfying_semantic_outcomes'] + else:assert chosen==source From b97a47e27ece57bc749950e1116261f2859b14c1 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 19 Oct 2026 23:46:20 +0800 Subject: [PATCH 20/80] Document default freshness recovery and product qualification limits Signed-off-by: Tartar --- docs/reference/local-delegation.md | 6 ++ .../goal-vision-replan-contract-v0.md | 79 +++++++++++++++++-- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 5a3df1cdad..28ac18660e 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -1212,6 +1212,12 @@ direction checkpoint remains pending, the existing mode bar shows the retained result and **Continue review**. The current SDK conversation reads and judges fresh native facts; a finished SDK run does not settle the LoopX Todo or Goal. No owner copies a context token and no additional execution setting is added. +The packaged checkpoint browser smoke drives that rendered control through the +production owning HTTP route, reloads and checks the original result/session and +single native settlement. Only its SDK transport and unrelated workspace shell +are synthetic. This evidence does not establish live model decision quality, +live Lark delivery or native Desktop rendering; see the +[default freshness support and recovery boundary](protocols/goal-vision-replan-contract-v0.md#recovery-checks-and-support-boundary). The existing `loopx_collaboration` tool retains its name and action vocabulary. `read(operation_id=todo_id, brief={purpose:"first_delivery"})` reads the configured diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 0e966b6bfb..0017114a51 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -1012,8 +1012,17 @@ File and SQLite. PostgreSQL has native result transaction/head/CAS conformance; the ordinary Python CLI/MCP/managed runtime does not supply the existing service-owned PostgreSQL factory. A PostgreSQL selector therefore fails closed without legacy fallback; this slice does not add that product integration. -Packaged frontend and Lark recovery journeys are also unqualified. These are delivery -limits, not an option to silently bypass freshness on a protected admission. +The packaged Chat regression exercises the existing **Continue review** control, +production HTTP/controller and native checkpoint owners on a disposable Goal. +It retains the original SDK session, Todo, Turn and committed result, reads fresh +direction facts, settles once, then reloads and replays without another debit. +Its SDK transport is deterministic and unrelated workspace navigation is a +synthetic shell; the checkpoint HTTP responses come from the production owner. +Run the public smoke described below for the exact candidate being delivered. +This does not qualify live model judgments, native Desktop rendering or live Lark +delivery. The four legacy Lark recovery entrypoints and PostgreSQL first-delivery +callers remain unqualified. These are delivery limits, not an option to silently +bypass freshness on a protected admission. Manual Todo lifecycle operations without any work admission retain their existing permission and validation contract. They have not captured an Agent work basis and are not qualified result-freshness execution. Absence of an admission is not @@ -1271,10 +1280,68 @@ terminal owner 先保留合法结果和原终结意图,完成当前 Agent 的 沿同 Turn terminal upgrade 关闭。默认 managed 续接原 Host/session,required checkpoint 会增加一次方向调用;typed 豁免不增加该调用。方向 stale 只重判方向, 不重复实施、验证或扣额。历史 refresh→spend→terminal 顺序保留。 -完整 changed-frontier 改向出口与混版本回退仍待独立验收;独立 reviewer -另行登记完整契约,没有方向专用尝试预算或通用回滚协议。 -status/quota 的回执投影可供既有前端及 Lark 读取,但不代表默认产品旅程已交付, -也不代表 Goal 已完成或模型效果已提升。 +公开 terminal/frontier 回归覆盖当前方向改变后保留原结果、按新 frontier 续接, +以及显式 gate、方向权限拒绝和原 Turn 恢复;通过这些隔离用例不等于真实模型 +能正确判断任意方向。混版本回退和独立 reviewer 仍须各自的完整契约;没有 +方向专用尝试预算或通用回滚协议。打包 Chat 的“继续核验”烟测运行实际控件、 +HTTP/controller 和原生 checkpoint,使用确定性 SDK,保留会话与原结果,并验证 +单次扣额及重载/重放零增量。真实 Lark、实时模型效果与 token 成本、Native +Desktop 渲染仍未验证,不能从 status/quota 投影存在推断这些旅程已通过, +也不能据此关闭 Goal。 + + +### Recovery checks and support boundary + +Public regression commands from the selected checkout are: + +```sh +python -m pytest -q tests/control_plane/test_receipt_bound_lifecycle_recovery.py \ + tests/control_plane/test_default_terminal_frontier.py \ + tests/control_plane/test_default_terminal_admission_gates.py \ + tests/test_chat_coordinator_checkpoint.py tests/test_chat_queued_write_recovery.py +node --experimental-sqlite --experimental-strip-types --test \ + tests/control_plane_ts/prior_monitor_closeout.test.ts +python scripts/chat_bundle.py build +python scripts/chat_bundle.py verify --source +node apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs \ + --output /absolute/path/to/fresh-disposable-evidence +``` + +Use the checkout's compatible Python test environment, supported Node and frontend +lockfile dependencies. The browser smoke needs Playwright and a headless Chromium; +`LOOPX_PLAYWRIGHT_PACKAGE` and `LOOPX_CHROME_HEADLESS_SHELL` may select existing +local installations. Its output includes actual HTTP observations, native result +and settlement records, desktop/narrow screenshots and browser errors. A missing, +failed or skipped check is an open evidence gap; a source test run does not qualify +a different installed artifact. + +Receipt-bound recovery preserves the original semantic obligation and cadence +source instead of deriving a replacement from the current schedule. Missing, +changed or foreign source facts fail closed; no implicit historical-source adoption +is granted. A committed monitor poll may close its own Turn without an accountable +work writeback or quota debit. Unbound settlement diagnostics must not report that +exact proved poll as old unpaid work. The execution guard still verifies its +committed metadata against the bound Todo; an arbitrary settled-looking flag is +not admission authority. The File/SQLite public tests retain wrong-identity, +missing-proof and replay negatives. + +Local-registry Legacy Markdown, File and SQLite caller coverage is distinct from +PostgreSQL native head/CAS conformance. Service-owned PostgreSQL first-delivery +factory integration remains outside this qualification. Lark contract tests use +synthetic transports; live delivery and the four legacy recovery routes remain +unqualified. Two platform-specific Lark lifecycle scenarios have POSIX-only +coverage and must not be presented as Windows support. These boundaries also apply +to any packaged build, regardless of a successful frontend smoke. + +For rollback, stop new admissions, preserve original receipt/journal/source blobs, +then finish or explicitly reconcile pending protected Turns using a compatible +runtime. Check source, interpreter, original identities and unresolved append state +before switching a task-owned launcher to a retained qualified installation. Never +relabel an earlier receipt as evidence for the new build, reimplement a durable +result or charge a replay. The regression for an unchanged result also preserves +its original completion timestamp. Global installation changes, integration with a +newer main and remote publication are separate operations with their own validation. +This support note does not certify a Goal, live model usefulness or a release. Code publication remains a separate operation. Qualify ref CAS against an integration target that is not checked out; this protocol does not wrap direct From b3f89a3bd604e781668c6b302c6229b80915ab6c Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 20 Oct 2026 00:38:27 +0800 Subject: [PATCH 21/80] fix: preserve terminal history with sealed lifecycle admission facts Signed-off-by: Tartar --- .../coordination/todo_terminal_reentry.ts | 3 +- .../goals/goal_frontier/__init__.py | 16 ++++++++ .../work_items/lifecycle_recovery.py | 5 ++- .../work_items/replan_semantics.ts | 39 ++++++++++++++++++- .../lifecycle_admission_snapshot.test.ts | 36 +++++++++++++++++ 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/control_plane_ts/lifecycle_admission_snapshot.test.ts diff --git a/loopx/control_plane/coordination/todo_terminal_reentry.ts b/loopx/control_plane/coordination/todo_terminal_reentry.ts index b488cc3860..a47d4cc751 100644 --- a/loopx/control_plane/coordination/todo_terminal_reentry.ts +++ b/loopx/control_plane/coordination/todo_terminal_reentry.ts @@ -92,6 +92,5 @@ export function prepareTerminalReentry(todo: JsonObject, input: CoordinationTodo const state = selectTodoCompletionState({schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, todo, requested_no_followup: true, has_successor: false, completion_identity_source: "lifecycle_reentry"}); return {...todo, no_followup: true, completion_continuation: state.continuation, - completion_recovery: state.recovery, completion_turn_key: input.requested_completion_turn_key, - updated_at: input.now.toISOString().replace(/\.\d{3}Z$/u, "Z")}; + completion_recovery: state.recovery, completion_turn_key: input.requested_completion_turn_key}; } diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index 4122c52309..4e7d19ac4e 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1682,6 +1682,7 @@ def build_goal_frontier_projection_context_from_status( selected_obligation_id=receipt_bound_replan_obligation_id, guard_recorded_at=receipt_bound_guard_recorded_at, agent_id=agent_id, items=agent_todo_source_items or [], + admitted_source=status_payload.get("_receipt_bound_semantic_source"), derive=lambda summary: derive_goal_frontier_replan_obligation_from_summaries( user_todo_summary=None, agent_todo_summary=summary, work_lane_contract=work_lane_contract, agent_id=agent_id, @@ -1728,6 +1729,21 @@ def build_goal_frontier_projection_context_from_status( goal_acceptance_contract=(agent_todo_summary or {}).get("goal_acceptance_contract"), receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, ) + if replan_obligation: + replan_obligation = dict(replan_obligation) + if replan_obligation.get("resolution_mode") == "todo_lifecycle_settlement": + # Retain the native admission facts in the immutable guard source. + # Closeout must not rewrite historical Todo timestamps to prove time. + ids = {item.get("todo_id") for item in replan_obligation.get("triggers", [])} + replan_obligation["lifecycle_admission_items"] = [ + {key: item[key] for key in ( + "todo_id", "index", "status", "done", "task_class", "archive_state", + "claimed_by", "last_actor_agent_id", "completed_at", "updated_at", + "no_followup", "completion_continuation", "completion_recovery", + "completion_turn_key", "successor_todo_ids", "superseded_by", + ) if key in item} + for item in (agent_todo_source_items or []) if item.get("todo_id") in ids + ] goal_frontier_projection = build_goal_frontier_projection_from_summaries( goal_id=goal_id, diff --git a/loopx/control_plane/work_items/lifecycle_recovery.py b/loopx/control_plane/work_items/lifecycle_recovery.py index 61ac44f7a1..e5d8dae6d9 100644 --- a/loopx/control_plane/work_items/lifecycle_recovery.py +++ b/loopx/control_plane/work_items/lifecycle_recovery.py @@ -14,15 +14,16 @@ def receipt_bound_lifecycle_candidate( agent_id: str | None, items: list[dict[str, Any]], derive: Callable[[dict[str, Any]], dict[str, Any] | None], + admitted_source: dict[str, Any] | None = None, ) -> dict[str, Any] | None: facts = project_replan_semantics({ "operation": "lifecycle_source", "guard_recorded_at": guard_recorded_at, - "agent_id": agent_id, "items": items, + "agent_id": agent_id, "items": items, "obligation": admitted_source, }) if not facts.get("items"): return None gaps = facts["items"] - source = derive({"completed_without_successor_items": gaps, + source = admitted_source or derive({"completed_without_successor_items": gaps, "completed_without_successor_count": len(gaps)}) if not source or source.get("obligation_id") != selected_obligation_id: return None diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 7446c40266..4554bec3fd 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -126,6 +126,8 @@ function projectTurnTransition(request: JsonObject): JsonObject { * The selected obligation digest additionally binds Todo and completion identities. */ function lifecycleSource(request: JsonObject): JsonObject { + const obligation = object(request.obligation); + if (Array.isArray(obligation.lifecycle_admission_items)) return admittedLifecycleSource(request, obligation); const agent = optionalNonEmptyString(request.agent_id, "agent_id"); const admitted = typeof request.guard_recorded_at === "string" ? parseTodoTimestampMicros(request.guard_recorded_at) : null; @@ -161,13 +163,48 @@ function lifecycleSource(request: JsonObject): JsonObject { return {items, settled}; } +function admittedLifecycleSource(request: JsonObject, obligation: JsonObject): JsonObject { + const triggers = Array.isArray(obligation.triggers) ? obligation.triggers.map(object) : []; + // New guards seal the original pending rows together with the obligation. + // Older guards retain their historical reconstruction path; no source is + // invented from a now-closed row or a newly observed timestamp. + let source = {items: [] as JsonObject[], settled: false}; + if (Array.isArray(obligation.lifecycle_admission_items)) { + const admitted = obligation.lifecycle_admission_items.map(object); + const current = Array.isArray(request.items) ? request.items.map(object) : []; + const timestamp = typeof request.guard_recorded_at === "string" + ? parseTodoTimestampMicros(request.guard_recorded_at) : null; + const stable = ["todo_id", "index", "status", "done", "task_class", "archive_state", + "claimed_by", "last_actor_agent_id", "completed_at", "updated_at", + "completion_turn_key", "successor_todo_ids", "superseded_by"]; + const items = triggers.map(trigger => current.find(item => item.todo_id === trigger.todo_id) ?? {}); + const settled = timestamp !== null && triggers.length > 0 && triggers.length <= 3 && + admitted.length === triggers.length && new Set(admitted.map(item => item.todo_id)).size === admitted.length && + triggers.every((trigger, index) => { + const prior = admitted.find(item => item.todo_id === trigger.todo_id); + const item = items[index]; + const completed = typeof prior?.completed_at === "string" ? parseTodoTimestampMicros(prior.completed_at) : null; + return prior !== undefined && current.filter(row => row.todo_id === trigger.todo_id).length === 1 && + prior.status === "done" && prior.done === true && prior.task_class === "advancement_task" && + (prior.archive_state ?? "active") === "active" && prior.claimed_by === request.agent_id && + prior.last_actor_agent_id === request.agent_id && completed !== null && completed <= timestamp && + prior.no_followup !== true && strings(prior.successor_todo_ids).length === 0 && !prior.superseded_by && + stable.every(key => JSON.stringify(prior[key] ?? null) === JSON.stringify(item[key] ?? null)) && + item.no_followup === true && item.completion_continuation === "no_followup" && + item.completion_recovery === "lifecycle_reentry_terminal_closeout"; + }); + source = {items, settled}; + } + return source; +} + function lifecycleObligation(request: JsonObject): JsonObject | null { const obligation = object(request.obligation); if (obligation.obligation_id !== request.selected_obligation_id || obligation.resolution_mode !== "todo_lifecycle_settlement") return null; + const triggers = Array.isArray(obligation.triggers) ? obligation.triggers.map(object) : []; const source = lifecycleSource(request); const items = source.items as JsonObject[]; - const triggers = Array.isArray(obligation.triggers) ? obligation.triggers.map(object) : []; if (items.length === 0 || (typeof request.lifecycle_settled === "boolean" && request.lifecycle_settled !== source.settled) || triggers.length !== Math.min(3, items.length) || diff --git a/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts new file mode 100644 index 0000000000..6a3be3e93b --- /dev/null +++ b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {projectReplanSemantics} from "../../loopx/control_plane/work_items/replan_semantics.ts"; + +const admitted = [0,1,2].map(index => ({todo_id:`todo_${index}`,index,status:"done",done:true, + task_class:"advancement_task",archive_state:"active",claimed_by:"agent-a",last_actor_agent_id:"agent-a", + completed_at:`2026-09-01T00:00:0${2-index}Z`,updated_at:"2026-09-01T00:00:00Z", + completion_turn_key:`original-${index}`,completion_continuation:"active_goal"})); +const current=admitted.map(item=>({...item,no_followup:true,completion_continuation:"no_followup", + completion_recovery:"lifecycle_reentry_terminal_closeout"})); +const source={obligation_id:"replan-1111111111111111",resolution_mode:"todo_lifecycle_settlement", + lifecycle_admission_items:admitted,triggers:admitted.map(item=>({kind:"completed_advancement_without_successor", + agent_id:"agent-a",todo_id:item.todo_id,completion_turn_key:item.completion_turn_key}))}; +function run(obligation=source,items=current){return projectReplanSemantics({operation:"lifecycle_obligation", + obligation,selected_obligation_id:source.obligation_id,agent_id:"agent-a",guard_recorded_at:"2026-09-02T00:00:00Z",items});} +test("sealed original admission recovers closeout without rewriting old Todo timestamps",()=>{ + assert.equal((run().obligation as Record).resolution_mode,"receipt_bound_replan_settlement"); +}); +for(const field of ["todo_id","claimed_by","last_actor_agent_id","completion_turn_key","completed_at","updated_at"]){ + test(`snapshot recovery rejects changed ${field}`,()=>{ + const items=structuredClone(current);(items[0] as Record)[field]="changed"; + assert.throws(()=>run(source,items),/lifecycle source does not match/); + }); +} +for(const shape of ["closed_before_guard","unsettled","missing_source","duplicate_source","duplicate_current","wrong_trigger"]){ + test(`snapshot recovery rejects ${shape}`,()=>{ + const obligation=structuredClone(source),items=structuredClone(current); + if(shape==="closed_before_guard")Object.assign(obligation.lifecycle_admission_items[0],{no_followup:true}); + if(shape==="unsettled")Object.assign(items[0],{no_followup:false}); + if(shape==="missing_source")obligation.lifecycle_admission_items.pop(); + if(shape==="duplicate_source")obligation.lifecycle_admission_items[1]=obligation.lifecycle_admission_items[0]; + if(shape==="duplicate_current")items.push(items[0]); + if(shape==="wrong_trigger")obligation.triggers[0].completion_turn_key="wrong"; + assert.throws(()=>run(obligation,items),/lifecycle source does not match/); + }); +} From 651d977ec228f207c158b8a448dac00cd5313801 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 20 Oct 2026 01:01:36 +0800 Subject: [PATCH 22/80] fix: address long atomic Chat temporary paths on Windows Signed-off-by: Tartar --- loopx/chat_store.py | 1 + tests/test_chat_store_paths.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/test_chat_store_paths.py diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 3dab5ba5de..7e5dcd13c3 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -14,6 +14,7 @@ from typing import Any import uuid from weakref import WeakValueDictionary +from .control_plane.runtime.file_paths import windows_extended_path from .chat import ( require_matching_message_replay, diff --git a/tests/test_chat_store_paths.py b/tests/test_chat_store_paths.py new file mode 100644 index 0000000000..ed0591bf45 --- /dev/null +++ b/tests/test_chat_store_paths.py @@ -0,0 +1,14 @@ +"""Atomic Chat publication keeps the original filesystem address on Windows.""" +import json +from loopx.chat_store import _atomic_write_json +from loopx.control_plane.runtime.file_paths import windows_extended_path + +def test_atomic_chat_temp_name_beyond_windows_limit(tmp_path): + parent=tmp_path + while len(str(parent))<220: + parent=parent/'nested-chat-path' + path=parent/'state.json' + _atomic_write_json(path,{'identity':'original','version':1}) + _atomic_write_json(path,{'identity':'original','version':2},preserve_mode=True) + assert json.loads(windows_extended_path(path).read_text())=={'identity':'original','version':2} + assert not list(windows_extended_path(parent).glob('*.tmp')) From 20ba29c5dc22443f7b48a6812140b85bcd331086 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 21 Oct 2026 10:31:11 +0800 Subject: [PATCH 23/80] fix: reconcile freshness recovery with current main authority contracts Signed-off-by: Tartar --- loopx/cli_commands/turn.py | 5 +-- loopx/cli_runtime.py | 3 ++ .../coordination/coordination_projection.ts | 1 + .../coordination/todo_terminal_lifecycle.ts | 6 ++- .../coordination/todo_terminal_reentry.ts | 3 +- .../turn_driver/host_process_transport.py | 15 ++++++- .../work_items/replan_semantics.ts | 16 +++++--- ...roject_registry_io_classifications_v1.json | 1 + loopx/state_refresh.py | 1 - .../test_completion_original_identity.py | 1 - .../test_declared_terminal_settlement_cli.py | 4 +- .../test_default_checkpoint_continuation.py | 21 ++++------ .../test_default_result_freshness.py | 41 ++++++++++--------- .../test_default_terminal_admission_gates.py | 4 +- .../test_default_terminal_frontier.py | 3 +- .../test_default_turn_recovery.py | 8 ++-- .../automation_cadence.test.ts | 1 - .../effect_runtime_snapshot.test.ts | 8 ++-- .../lifecycle_admission_snapshot.test.ts | 16 ++++++++ tests/managed_effect_runtime_fixture.py | 5 ++- tests/test_codex_operation_host.py | 18 ++++++-- tests/test_goal_mode_mcp_settlement.py | 4 +- tests/test_project_alias_stdin.py | 2 +- tests/test_windows_atomic_writes.py | 2 +- 24 files changed, 116 insertions(+), 73 deletions(-) diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index af49c57e9b..0fff8391b0 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -388,8 +388,5 @@ def handle_turn_command( if args.turn_command == "run-once" else _render_loopx_turn_plan_markdown ) - if args.turn_command == "plan" and not args.include_transaction_detail: - print_payload(payload, output_format(args), renderer) - else: - print_payload(payload, output_format(args), renderer) + print_payload(payload, output_format(args), renderer) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index e25a6db73e..ebe149f7fd 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -97,6 +97,9 @@ def error(self, message: str) -> None: raise _SelectedParseRejected(message) +PrintPayload = Callable[[dict[str, object], str, Callable[[dict[str, object]], str]], None] + + def print_payload( payload: dict[str, object], fmt: str, diff --git a/loopx/control_plane/coordination/coordination_projection.ts b/loopx/control_plane/coordination/coordination_projection.ts index 18da16c4ac..f0af305ea6 100644 --- a/loopx/control_plane/coordination/coordination_projection.ts +++ b/loopx/control_plane/coordination/coordination_projection.ts @@ -91,6 +91,7 @@ const TODO_CONTRACT_REVISION_FIELDS: readonly (readonly string[])[] = [ ["completion_validation_revision", "completion_validation_revision_history"], ["completion_result"], ["completion_receipt_id"], + ["completion_requested_continuation"], ]; interface HistoricalTodoContract { diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 9b07bb63a6..efad55b08b 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1789,8 +1789,10 @@ export async function executeCoordinationTodoTerminalLifecycle( requested_has_successor: input.successor_intents.length > 0 || input.linked_successor_todo_ids.length > 0, dry_run: input.dry_run, - validation_receipt: implicitMonitorNoChange ? null : terminalUpgradeReceipt !== null - ? terminalUpgradeReceipt.validation_receipt : input.validation_receipt, + // A same-Turn closeout reuses the verified durable receipt below; it + // must not submit that receipt as a newly executed validation effect. + validation_receipt: implicitMonitorNoChange || terminalUpgradeReceipt !== null + ? null : input.validation_receipt, // Link ids are caller intent; role/status are facts owned by this head. // Receipt recovery above preserves the originally accepted policy. completion_policy_request: input.completion_policy_request === null ? null : { diff --git a/loopx/control_plane/coordination/todo_terminal_reentry.ts b/loopx/control_plane/coordination/todo_terminal_reentry.ts index a47d4cc751..b488cc3860 100644 --- a/loopx/control_plane/coordination/todo_terminal_reentry.ts +++ b/loopx/control_plane/coordination/todo_terminal_reentry.ts @@ -92,5 +92,6 @@ export function prepareTerminalReentry(todo: JsonObject, input: CoordinationTodo const state = selectTodoCompletionState({schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, todo, requested_no_followup: true, has_successor: false, completion_identity_source: "lifecycle_reentry"}); return {...todo, no_followup: true, completion_continuation: state.continuation, - completion_recovery: state.recovery, completion_turn_key: input.requested_completion_turn_key}; + completion_recovery: state.recovery, completion_turn_key: input.requested_completion_turn_key, + updated_at: input.now.toISOString().replace(/\.\d{3}Z$/u, "Z")}; } diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index 1ebc75fc43..568880eb30 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -127,6 +127,15 @@ def _host_process_drain(record: dict[str, Any] | None, *, supervises: str, return (HOST_PROCESS_NOT_LAUNCHED if record.get("process_group") is None and "bridge_pid" not in record else HOST_PROCESS_UNATTRIBUTABLE) if not hasattr(os, "killpg"): + if sys.platform == "win32" and "windows_job" in record: + from .windows_host_job import active_job_processes, valid_job_name + if not valid_job_name(record["windows_job"]): + return HOST_PROCESS_UNATTRIBUTABLE + try: + active = active_job_processes(record["windows_job"]) + except OSError: + return HOST_PROCESS_UNATTRIBUTABLE + return HOST_PROCESS_DRAINING if active else HOST_PROCESS_DRAINED # A launched Host on a platform without process groups is never proven # drained. This is a platform boundary, not an attribution failure. return HOST_PROCESS_UNSUPPORTED_PLATFORM @@ -177,7 +186,11 @@ def execution_host_drain(record_path: Path, *, launch_possible: bool = False) -> host = _host_process_drain(owned_record, supervises=HOST_PROCESS_SUPERVISES_HOST, expected=True) drain = next(state for state in _DRAIN_PRECEDENCE if state in {supervisor, host}) - if not host_process_drain_supported() and (launch_possible or drain != HOST_PROCESS_NOT_LAUNCHED): + assigned_windows_job = sys.platform == "win32" and any( + "windows_job" in item for item in (owned_record or {}, supervisor_record or {}) + ) + if (not assigned_windows_job and not host_process_drain_supported() + and (launch_possible or drain != HOST_PROCESS_NOT_LAUNCHED)): return HOST_PROCESS_UNSUPPORTED_PLATFORM return drain diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 4554bec3fd..1ea2c388df 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -175,10 +175,10 @@ function admittedLifecycleSource(request: JsonObject, obligation: JsonObject): J const timestamp = typeof request.guard_recorded_at === "string" ? parseTodoTimestampMicros(request.guard_recorded_at) : null; const stable = ["todo_id", "index", "status", "done", "task_class", "archive_state", - "claimed_by", "last_actor_agent_id", "completed_at", "updated_at", + "claimed_by", "last_actor_agent_id", "completed_at", "completion_turn_key", "successor_todo_ids", "superseded_by"]; const items = triggers.map(trigger => current.find(item => item.todo_id === trigger.todo_id) ?? {}); - const settled = timestamp !== null && triggers.length > 0 && triggers.length <= 3 && + const valid = timestamp !== null && triggers.length > 0 && triggers.length <= 3 && admitted.length === triggers.length && new Set(admitted.map(item => item.todo_id)).size === admitted.length && triggers.every((trigger, index) => { const prior = admitted.find(item => item.todo_id === trigger.todo_id); @@ -190,10 +190,16 @@ function admittedLifecycleSource(request: JsonObject, obligation: JsonObject): J prior.last_actor_agent_id === request.agent_id && completed !== null && completed <= timestamp && prior.no_followup !== true && strings(prior.successor_todo_ids).length === 0 && !prior.superseded_by && stable.every(key => JSON.stringify(prior[key] ?? null) === JSON.stringify(item[key] ?? null)) && - item.no_followup === true && item.completion_continuation === "no_followup" && - item.completion_recovery === "lifecycle_reentry_terminal_closeout"; + ((item.no_followup !== true && item.updated_at === prior.updated_at && + item.completion_continuation === prior.completion_continuation && + item.completion_recovery === prior.completion_recovery) || + (item.no_followup === true && item.completion_continuation === "no_followup" && + item.completion_recovery === "lifecycle_reentry_terminal_closeout" && + (item.updated_at === prior.updated_at || + (typeof item.updated_at === "string" && parseTodoTimestampMicros(item.updated_at) !== null && + parseTodoTimestampMicros(item.updated_at)! >= timestamp)))); }); - source = {items, settled}; + source = {items: valid ? items : [], settled: valid && items.every(item => item.no_followup === true)}; } return source; } diff --git a/loopx/semantics/project_registry_io_classifications_v1.json b/loopx/semantics/project_registry_io_classifications_v1.json index 7d92e680dc..1d7c19d817 100644 --- a/loopx/semantics/project_registry_io_classifications_v1.json +++ b/loopx/semantics/project_registry_io_classifications_v1.json @@ -1,4 +1,5 @@ { + "loopx/control_plane/goals/checkpoint_commit.ts::::direct_json_read:readFileSync#1": "source_digest_guard", "loopx/control_plane/goals/deletion_service.py::._load_locked_payloads::direct_json_read:read_json#1": "global_registry_io", "loopx/control_plane/goals/deletion_service.py::._load_locked_payloads::direct_json_read:read_json#2": "global_registry_io", "loopx/control_plane/goals/deletion_service.py::._resolve_route::direct_json_read:read_json#1": "global_registry_io", diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 62af1c025f..3bd9256ae2 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1163,7 +1163,6 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): guard_capability=settlement_replan_guard.get("selected_capability_guard"), todo_fields=todo_fields, receipt_bound_source=(usable_source or {}).get("obligation"), - receipt_bound_guard_recorded_at=(settlement_readback.heartbeat_receipt or {}).get("recorded_at") if settlement_readback else None, autonomous_replan_recorded=replan_stage["qualify"], requested_delta_kinds=normalized_repair_delta_kinds, active_state_next_action_update=None, diff --git a/tests/control_plane/test_completion_original_identity.py b/tests/control_plane/test_completion_original_identity.py index 094fdb09a0..e07ddf9d50 100644 --- a/tests/control_plane/test_completion_original_identity.py +++ b/tests/control_plane/test_completion_original_identity.py @@ -358,4 +358,3 @@ def capture(*args, **kwargs): assert code == 0 and exact["idempotent_replay"] is True, exact assert ledger.read_bytes() == original_ledger assert marker.read_bytes() == original_validation - diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index 52b6ac24ce..ecfe47964b 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -248,7 +248,7 @@ def test_declared_leased_completion_writeback_spend_terminal_and_replay( assert settled["effective_action"] == "heartbeat_settled_skip" -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_original_lease_and_failed_validator_remain_required_before_accounting( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, ) -> None: @@ -294,7 +294,7 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( assert cli._spend_run_count(runtime) == 0 -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_lease_change_during_validator_still_blocks_completion( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, ) -> None: diff --git a/tests/control_plane/test_default_checkpoint_continuation.py b/tests/control_plane/test_default_checkpoint_continuation.py index a085c4ee24..e901599eee 100644 --- a/tests/control_plane/test_default_checkpoint_continuation.py +++ b/tests/control_plane/test_default_checkpoint_continuation.py @@ -6,9 +6,7 @@ import subprocess 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.status import parse_active_state_todos +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime from loopx.todos import add_goal_todo, update_goal_todo from loopx.control_plane.quota.settlement import SettlementIdentity from loopx.control_plane.turn_driver.checkpoint_continuation import CurrentAgentCheckpoint @@ -19,7 +17,7 @@ def _fixture(provider, tmp_path, monkeypatch, *, validation_command_json=None): isolate_sqlite_runtime(tmp_path, monkeypatch) - registry, state = _write_fixture(tmp_path) + registry, state = _write_fixture(tmp_path, provider=provider) doc = json.loads(registry.read_text()) doc['goals'][0]['adapter'] = {'kind': 'fixture_v0', 'status': 'connected-delivery'} registry.write_text(json.dumps(doc), encoding='utf-8') @@ -30,15 +28,10 @@ def _fixture(provider, tmp_path, monkeypatch, *, validation_command_json=None): todo = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', text='Verify one candidate.', task_class='advancement_task', claimed_by=AGENT_ID, continuation_policy='same_agent_non_delivery', validation_command_json=validation_command_json) - if provider != 'legacy': - todos = parse_active_state_todos(state.read_text(encoding='utf-8'))['agent_todos']['items'] - initialize_canonical_authority(tmp_path / 'runtime', GOAL_ID, - build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode='soft_claim'), - state_path=state, provider=provider) return registry, state, todo['todo_id'] -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('terminal', [False, True, 'stored', 'cold_explicit']) def test_default_mcp_uses_post_result_basis_and_original_turn(provider, terminal, tmp_path, monkeypatch): registry, state, todo = _fixture(provider, tmp_path, monkeypatch) @@ -66,7 +59,7 @@ def test_default_mcp_uses_post_result_basis_and_original_turn(provider, terminal assert second['settlement_identity'] == original -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) def test_default_mcp_rejects_stale_direction_preserves_result(provider, tmp_path, monkeypatch): registry, state, todo = _fixture(provider, tmp_path, monkeypatch) control = _control(registry) @@ -88,7 +81,7 @@ def test_default_mcp_rejects_stale_direction_preserves_result(provider, tmp_path assert resumed['settlement_identity'] == first['settlement_identity'] -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) def test_default_pending_readback_guides_the_original_caller(provider, tmp_path, monkeypatch): registry, state, todo = _fixture(provider, tmp_path, monkeypatch) control = _control(registry) @@ -121,7 +114,7 @@ def test_default_pending_readback_guides_the_original_caller(provider, tmp_path, assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('terminal', [False, True]) def test_managed_default_reuses_candidate_and_calls_current_agent_for_direction(provider, terminal, tmp_path, monkeypatch): registry, state, todo = _fixture(provider, tmp_path, monkeypatch) @@ -194,7 +187,7 @@ def run_dsh_turn(**kwargs): assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('change', ['none', 'related', 'display', 'cached_binding']) def test_direction_retry_keeps_input_response_binding_and_result(provider, change, tmp_path, monkeypatch): registry, state, todo = _fixture(provider, tmp_path, monkeypatch) diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index 38d7163e29..bc4ca1f066 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -27,7 +27,7 @@ def _default_fixture(provider, tmp_path, monkeypatch): return project, runtime, registry, state -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_default_guard_restores_actual_basis_and_result_commit(provider, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) @@ -71,7 +71,7 @@ def read(): "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--scan-path", str(project), cwd=project) with ThreadPoolExecutor(max_workers=2) as pool: results = list(pool.map(lambda _: read(), range(2))) - assert all(rc == 0 for rc, _ in results), results + assert all(rc == 0 for rc, _ in results), json.dumps(results, indent=2) assert results[0][1]["delivery_result_context"] == results[1][1]["delivery_result_context"] @@ -141,7 +141,7 @@ def test_already_bound_historical_receipt_retains_original_policy(provider, tmp_ assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_default_commit_keeps_basis_after_unrelated_todo_revision(provider, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) @@ -177,23 +177,17 @@ def test_damaged_work_input_blocks_default_dispatch_and_preserves_admission(prov assert rc == 0 and restored["delivery_result_context"] == first["delivery_result_context"], restored -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("recovery_window", ["normal", "receipt_replaced", "input_persisted", "commit_response_lost", "commit_receipt_deleted", "commit_receipt_damaged", "commit_race", "binding_race", "terminal_commit_race", "terminal_host_cold", "terminal_validation_cold", "terminal_host_replaced", "terminal_validation_replaced", "terminal_commit_response_lost", "terminal_receipt_deleted", "terminal_receipt_damaged", "terminal_validation_failed", "terminal_stop"]) def test_historical_result_only_turn_retains_candidate_and_revalidates(provider, recovery_window, tmp_path, monkeypatch): from tests.test_loopx_turn_driver import _write_live_fixture - from loopx.status import parse_active_state_todos isolate_sqlite_runtime(tmp_path, monkeypatch) terminal_race = recovery_window.startswith("terminal_") - project, runtime, registry = _write_live_fixture(tmp_path, + project, runtime, registry = _write_live_fixture(tmp_path, provider=provider, **({"todo_metadata_extra": "no_followup=true"} if terminal_race else {})) goal, agent = "loopx-turn-fixture", "codex-fixture" state = project / f".codex/goals/{goal}/ACTIVE_GOAL_STATE.md" - todos = parse_active_state_todos(state.read_text(encoding="utf-8"))["agent_todos"]["items"] - if provider != "legacy": - initialize_canonical_authority(runtime, goal, - build_todo_runtime_shadow_projection(goal_id=goal, todos=todos, handoff_mode="soft_claim"), - state_path=state, provider=provider) host = f''' import json, pathlib, sys r = json.load(sys.stdin) @@ -383,26 +377,20 @@ def lose_response(*args, **kwargs): assert rc == 0 and replay["replayed"] is True and replay["effects"]["host_invoked"] is False, replay -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("receipt_loss", [None, "deleted", "damaged", "writeback_deleted", "writeback_damaged"]) def test_default_mcp_reads_before_work_and_rejects_stale_result(provider, receipt_loss, tmp_path, monkeypatch): from tests.test_goal_mode_mcp_settlement import _write_fixture as mcp_fixture, _control, _complete_default_task, GOAL_ID as goal, AGENT_ID as agent from loopx.todos import add_goal_todo - from loopx.status import parse_active_state_todos isolate_sqlite_runtime(tmp_path, monkeypatch) - registry, state = mcp_fixture(tmp_path) + registry, state = mcp_fixture(tmp_path, provider=provider) registration = json.loads(registry.read_text(encoding="utf-8")) registration["goals"][0]["adapter"] = {"kind": "fixture_v0", "status": "connected-delivery"} registry.write_text(json.dumps(registration), encoding="utf-8") added = add_goal_todo(registry_path=registry, goal_id=goal, role="agent", text="Verify a bounded candidate.", task_class="advancement_task", claimed_by=agent, continuation_policy="same_agent_non_delivery") runtime = tmp_path / "runtime" - if provider != "legacy": - todos = parse_active_state_todos(state.read_text(encoding="utf-8"))["agent_todos"]["items"] - initialize_canonical_authority(runtime, goal, - build_todo_runtime_shadow_projection(goal_id=goal, todos=todos, handoff_mode="soft_claim"), - state_path=state, provider=provider) control = _control(registry) first = json.loads(control.should_run()) assert first.get("delivery_result_context"), json.dumps(first) @@ -449,3 +437,18 @@ def lose_result_response(args, **kwargs): next_work = json.loads(control.should_run()) assert next_work.get("delivery_result_context"), next_work assert next_work["delivery_result_context"]["settlement_identity"]["turn_instance_id"] != original["settlement_identity"]["turn_instance_id"] + + +@pytest.mark.parametrize("operation", ["add", "complete"]) +def test_legacy_work_read_does_not_restore_retired_mutation_authority(operation, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture("legacy", tmp_path, monkeypatch) + rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + assert rc == 0 and "delivery_result_context" in admitted, admitted + original = state.read_bytes() + args = (("--role", "agent", "--text", "Independent candidate.") if operation == "add" + else ("--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--note", "Verified candidate.")) + rc, rejected = _run_cli(registry, runtime, "todo", operation, "--goal-id", GOAL_ID, *args, cwd=project) + assert rc == 1 and rejected["error_code"] == "canonical_authority_required", rejected + assert state.read_bytes() == original + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_default_terminal_admission_gates.py b/tests/control_plane/test_default_terminal_admission_gates.py index 782746123f..1b1e323fb4 100644 --- a/tests/control_plane/test_default_terminal_admission_gates.py +++ b/tests/control_plane/test_default_terminal_admission_gates.py @@ -57,7 +57,7 @@ def _resume(args): return subprocess.run([sys.executable, '-m', 'loopx.cli', *args], capture_output=True, text=True, timeout=90) -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('gate', ['pause', 'stop']) def test_pending_result_respects_explicit_gate_and_original_resume(provider, gate, tmp_path, monkeypatch): state, project, runtime, registry, journal_path, journal, resume = _original(provider, tmp_path, monkeypatch) @@ -96,7 +96,7 @@ def test_pending_result_respects_explicit_gate_and_original_resume(provider, gat _assert_preserved(after, _snapshot(project, runtime)) -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) def test_real_direction_permission_signal_retains_result_and_blocks_retry(provider, tmp_path, monkeypatch): _, project, runtime, _, journal_path, journal, resume = _original(provider, tmp_path, monkeypatch) host = project/'host.py' diff --git a/tests/control_plane/test_default_terminal_frontier.py b/tests/control_plane/test_default_terminal_frontier.py index 2a929e4bdc..02e51358fd 100644 --- a/tests/control_plane/test_default_terminal_frontier.py +++ b/tests/control_plane/test_default_terminal_frontier.py @@ -82,7 +82,7 @@ def test_negative_oracle_sentinels(tmp_path): with pytest.raises(AssertionError): assert result_facts(original) == result_facts(damaged) -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('scenario', SCENARIOS) def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): runtime = tmp_path / 'runtime' @@ -225,4 +225,3 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): assert result_facts(next(r for r in final_rows if r['todo_id'] == original_todo)) == original_result_facts assert next(r for r in final_rows if r['todo_id'] == new_todo)['status'] == 'done' assert new_finished.get('goal_completion_certified') is not True - diff --git a/tests/control_plane/test_default_turn_recovery.py b/tests/control_plane/test_default_turn_recovery.py index 536eec6ece..365225bf37 100644 --- a/tests/control_plane/test_default_turn_recovery.py +++ b/tests/control_plane/test_default_turn_recovery.py @@ -129,7 +129,7 @@ def lose(*a,**kw): return state, project, args, injection -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('fault', ['result', 'read', 'decision', 'checkpoint', 'spend', 'terminal', 'stale']) def test_default_managed_cold_recovery_resumes_only_unfinished_checkpoint(provider, fault, tmp_path, monkeypatch): state, project, args, injection = _managed_recovery_call(provider, fault, tmp_path, monkeypatch) @@ -178,7 +178,7 @@ def _provider_revision(runtime): return snapshot['provider_revision'] if snapshot is not None else None -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('fault', [ 'result', 'read', 'dispatch', 'response', 'bound_response', 'decision', 'checkpoint', 'spend', 'terminal', @@ -256,7 +256,7 @@ def _original_checkpoint_receipt(runtime): return next(receipt for receipt in receipts if receipt.get('purpose') == 'first_delivery') -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize(('fault', 'change'), [ ('dispatch', 'none'), ('dispatch', 'related'), ('response', 'none'), ('response', 'related'), @@ -308,7 +308,7 @@ def test_unpersisted_direction_response_rejudges_without_repeating_work( assert _work_counts(project) == after_work and _business_rows(runtime) == after_effects -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('fault', ['before_artifacts', 'after_json', 'after_markdown', 'after_index']) def test_default_managed_native_append_recovery_never_treats_unknown_as_absent( provider, fault, tmp_path, monkeypatch): diff --git a/tests/control_plane_ts/automation_cadence.test.ts b/tests/control_plane_ts/automation_cadence.test.ts index 9a60ee98ca..9629fe60dc 100644 --- a/tests/control_plane_ts/automation_cadence.test.ts +++ b/tests/control_plane_ts/automation_cadence.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import test from "node:test"; -import {syncBuiltinESMExports} from "node:module"; import {mkdtemp, mkdir, rm, readFile, writeFile, readdir} from "node:fs/promises"; import {syncBuiltinESMExports} from "node:module"; import {tmpdir} from "node:os"; diff --git a/tests/control_plane_ts/effect_runtime_snapshot.test.ts b/tests/control_plane_ts/effect_runtime_snapshot.test.ts index 8f67523c05..c4eea51949 100644 --- a/tests/control_plane_ts/effect_runtime_snapshot.test.ts +++ b/tests/control_plane_ts/effect_runtime_snapshot.test.ts @@ -4,15 +4,13 @@ import {mkdtemp, writeFile, chmod, symlink, rm} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; import {test} from "node:test"; -import {execFile, spawnSync} from "node:child_process"; +import {execFile} from "node:child_process"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; import {fileURLToPath} from "node:url"; async function windowsFixture(directory: string, path: string): Promise { if (process.platform !== "win32") return; - const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN ?? - spawnSync("python", ["-c", "import sys;print(sys.executable)"], - {encoding: "utf8", windowsHide: true, timeout: 5000}).stdout?.trim(); - assert.ok(python, "Windows fixture needs its selected Python interpreter"); + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN ?? resolveTestPython(); process.env.LOOPX_EFFECT_RUNTIME_PYTHON_BIN = python; const helper = fileURLToPath(new URL("../../loopx/control_plane/runtime/private_permissions.py", import.meta.url)); await command(python, ["-c", "import runpy,sys;from pathlib import Path;m=runpy.run_path(sys.argv[1]);m['protect_private_path'](Path(sys.argv[2]),directory=True);m['protect_private_path'](Path(sys.argv[3]))", helper, directory, path]); diff --git a/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts index 6a3be3e93b..c549f044a7 100644 --- a/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts +++ b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts @@ -34,3 +34,19 @@ for(const shape of ["closed_before_guard","unsettled","missing_source","duplicat assert.throws(()=>run(obligation,items),/lifecycle source does not match/); }); } + +test("sealed admission retains a still-pending original lifecycle", () => { + const result = run(source, structuredClone(admitted) as typeof current); + assert.equal((result.obligation as Record).resolution_mode, "todo_lifecycle_settlement"); + assert.equal((result.obligation as Record).selection_binding, "heartbeat_receipt"); +}); + + +test("sealed admission accepts the lifecycle owner's closeout timestamp", () => { + const items = structuredClone(current); + items[0].updated_at = "2026-09-03T00:00:00Z"; + assert.equal((run(source, items).obligation as Record).resolution_mode, + "receipt_bound_replan_settlement"); + items[0].updated_at = "2026-09-01T12:00:00Z"; + assert.throws(() => run(source, items), /lifecycle source does not match/); +}); diff --git a/tests/managed_effect_runtime_fixture.py b/tests/managed_effect_runtime_fixture.py index ce580c9bc2..360b44bfc2 100644 --- a/tests/managed_effect_runtime_fixture.py +++ b/tests/managed_effect_runtime_fixture.py @@ -6,11 +6,13 @@ from uuid import uuid4 from loopx.control_plane import effect_runtime +from loopx.control_plane.runtime.file_paths import windows_extended_path def capture_disposable_effect_runtimes(root: Path) -> list[tuple[Path, str]]: if root.is_symlink(): return [] + root = windows_extended_path(root) owned_root = root.resolve() captured = [] for locator in root.glob("**/loopx-effect-runtime-*/runtime-*.json"): @@ -30,6 +32,7 @@ def stop_disposable_effect_runtimes( ) -> None: if root.is_symlink(): return + root = windows_extended_path(root) owned_root = root.resolve() records = dict.fromkeys([*(captured or []), *capture_disposable_effect_runtimes(root)]) if not records: @@ -61,7 +64,7 @@ def stop_disposable_effect_runtimes( # the server's frozen physical publication path, never a path # claimed by copied JSON. Unknown/older servers remain untouched. published = ping.get("info_path") - if not isinstance(published, str) or Path(published) != locator.resolve(): + if not isinstance(published, str) or windows_extended_path(Path(published).resolve()) != locator.resolve(): continue result = effect_runtime._stop_effect_runtime( locator, info, timeout=5, wait_for_process_exit=True, diff --git a/tests/test_codex_operation_host.py b/tests/test_codex_operation_host.py index 792e0e2e77..5731e7191e 100644 --- a/tests/test_codex_operation_host.py +++ b/tests/test_codex_operation_host.py @@ -102,6 +102,11 @@ def emit(value): """ +def _fake_server(tmp_path: Path, program: str = FAKE_SERVER) -> Path: + from tests.control_plane.host_process_fixture import write_python_host_fixture + return write_python_host_fixture(tmp_path / "fake-codex-operation", program) + + def _registered_host_goal(tmp_path: Path) -> Path: """Direct transport tests still require a registered live Goal at launch.""" registry = tmp_path / "registry.json" @@ -601,7 +606,7 @@ def test_owned_app_server_process_authenticates_native_metadata_and_resumes_same @pytest.mark.parametrize('tool_attempt', [False, True]) def test_checkpoint_direction_resumes_session_without_operation_or_child_adoption(tmp_path, monkeypatch, tool_attempt): executable = _fake_server(tmp_path) - options = dict(runtime_root=tmp_path / 'runtime', registry_path=tmp_path / 'registry.json', + options = dict(runtime_root=tmp_path / 'runtime', registry_path=_registered_host_goal(tmp_path), project=tmp_path, codex_bin=str(executable), model='test-model', reasoning_effort='xhigh', timeout_seconds=5) run_codex_operation_host(_request(), **options) @@ -644,10 +649,15 @@ def forbidden_plain_cli(*args, **kwargs): ) selected_route = {"host_surface": "codex-app", "thread_id": "source-thread"} observed_routes = [] + host_failures = [] def operation_host(*args, **kwargs): observed_routes.append(kwargs["source_route"]) - return run_codex_operation_host(*args, **kwargs) + try: + return run_codex_operation_host(*args, **kwargs) + except Exception as error: + host_failures.append({"error": str(error), "gate": getattr(error.__cause__, "gate", None), "cause": str(error.__cause__)}) + raise monkeypatch.setattr("loopx.control_plane.turn_driver.codex_operation_host.run_codex_operation_host", operation_host) arguments = [ @@ -657,7 +667,7 @@ def operation_host(*args, **kwargs): "--no-global-sync", "--codex-operation-tools", "--codex-bin", str(executable), "--codex-operation-source-route-json", json.dumps(selected_route), "--codex-model", "test-model", "--codex-reasoning-effort", "xhigh", - "--codex-sandbox", "read-only", "--timeout-seconds", "5", + "--codex-sandbox", "read-only", "--timeout-seconds", "30", "--validation-command-json", json.dumps([sys.executable, "-c", "import json,sys; json.load(sys.stdin)"]), ] lineage = {"goal_id": "loopx-turn-fixture", "agent_id": "codex-fixture", "todo_id": "todo_fixture0001"} @@ -671,7 +681,7 @@ def operation_host(*args, **kwargs): with contextlib.redirect_stdout(output): exit_code = cli_main([*arguments, "--execute"]) payload = json.loads(output.getvalue()) - assert exit_code == 0, json.dumps(payload, indent=2) + assert exit_code == 0, json.dumps({"payload": payload, "host_failures": host_failures}, indent=2) assert payload["host"] == {"executable": "built-in", "kind": "codex-cli"} binding = load_codex_cli_session(runtime, lineage=lineage) assert binding["operation_transport"] == "app-server-operation-tools-v0" diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 8958e9127c..94dd79191a 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -171,7 +171,7 @@ def provider(args: list[str], *, legacy_args: list[str] | None = None) -> str: assert provider_calls[1][:2] == ["todo", "complete"] -def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: +def _write_fixture(tmp_path: Path, *, provider: str = "file") -> tuple[Path, Path]: project = tmp_path / "project" project.mkdir() state_file = project / "ACTIVE_GOAL_STATE.md" @@ -218,7 +218,7 @@ def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: "registered_agents": [AGENT_ID], "storage_target": { "schema_version": "loopx_new_goal_storage_target_v1", - "provider": "file", + "provider": provider, "handoff_mode": "soft_claim", }, }, diff --git a/tests/test_project_alias_stdin.py b/tests/test_project_alias_stdin.py index 4752c905f4..075fce5d2b 100644 --- a/tests/test_project_alias_stdin.py +++ b/tests/test_project_alias_stdin.py @@ -26,7 +26,7 @@ def test_git_probe_preserves_protocol_input(missing_origin: bool) -> None: f"alias.subprocess.run=lambda argv,**kwargs: " f"run([sys.executable,'-I','-c',{fake_git!r}],**kwargs); " "result=alias._run_git(Path.cwd(),'rev-parse','--show-toplevel'); " - "print(json.dumps({'code':result.returncode,'stdout':result.stdout," + "print(json.dumps({'code':result.returncode,'stdout':result.stdout," "'remaining':sys.stdin.readline()}))" ) result = subprocess.run( diff --git a/tests/test_windows_atomic_writes.py b/tests/test_windows_atomic_writes.py index 683deaed9f..192bafa35e 100644 --- a/tests/test_windows_atomic_writes.py +++ b/tests/test_windows_atomic_writes.py @@ -177,7 +177,7 @@ def test_lark_private_json_long_path_interrupted_replace( def interrupted_replace(*args: object) -> None: raise OSError("interrupted replacement") - monkeypatch.setattr(private_json.os, "replace", interrupted_replace) + monkeypatch.setattr(file_lock.os, "replace", interrupted_replace) with pytest.raises(OSError, match="interrupted replacement"): private_json.write_private_json_atomic(target, {"status": "replacement"}) assert address.read_bytes() == before From 0f26f81c7d4d32590934acf4eb7b37a36110838f Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 21 Oct 2026 10:31:23 +0800 Subject: [PATCH 24/80] docs: align freshness qualification with canonical-only writes Signed-off-by: Tartar --- .../rfcs/loopx-overall-roadmap-v0.md | 4 ++-- .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 18 +++--------------- .../goal-vision-replan-contract-v0.md | 9 +++++---- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 9527428c75..2b00c163f4 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -1445,8 +1445,8 @@ the existing Todo owner. Default identity acquisition restores the unfinished Turn; settled work releases a new work identity. Relevant Todo, dependencies, acceptance and source changes require candidate revalidation, while unrelated revisions do not. Original successful receipts survive response loss and cold -recovery. Legacy Markdown uses its existing locks; File/SQLite and PostgreSQL -result transactions retain their typed head/CAS owner. See the +recovery. Legacy Markdown is read-only and cannot authorize Todo mutations; +File/SQLite and PostgreSQL result transactions retain their typed head/CAS owner. See the [operating protocol](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness). New supported admissions also persist the default current-Agent checkpoint continuation contract. Required checkpoints use the actual post-result/full diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 98ccb76494..b02bf4f043 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -940,21 +940,9 @@ lifecycle。 见[验收合同](../../reference/goal-acceptance-observations.md#owner-authorized-contract-v0)。 这修复局部恢复缺口,不代表 R1/R2 协同整体验收完成。 -**R1 首次交付检查点。** 新的普通 CLI/MCP/managed 工作准入在执行前持久化原始结果依据, -并由既有 Todo owner 强制验证其新鲜度。默认身份获取恢复未完成的原 Turn; -已结算工作释放后才获取新的工作身份。相关 Todo、依赖、验收及来源变化要求重新验证候选, -无关 revision 不触发失效。原成功回执在响应丢失和冷恢复后保留。legacy Markdown 复用原锁; -File/SQLite 与 PostgreSQL 结果事务保留其类型化 head/CAS owner。见 -[操作协议](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness)。 -新的已支持准入同时持久化默认的当前 Agent 检查点续跑合同。必需检查点读取真实的提交后 -完整 frontier;MCP 将其交给当前工具调用者,managed 执行则恢复原 Host/session。 -未完成结果保留原回执并复用既有 supplement owner。相关方向变化只要求重新判断方向, -保留成功结果和一次扣额。普通终态完成在检查点及扣额后复用原 Turn 的既有升级路径。 -历史 result-only、显式 first-delivery 和 supplement Turn 保留各自已持久化的合同。 -完整的 frontier 变化恢复、打包 frontend/Lark 旅程、混合版本回滚和真实模型效用,仍是未完成的 -首阶段验收。source-session 仍只支持生命周期操作,普通 Python PostgreSQL 执行仍缺少 -服务 owner 提供的 factory。独立 reviewer 需要另行注册的可选合同。这些默认行为不关闭 R1, -不认证 Goal 已完成,不建立分布式原子性,也不证明模型结果收益。 +**R1 首次交付检查点。** 新的普通 CLI/MCP/托管执行在工作前持久化原始结果依据,由现有 Todo owner 在提交时检查新鲜度。默认身份恢复未完成的 Turn;结算后才释放新的工作身份。相关 Todo、依赖、验收和来源变化要求重新验证候选结果,无关修订不会触发。成功回执支持响应丢失和冷恢复。Legacy Markdown 只读,不能授权 Todo 写入;File/SQLite 与 PostgreSQL 结果事务沿用类型化 head/CAS owner。见[操作协议](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness)。 + +新的受支持准入还保存当前 Agent 的默认 checkpoint 续跑契约。需要检查点时读取结果提交后的完整任务前沿;MCP 返回给当前工具调用者,托管执行恢复原 Host/session。非完成结果保留原回执并使用现有补充 owner。相关方向变化只重做方向判断,保留成功结果和单次扣费。普通终态完成在 checkpoint 与扣费后使用同一 Turn 的升级路径。历史 result-only、显式 first-delivery 及 supplement Turn 沿用保存的契约。完整的前沿变化恢复、打包前端/Lark 旅程、混合版本回滚与真实模型效用仍是未完成的一阶段验收。Source-session 仍只支持生命周期;普通 Python PostgreSQL 执行仍缺少 service-owned factory。独立 reviewer 需要单独注册的可选契约。这些默认值不关闭 R1、不认证 Goal 完成,也不证明分布式原子性或模型收益。 **R1 事务检查点。** 团队计划准入与整批规划现在归 `work_items/team_plan.ts`。确认后,全部已准入 lane 与持久操作回执一次提交;身份由 proposal + lane 决定,不再由 Todo 文本决定。File/SQLite 权威复用现有 CAS 与回执 owner;legacy Markdown 在原有 fence 和锁内同时写入任务和不可变回执。同一操作重试只读历史结果,接收者后来修改、完成或删除任务也不会触发重建。提交前失败不会留下部分 lane;canonical 展示投递仍 pending 时,Chat 必须恢复后才能报告验证成功。卡片列出部分分配及缺口;quota/stop 是参考,显式强制声明会被拒绝。Agent 发起的结算在 journal 首次写入时绑定同一状态基线并在结算时重读;基线缺失或已变动、或全部 lane 均为缺口的计划,记录为类型化的失败回执,不创建 Todo,重放结果不变。 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 0017114a51..4b1678738e 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -992,7 +992,8 @@ callers retain their existing identity and lease rules; mixed explicit/automatic calls do not gain a global single-Turn guarantee. MCP completion and vision recovery restore the admitted identity; the older deterministic host identity remains a compatibility fallback when there is no default work admission. -Legacy Markdown uses its existing writer locks; File/SQLite use their existing +Legacy Markdown is read-only and rejects Todo mutations with +`canonical_authority_required`; File/SQLite use their existing typed Todo head/CAS owner. PostgreSQL result-only reads use the provider head and identity, with the final check on the native Todo transaction head. Source-session remains a lifecycle-only profile under its existing runtime admission restriction. @@ -1007,8 +1008,8 @@ repair a completed result's pending direction; use `review_task_vision` and the original completion intent for that recovery. Resume unfinished work through `should_run` when ready and revalidate a retained candidate on changed facts. -Default entrypoint qualification currently covers local-registry Legacy Markdown, -File and SQLite. PostgreSQL has native result transaction/head/CAS conformance; +Default mutating entrypoint qualification targets local-registry File and SQLite; +Legacy Markdown coverage is limited to reads and rejected writes. PostgreSQL has native result transaction/head/CAS conformance; the ordinary Python CLI/MCP/managed runtime does not supply the existing service-owned PostgreSQL factory. A PostgreSQL selector therefore fails closed without legacy fallback; this slice does not add that product integration. @@ -1325,7 +1326,7 @@ committed metadata against the bound Todo; an arbitrary settled-looking flag is not admission authority. The File/SQLite public tests retain wrong-identity, missing-proof and replay negatives. -Local-registry Legacy Markdown, File and SQLite caller coverage is distinct from +Local-registry File/SQLite caller coverage and Legacy Markdown read/rejection coverage are distinct from PostgreSQL native head/CAS conformance. Service-owned PostgreSQL first-delivery factory integration remains outside this qualification. Lark contract tests use synthetic transports; live delivery and the four legacy recovery routes remain From 465ea9bbc9cae4dbcb6064920ff75257dcf8feeb Mon Sep 17 00:00:00 2001 From: Tartar Date: Sun, 25 Oct 2026 00:10:46 +0800 Subject: [PATCH 25/80] fix(freshness): validate work basis on in-flight writeback Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 21 ++ .../control_plane/goals/checkpoint_commit.ts | 44 ++-- .../goals/checkpoint_context_io.py | 56 +++-- loopx/state_refresh.py | 37 +++- .../test_inflight_result_freshness.py | 197 ++++++++++++++++++ 5 files changed, 314 insertions(+), 41 deletions(-) create mode 100644 tests/control_plane/test_inflight_result_freshness.py diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 4b1678738e..4afa3c5f47 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -933,6 +933,27 @@ decision merely because the scheduler woke up. Agents must start from projected boundary and identity flags; reconstructing a generic `semantic_closeout` command discards that continuity contract. +For a Turn admitted with result freshness, this exemption applies only to +requiring a new direction judgment. An in-flight writeback still restores the +original `delivery_result` read token and compares its basis under the same +source locks and File/SQLite provider fence as its durable append (or the +legacy source locks for an admitted legacy Turn). A changed basis rejects the +writeback with `checkpoint_read_context_stale` before history or Vision is +committed; no quota is spent. This also covers an optional Vision patch. +Read `checkpoint-context --purpose delivery_result` for the original Turn, +revalidate the retained progress, and retry. The writeback never silently +issues a replacement read. Successful historical replay precedes a later +basis change. `result_work_context` records this work check separately from +the direction checkpoint, so it cannot certify Todo completion or direction +adoption. Historical admissions without result freshness retain their path; +this local append does not expand PostgreSQL checkpoint-fence support. + +中途继续只豁免强制重新判断方向,不豁免已启用的工作依据检查。进展写回与可选 +Vision patch 都必须沿用原 Turn 的 `delivery_result` 依据;过期时先拒绝,不写入 +历史或 Vision,也不记账。显式重读并复核已有进展后才能重试。工作检查记录与方向 +判断分开,成功提交的历史重放不因后续需求变化而失效;未启用策略的历史准入保持 +兼容。本地 append 不扩展 PostgreSQL checkpoint fence 的支持范围。 + If the work actually replanned or completed the Todo, explicitly choose `semantic_closeout` and provide its required vision checkpoint. Do not simply remove the replan ACK to make a real replan pass as in-flight work. Conversely, diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 0921600b77..a0852ea1d9 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -1,4 +1,4 @@ -/** Missing-checkpoint commit. Index/source claims survive the requesting CLI; +/** Receipt-bound work and direction commits. Index/source claims survive the requesting CLI; * the real provider fence lasts through the synchronous durable append. */ import {createHash} from "node:crypto"; import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, writeFileSync} from "node:fs"; @@ -28,7 +28,7 @@ import {checkpointBasisSnapshot, evaluateCheckpointReadContext} from "./checkpoi import {withCheckpointAuthority} from "./checkpoint_authority.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; -import {checkpointContinuationRequired} from "../quota/heartbeat_receipt_identity.ts"; +import {checkpointContinuationRequired, resultFreshnessRequired} from "../quota/heartbeat_receipt_identity.ts"; const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); function readReceiptValue(path: string): unknown { @@ -59,8 +59,8 @@ function indexBytes(path: string): Buffer { const row = requireJsonObject(JSON.parse(line), "run index row"); const checkpoint = jsonObject(row.vision_checkpoint); const effect = jsonObject(row.settlement_identity)?.effect_id; - if (checkpoint?.satisfied === true && checkpoint.read_context && typeof effect === "string") { - const version = canonicalAuthoritySha256({checkpoint, recovery: row.refresh_recovery, + if (checkpoint?.satisfied === true && (checkpoint.read_context || row.result_work_context) && typeof effect === "string") { + const version = canonicalAuthoritySha256({checkpoint, result_work_context: row.result_work_context ?? null, recovery: row.refresh_recovery, json_path: row.json_path, markdown_path: row.markdown_path}); if (checkpoints.has(effect) && checkpoints.get(effect) !== version) unknown("checkpoint index has conflicting decisions"); checkpoints.set(effect, version); @@ -79,12 +79,12 @@ function committedArtifacts(prior: JsonObject, runsDir: string): JsonObject { record = requireJsonObject(JSON.parse(readFileSync(jsonPath, "utf8")), "checkpoint record"); readFileSync(markdownPath); } catch { unknown("checkpoint artifacts are unavailable"); } - for (const field of ["settlement_identity", "refresh_recovery", "vision_checkpoint"]) { - if (canonicalAuthoritySha256(record[field]) !== canonicalAuthoritySha256(prior[field])) { + for (const field of ["settlement_identity", "refresh_recovery", "vision_checkpoint", "result_work_context"]) { + if (canonicalAuthoritySha256(record[field] ?? null) !== canonicalAuthoritySha256(prior[field] ?? null)) { unknown("checkpoint artifacts disagree with the committed index"); } } - return {ok: true, replayed: true, context: jsonObject(prior.vision_checkpoint)?.read_context, + return {ok: true, replayed: true, context: prior.result_work_context ?? jsonObject(prior.vision_checkpoint)?.read_context, json_path: jsonPath, markdown_path: markdownPath}; } @@ -135,10 +135,12 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { const runsDir = join(root, "goals", goalPathSegment(identity.goal_id), "runs"); const rows = indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) .filter(line => line.trim()).map(line => requireJsonObject(JSON.parse(line), "run index row")); + const work = request.purpose === "delivery_result"; const matching = rows.filter(row => jsonObject(row.settlement_identity)?.effect_id === identity.effect_id && - jsonObject(row.vision_checkpoint)?.satisfied === true && jsonObject(row.vision_checkpoint)?.read_context != null); + jsonObject(row.vision_checkpoint)?.satisfied === true && + (work ? row.result_work_context != null : jsonObject(row.vision_checkpoint)?.read_context != null)); const path = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts", - `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id")).digest("hex")}.json`); + `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id") + (work ? ":delivery_result" : "")).digest("hex")}.json`); let receipt: JsonObject | null = null; let receiptError: unknown; try { receipt = requireJsonObject(JSON.parse(readFileSync(path, "utf8")), "read receipt"); } @@ -218,7 +220,8 @@ export async function commitCheckpoint(value: unknown): Promise { const indexPath = join(runsDir, "index.jsonl"); const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); const firstDelivery = request.purpose === "first_delivery"; - const registryPath = firstDelivery ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; + const resultWork = request.purpose === "delivery_result"; + const registryPath = firstDelivery || resultWork ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; const targets = [indexPath, ...(registryPath ? [registryPath] : []), shadowMaintenanceLockPath(root, identity.goal_id), legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); const owner = parseQuotaAccountingOwner({ @@ -229,7 +232,7 @@ export async function commitCheckpoint(value: unknown): Promise { }); const retry = requireJsonObject(request.refresh_retry, "refresh retry"); const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", - `${createHash("sha256").update(identity.effect_id).digest("hex")}.json`); + `${createHash("sha256").update(identity.effect_id + (resultWork ? ":delivery_result" : "")).digest("hex")}.json`); async function admission(): Promise { indexBytes(indexPath); @@ -339,7 +342,10 @@ export async function commitCheckpoint(value: unknown): Promise { const repeated = replay(readback); if (repeated) return repeated; const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); - if (recovery.decision !== (firstDelivery ? "append" : "supplement_checkpoint")) { + if (resultWork && !resultFreshnessRequired(jsonObject(readback.heartbeat_receipt))) { + throw new EffectRuntimeRequestError("In-flight work check requires the original protected admission"); + } + if (recovery.decision !== (firstDelivery || resultWork ? "append" : "supplement_checkpoint")) { throw new EffectRuntimeRequestError(String(recovery.reason ?? "checkpoint supplement rejected"), "checkpoint_commit_rejected"); } await requireShadowPrimaryWriteAllowed(root, identity.goal_id); @@ -364,7 +370,7 @@ export async function commitCheckpoint(value: unknown): Promise { const context = evaluateCheckpointReadContext({phase: "check", identity: binding, checkpoint_continuation: checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), purpose: request.purpose, decision_scope: request.decision_scope, - read_context_id: retry.checkpoint_read_context_id, receipt, facts: current}); + read_context_id: resultWork ? request.read_context_id : retry.checkpoint_read_context_id, receipt, facts: current}); if (context.ok !== true) return context; const record = requireJsonObject(request.record, "checkpoint record"); const row = requireJsonObject(request.index_record, "checkpoint index record"); @@ -374,12 +380,18 @@ export async function commitCheckpoint(value: unknown): Promise { jsonObject(projected.vision_checkpoint)?.satisfied !== true) { throw new EffectRuntimeRequestError("checkpoint projection does not match typed admission"); } - projected.vision_checkpoint = {...requireJsonObject(projected.vision_checkpoint, "vision checkpoint"), read_context: context}; + if (resultWork) { + if (projected.delivery_outcome !== "outcome_progress" || + jsonObject(projected.vision_checkpoint)?.delivery_boundary !== "in_flight_continuation") { + throw new EffectRuntimeRequestError("Work-basis append requires an in-flight progress writeback"); + } + projected.result_work_context = context; + } else projected.vision_checkpoint = {...requireJsonObject(projected.vision_checkpoint, "vision checkpoint"), read_context: context}; } const jsonPath = runPath(row.json_path, runsDir, ".json"); const markdownPath = runPath(row.markdown_path, runsDir, ".md"); try { - if (firstDelivery) { + if (firstDelivery || resultWork) { // Persist uncertainty before the first run artifact. A replacement // read cannot discard this attempt after a crash or a lost response. writeReceipt(receiptPath, {...requireJsonObject(receipt, "first delivery receipt"), @@ -393,7 +405,7 @@ export async function commitCheckpoint(value: unknown): Promise { } catch { unknown("checkpoint append outcome is uncertain"); } return {ok: true, replayed: false, context, json_path: jsonPath, markdown_path: markdownPath}; }, firstDelivery && !checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), false, - firstDelivery || checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt))); + firstDelivery || resultWork || checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt))); } finally { // The effect owns the end of the handed-off critical section. Releasing the // markers here also handles a caller that timed out but is still alive. diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 310a8000fd..c8e010ea22 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -91,6 +91,21 @@ def checkpoint_continuation_required(readback: Any) -> bool: })["required"] +def result_work_read_id(root: Path, identity: SettlementIdentity) -> str: + """Restore the admitted work token; a writeback must never mint a new read.""" + try: + receipt = _read_context_receipt(_receipt_path(root, identity, "delivery_result")) + token = receipt.get("read_context_id") + if isinstance(token, str) and token: + return token + except (OSError, ValueError): + pass + raise CheckpointReadContextRejected({ + "ok": False, "error_code": "delivery_work_context_required", "reread_required": True, + "error": "Restore delivery_result context for the original Turn and revalidate the retained progress before retrying.", + }) + + def result_work_context_exists(root: Path, identity: SettlementIdentity) -> bool: return _receipt_path(root, identity, "delivery_result").exists() @@ -393,14 +408,14 @@ def first_delivery_context_enrolled(root: Path, identity: SettlementIdentity, *, return False -def _inspect_attempt(root: Path, identity: SettlementIdentity) -> dict[str, Any]: +def _inspect_attempt(root: Path, identity: SettlementIdentity, purpose: str = "first_delivery") -> dict[str, Any]: # Called with the original index and source locks held. The TS inspector is # read-only; only this locked IO adapter can clear a proved absent marker. result = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { - "runtime_root": str(root.resolve()), "identity": identity.as_dict(), "check_other_attempts": True}) + "runtime_root": str(root.resolve()), "identity": identity.as_dict(), "purpose": purpose, "check_other_attempts": True}) receipt = result.pop("receipt", None) if receipt is not None: - atomic_write_json(_receipt_path(root, identity), receipt) + atomic_write_json(_receipt_path(root, identity, purpose), receipt) return result @@ -728,6 +743,13 @@ def read_checkpoint_context( previous_receipt = json.loads(_receipt_path(root, identity, purpose).read_text(encoding="utf-8")) except FileNotFoundError: pass + if purpose == "delivery_result" and (previous_receipt or {}).get("commit_attempt") is not None: + attempt = _inspect_attempt(root, identity, purpose) + if attempt.get("status") == "committed": + raise CheckpointReadContextRejected({"ok": False, + "error_code": "delivery_result_already_committed", "reread_required": False, + "error": "Progress is already committed. Replay its original writeback; do not replace its work basis."}) + previous_receipt = _read_context_receipt(_receipt_path(root, identity, purpose)) result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run, writeback_committed=readback.writeback.failure is None, quota_spend_committed=readback.spend_run is not None or readback.spend_event is not None, @@ -744,7 +766,7 @@ def read_checkpoint_context( atomic_write_json(_receipt_path(root, identity, purpose), receipt) return {**result, "read_context_id": receipt["read_context_id"], "settlement_identity": identity.as_dict(), "purpose": purpose, - "instructions": ("Read this basis and validate the candidate. Echo read_context_id as --delivery-read-context in todo complete. " + "instructions": ("Read this basis and revalidate the retained candidate or in-flight progress. Retry the original submission; todo complete requires --delivery-read-context. This read does not complete the Todo. " if purpose == "delivery_result" else "Read this basis and judge the direction again. Echo read_context_id as --checkpoint-read-context in the direction refresh for this exact Turn. ") + "A new checkpoint-context read replaces this receipt; do not run parallel confirmations " "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend."} @@ -760,17 +782,17 @@ def checkpoint_commit_guard( ) -> Iterator[dict[str, Any]]: """Capture/preview under source locks. The native save repeats the check under the real provider fence; this preliminary check is not the commit.""" - first_delivery = purpose == "first_delivery" - with _source_guard(runtime_root, identity.goal_id, state_file, registry_path if first_delivery else None): + protected = purpose != "supplement_checkpoint" + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path if protected else None): readback = read_heartbeat_settlement(runtime_root, goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, turn_instance_id=identity.turn_instance_id, replan_obligation_id=identity.replan_obligation_id, registry_path=registry_path, goal_ref=goal_ref, source_admission=source_admission, borrow_source_admission=source_admission is not None) - if first_delivery: - _inspect_attempt(runtime_root, identity) + if protected: + _inspect_attempt(runtime_root, identity, purpose) try: - receipt = json.loads(_receipt_path(runtime_root, identity).read_text(encoding="utf-8")) + receipt = json.loads(_receipt_path(runtime_root, identity, purpose).read_text(encoding="utf-8")) except FileNotFoundError: receipt = None result = _resolve(runtime_root, phase="check", identity=identity.as_dict(), read_context_id=read_context_id, @@ -782,7 +804,7 @@ def checkpoint_commit_guard( state_file, identity, goal_ref, - first_delivery=first_delivery, + first_delivery=protected, )) yield result @@ -793,19 +815,21 @@ def commit_checkpoint_run( goal_ref: Mapping[str, Any] | None = None, source_admission: Mapping[str, Any] | None = None, purpose: str = "supplement_checkpoint", decision_scope: str | None = None, + read_context_id: str | None = None, ) -> dict[str, Any]: """Handoff the held locks and parsed bytes to one native save operation.""" root = runtime_root.resolve() index = root / "goals" / identity.goal_id / "runs" / "index.jsonl" - targets = (index, *((registry_path,) if purpose == "first_delivery" else ()), + protected = purpose != "supplement_checkpoint" + targets = (index, *((registry_path,) if protected else ()), shadow_maintenance_lock_target(root, identity.goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity.goal_id), state_file) result = _checkpoint_effect("goal.checkpoint_read_context.commit", { "runtime_root": str(root), "state_file": str(state_file.resolve()), "identity": identity.as_dict(), - "purpose": purpose, "decision_scope": decision_scope, + "purpose": purpose, "decision_scope": decision_scope, "read_context_id": read_context_id, **({"registry_path": str(registry_path.resolve()), "registry_sha256": hashlib.sha256(registry_path.read_bytes()).hexdigest()} - if purpose == "first_delivery" else {}), + if protected else {}), "locks": [cross_runtime_lock_witness(target) for target in targets], "state_sha256": hashlib.sha256(state_file.read_bytes()).hexdigest(), "index_sha256": hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), @@ -815,7 +839,7 @@ def commit_checkpoint_run( state_file, identity, goal_ref, - first_delivery=purpose == "first_delivery", + first_delivery=protected, ), "refresh_retry": refresh_retry, "record": record, "index_record": index_record, "markdown": markdown, **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), @@ -833,7 +857,9 @@ def commit_checkpoint_run( def inspect_checkpoint_replay(runtime_root: Path, goal_id: str, prior: dict[str, Any]) -> None: - if isinstance(prior.get("vision_checkpoint"), dict) and prior["vision_checkpoint"].get("read_context"): + if prior.get("result_work_context") is not None or ( + isinstance(prior.get("vision_checkpoint"), dict) and prior["vision_checkpoint"].get("read_context") + ): _checkpoint_effect("goal.checkpoint_read_context.inspect_replay", { "runtime_root": str(runtime_root.resolve()), "goal_id": goal_id, "prior": prior, }) diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 3bd9256ae2..c931137a67 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -108,7 +108,7 @@ from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs from .control_plane.goals.checkpoint_context_io import ( checkpoint_commit_guard, commit_checkpoint_run, require_complete_checkpoint_index, inspect_checkpoint_replay, - first_delivery_context_enrolled, result_work_required, checkpoint_continuation_required, + first_delivery_context_enrolled, result_work_required, checkpoint_continuation_required, result_work_read_id, ) from .registry import registry_goals as registry_goals, resolve_state_file as resolve_state_file from .runtime import validate_goal_id_path_segment @@ -863,6 +863,8 @@ def refresh_state_run( default_checkpoint = False checkpoint_supplement = False first_delivery_checkpoint = False + result_work_checkpoint = False + work_read_id = None if todo_id or normalized_replan_obligation_id or turn_instance_id: if checkpoint_read_context_id or agent_vision_packet or vision_unchanged_reason: require_complete_checkpoint_index(runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl") @@ -943,6 +945,13 @@ def refresh_state_run( result_only=result_work_required(settlement_readback)) ) default_checkpoint = checkpoint_continuation_required(settlement_readback) + result_work_checkpoint = ( + not checkpoint_supplement and not first_delivery_checkpoint + and normalized_delivery_boundary == "in_flight_continuation" + and result_work_required(settlement_readback) + ) + if result_work_checkpoint: + work_read_id = result_work_read_id(runtime_root, settlement_identity) if first_delivery != first_delivery_checkpoint and not default_checkpoint: raise ValueError("first delivery requires matching --first-delivery and persisted first_delivery context; " "read and judge checkpoint-context --purpose first_delivery on the original Turn") @@ -1511,21 +1520,26 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): source_context=recommendation_source_context(current_goal, current_text, source_registry=next_action_source_registry, todo_fields=current_planning.todo_fields), runs=newest_first_runs) - if checkpoint_supplement or first_delivery_checkpoint: + checkpoint_purpose = ("delivery_result" if result_work_checkpoint else + "first_delivery" if first_delivery_checkpoint else "supplement_checkpoint") + if checkpoint_supplement or first_delivery_checkpoint or result_work_checkpoint: assert settlement_identity is not None context = usage_booking_guard.enter_context(checkpoint_commit_guard( runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, - read_context_id=checkpoint_read_context_id, + read_context_id=work_read_id if result_work_checkpoint else checkpoint_read_context_id, goal_ref=goal_ref, source_admission=source_admission, - purpose="first_delivery" if first_delivery_checkpoint else "supplement_checkpoint", + purpose=checkpoint_purpose, decision_scope="goal" if default_checkpoint else normalized_progress_scope if first_delivery_checkpoint else None, )) for projection in (record, index_record, payload): - projection["vision_checkpoint"] = { - **projection["vision_checkpoint"], "read_context": context, - } + if result_work_checkpoint: + projection["result_work_context"] = context + else: + projection["vision_checkpoint"] = { + **projection["vision_checkpoint"], "read_context": context, + } if usage_codex_session is not None: if not dry_run: runs_dir.mkdir(parents=True, exist_ok=True) @@ -1588,16 +1602,19 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): index_record["markdown_path"] = str(markdown_path) payload["json_path"] = str(json_path) payload["markdown_path"] = str(markdown_path) - if checkpoint_supplement or first_delivery_checkpoint: + if checkpoint_supplement or first_delivery_checkpoint or result_work_checkpoint: saved = commit_checkpoint_run(runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, refresh_retry=refresh_retry_request, record=record, index_record=index_record, markdown=render_state_refresh_markdown(payload) + "\n", goal_ref=goal_ref, source_admission=source_admission, - purpose="first_delivery" if first_delivery_checkpoint else "supplement_checkpoint", + purpose=checkpoint_purpose, read_context_id=work_read_id, decision_scope="goal" if default_checkpoint else normalized_progress_scope if first_delivery_checkpoint else None) for projection in (record, index_record, payload): - projection["vision_checkpoint"]["read_context"] = saved["context"] + if result_work_checkpoint: + projection["result_work_context"] = saved["context"] + else: + projection["vision_checkpoint"]["read_context"] = saved["context"] for projection in (index_record, payload): projection.update({key: saved[key] for key in ("json_path", "markdown_path")}) if saved["replayed"]: diff --git a/tests/control_plane/test_inflight_result_freshness.py b/tests/control_plane/test_inflight_result_freshness.py new file mode 100644 index 0000000000..c8f5d13586 --- /dev/null +++ b/tests/control_plane/test_inflight_result_freshness.py @@ -0,0 +1,197 @@ +"""In-flight writebacks retain the original work basis on real providers.""" +from __future__ import annotations + +import json + +import pytest + +from tests.control_plane.test_default_result_freshness import _default_fixture +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _spend_run_count, +) + + +def _admit(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', + '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID, cwd=project) + assert rc == 0, guard + context = guard['delivery_result_context'] + args = ('refresh-state', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, + '--todo-id', TODO_ID, '--turn-instance-id', context['settlement_identity']['turn_instance_id'], + '--classification', 'verified_inflight_candidate', '--delivery-outcome', 'outcome_progress', + '--delivery-batch-scale', 'implementation', '--delivery-boundary', 'in_flight_continuation', + '--delivery-workspace-path', str(project), '--no-global-sync', '--suppress-external-sinks') + return project, runtime, registry, state, context, args + + +def _runs(runtime): + return {p.name: p.read_bytes() for p in (runtime / 'goals' / GOAL_ID / 'runs').glob('*') if p.is_file() and p.suffix in {'.json', '.jsonl', '.md'}} + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('vision', [False, True]) +def test_inflight_rejects_changed_basis_then_revalidates_same_turn(provider, vision, tmp_path, monkeypatch): + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + if vision: + args += ('--vision-summary', 'Continue the originally accepted reporting route.', + '--vision-acceptance', 'The retained candidate must satisfy the current owner requirement.') + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement B\nUse the revised reporting contract.\n', encoding='utf-8') + before = _runs(runtime) + contexts = {p: p.read_bytes() for p in (runtime / 'goals' / GOAL_ID / 'checkpoint-contexts').glob('*.json')} + original_state = state.read_bytes() + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', rejected + assert _runs(runtime) == before + assert state.read_bytes() == original_state + assert all(p.read_bytes() == data for p, data in contexts.items()) + assert _spend_run_count(runtime) == 0 + rc, reread = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], + '--purpose', 'delivery_result', cwd=project) + assert rc == 0 and reread['read_context_id'] != context['read_context_id'], reread + assert 'Requirement B' in json.dumps(reread['basis']) + assert 'in-flight progress' in reread['instructions'] + rc, committed = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and committed['ok'], committed + assert committed['vision_checkpoint']['required'] is False + assert committed['vision_checkpoint']['decision'] == ('patched' if vision else 'not_required') + assert 'read_context' not in committed['vision_checkpoint'], 'A work check is not a direction decision' + assert committed['result_work_context']['read_context_id'] == reread['read_context_id'] + after = _runs(runtime) + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement C\nA later owner change.\n', encoding='utf-8') + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay.get('idempotent_replay') is True, replay + assert replay['result_work_context'] == committed['result_work_context'] + assert _runs(runtime) == after + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +def test_inflight_unchanged_basis_does_not_require_new_direction(provider, tmp_path, monkeypatch): + project, runtime, registry, _, context, args = _admit(provider, tmp_path, monkeypatch) + rc, committed = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, committed + assert committed['vision_checkpoint']['required'] is False + assert committed['vision_checkpoint']['decision'] == 'not_required' + assert committed['result_work_context']['read_context_id'] == context['read_context_id'] + assert committed['result_work_context']['purpose'] == 'delivery_result' + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_inflight_missing_work_receipt_cannot_mint_a_new_read(provider, tmp_path, monkeypatch): + from loopx.control_plane.goals.checkpoint_context_io import _receipt_path + from loopx.control_plane.quota.settlement import SettlementIdentity + project, runtime, registry, _, context, args = _admit(provider, tmp_path, monkeypatch) + path = _receipt_path(runtime, SettlementIdentity.from_runtime_payload(context['settlement_identity']), 'delivery_result') + path.unlink() + before = _runs(runtime) + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'delivery_work_context_required', rejected + assert not path.exists() + assert _runs(runtime) == before + + +@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +def test_historical_inflight_admission_keeps_its_original_policy(provider, tmp_path, monkeypatch): + from tests.control_plane.test_quota_settlement_cli import _historical_turn + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + # A synthetic persisted pre-freshness admission; never downgrade active state. + _historical_turn(runtime, context['settlement_identity']['turn_instance_id']) + state.write_text(state.read_text(encoding='utf-8') + '\nA later requirement.\n', encoding='utf-8') + rc, saved = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and saved['ok'], saved + assert 'result_work_context' not in saved + assert saved['vision_checkpoint']['decision'] == 'not_required' + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('fault', ['before_artifacts', 'after_json', 'after_index']) +def test_inflight_native_append_failure_retains_original_work(provider, fault, tmp_path, monkeypatch): + import subprocess + from loopx.control_plane.goals import checkpoint_context_io as io + from loopx.state_refresh import refresh_state_run + from tests.control_plane.test_quota_settlement_cli import REPO_ROOT + + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + barrier = tmp_path / 'barrier' + barrier.mkdir() + (barrier / 'release').touch() + original_effect = io._checkpoint_effect + + def lose_native_response(method, request): + if method != 'goal.checkpoint_read_context.commit': + return original_effect(method, request) + packet = {'mode': 'checkpoint', 'barrier': str(barrier), 'provider': provider, + 'method': method, 'params': request, 'fault': fault} + result = subprocess.run(['node', '--no-warnings', '--experimental-strip-types', + str(REPO_ROOT / 'tests/control_plane_ts/checkpoint_commit_probe.ts')], + input=json.dumps(packet), capture_output=True, text=True, cwd=REPO_ROOT, timeout=45) + assert result.returncode == 86, result.stdout + result.stderr + raise OSError('Synthetic lost native append response') + + with monkeypatch.context() as patch: + patch.setattr(io, '_checkpoint_effect', lose_native_response) + with pytest.raises(OSError, match='Synthetic lost native'): + refresh_state_run(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, + turn_instance_id=context['settlement_identity']['turn_instance_id'], + project=None, state_file=None, classification='verified_inflight_candidate', + recommended_action=None, delivery_outcome='outcome_progress', + delivery_batch_scale='implementation', delivery_boundary='in_flight_continuation', + delivery_workspace_path=project, dry_run=False, sync_global=False, + external_delivery={'suppress': True, 'resume_key': None}) + assert _spend_run_count(runtime) == 0 + if fault == 'after_index': + state.write_text(state.read_text(encoding='utf-8') + '\nChange after committed progress.\n', encoding='utf-8') + committed_runs = _runs(runtime) + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay['appended'] is False, replay + assert replay['refresh_recovery']['decision'] == 'repair_receipt', replay + assert replay['result_work_context']['read_context_id'] == context['read_context_id'] + assert _runs(runtime) == committed_runs + rc, repeated = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and repeated['idempotent_replay'], repeated + assert _runs(runtime) == committed_runs + else: + rc, reread = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], + '--purpose', 'delivery_result', cwd=project) + if fault == 'after_json': + assert rc == 1 and reread['error_code'] == 'checkpoint_commit_unknown', reread + else: + assert rc == 0 and reread['read_context_id'] != context['read_context_id'], reread + rc, saved = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and saved['result_work_context']['read_context_id'] == reread['read_context_id'], saved + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_inflight_native_append_rechecks_captured_goal_bytes(provider, tmp_path, monkeypatch): + from loopx.control_plane.goals import checkpoint_context_io as io + from loopx.state_refresh import refresh_state_run + + project, runtime, registry, state, context, _ = _admit(provider, tmp_path, monkeypatch) + before = _runs(runtime) + original_effect = io._checkpoint_effect + + def change_after_preview(method, request): + if method == 'goal.checkpoint_read_context.commit': + # An owner publication between the preliminary check and native save. + state.write_text(state.read_text(encoding='utf-8') + '\nLate requirement B.\n', encoding='utf-8') + return original_effect(method, request) + + monkeypatch.setattr(io, '_checkpoint_effect', change_after_preview) + with pytest.raises(io.CheckpointReadContextRejected, match='sources changed'): + refresh_state_run(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, + turn_instance_id=context['settlement_identity']['turn_instance_id'], + project=None, state_file=None, classification='verified_inflight_candidate', recommended_action=None, + delivery_outcome='outcome_progress', delivery_batch_scale='implementation', + delivery_boundary='in_flight_continuation', delivery_workspace_path=project, + dry_run=False, sync_global=False, external_delivery={'suppress': True, 'resume_key': None}) + # Reserving empty paths is not a committed run; the index must stay unchanged. + assert _runs(runtime).get('index.jsonl') == before.get('index.jsonl') + assert _spend_run_count(runtime) == 0 From 5d642667b68d5c29dc3e7f05359da67d14ff6f81 Mon Sep 17 00:00:00 2001 From: Tartar Date: Sun, 25 Oct 2026 00:56:14 +0800 Subject: [PATCH 26/80] fix(freshness): fence PostgreSQL and all protected result writebacks Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 45 +++++- .../postgresql_authority_store.ts | 39 +++++ .../control_plane/effect_runtime_handlers.ts | 7 +- .../goals/checkpoint_authority.ts | 22 +-- .../control_plane/goals/checkpoint_commit.ts | 32 +++-- loopx/state_refresh.py | 3 +- .../test_inflight_result_freshness.py | 78 +++++++++- .../checkpoint_provider_head.test.ts | 24 +++- ...gresql_authority_store.integration.test.ts | 133 +++++++++++++++++- 9 files changed, 349 insertions(+), 34 deletions(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 4afa3c5f47..52b8345766 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -936,7 +936,7 @@ projected boundary and identity flags; reconstructing a generic For a Turn admitted with result freshness, this exemption applies only to requiring a new direction judgment. An in-flight writeback still restores the original `delivery_result` read token and compares its basis under the same -source locks and File/SQLite provider fence as its durable append (or the +source locks and supported provider fence as its durable append (or the legacy source locks for an admitted legacy Turn). A changed basis rejects the writeback with `checkpoint_read_context_stale` before history or Vision is committed; no quota is spent. This also covers an optional Vision patch. @@ -946,13 +946,13 @@ issues a replacement read. Successful historical replay precedes a later basis change. `result_work_context` records this work check separately from the direction checkpoint, so it cannot certify Todo completion or direction adoption. Historical admissions without result freshness retain their path; -this local append does not expand PostgreSQL checkpoint-fence support. +PostgreSQL uses the service-owned fence described below, without adding a credential factory to local callers. 中途继续只豁免强制重新判断方向,不豁免已启用的工作依据检查。进展写回与可选 Vision patch 都必须沿用原 Turn 的 `delivery_result` 依据;过期时先拒绝,不写入 历史或 Vision,也不记账。显式重读并复核已有进展后才能重试。工作检查记录与方向 判断分开,成功提交的历史重放不因后续需求变化而失效;未启用策略的历史准入保持 -兼容。本地 append 不扩展 PostgreSQL checkpoint fence 的支持范围。 +兼容。PostgreSQL 使用下述服务持有的提交保护,本地调用方不增加凭据工厂。 If the work actually replanned or completed the Todo, explicitly choose `semantic_closeout` and provide its required vision checkpoint. Do not simply @@ -1428,12 +1428,45 @@ transaction. Model reasoning and projection sync remain outside it. The provider revision is returned for diagnostics, but only relevant component changes or a different store identity invalidate the basis. Old v0 receipts require a new read. +Every newly protected Turn checks its original work basis before a new result +append, including in-flight progress, semantic closeout with pending direction, +and typed blocked results. Direction cadence exemptions do not exempt result +freshness. A new Turn owns a distinct receipt; another Turn's successful replay +cannot satisfy it. Historical committed results remain replayable, and completing +missing direction or workspace settlement does not re-author that result. + +The service-owned PostgreSQL checkpoint path holds the existing shared database +incarnation lock, then a `FOR SHARE` lock on the exact tenant/Goal head. It reads +and compares the original basis and saves external checkpoint artifacts before +releasing either lock. Writers use the same head with `FOR UPDATE`; identity +rotation requires the exclusive incarnation lock. Rejection does not advance +provider revision or append result history. The synchronous save callback must +not await model, network or projection work. Like SQLite, PostgreSQL rollback +cannot undo external files; the existing attempt marker and exact indexed +readback recover a crash or lost response. Provider hooks are supplied only by +the embedding service through the existing authority-provider dependencies, never +by Agent RPC parameters. The ordinary local CLI/MCP runtime still has no database +credential factory and fails closed for an unavailable PostgreSQL binding. + +每个新准入且受保护的 Turn 在追加结果前都校验自己的原始工作依据,包括中途进展、 +方向尚未补齐的语义关闭以及类型化阻塞结果。方向判断的节奏豁免不豁免结果新鲜度; +新 Turn 的独立依据不能由其他 Turn 的成功重放替代。历史成功结果仍可重放,补齐方向 +或工作区结算也不能改写该结果。 + +服务持有的 PostgreSQL 提交路径先取得数据库身份的共享锁,再对精确租户/Goal 的 +head 行取得 `FOR SHARE` 锁;在锁内读取、比较原始依据并保存外部 checkpoint 文件。 +写入方使用同一 head 的 `FOR UPDATE`,身份轮换需要排他身份锁。拒绝不推进 provider +版本,也不追加结果历史。同步保存回调不能等待模型、网络或投影;SQL 回滚不能撤销 +外部文件,崩溃和丢失响应仍由原有尝试标记与精确索引读回恢复。provider 由服务通过 +既有依赖接口注入,Agent RPC 参数不能提供凭据或选择工厂。普通本地 CLI/MCP 尚未 +提供数据库凭据工厂,PostgreSQL 绑定不可用时继续拒绝执行。 + The ordinary local Todo command wrapper already takes the maintenance lock before committing. The provider fence additionally covers transactions through the exported provider boundary that do not take that outer lock; these are -distinct concurrency tests. Provider failures stay closed. This adds no -PostgreSQL or cross-Goal transaction support and does not move checkpoint -authority into the Todo provider. SQLite cannot roll back the external run files. +distinct concurrency tests. Provider failures stay closed. The PostgreSQL fence above does not add a +cross-Goal transaction or move checkpoint authority into the Todo provider. +Neither SQL provider can roll back the external run files. Index lock order is kernel then mutation marker for Python writers; existing quota adapters retain their kernel lock around the native marker owner. Native diff --git a/loopx/control_plane/coordination/postgresql_authority_store.ts b/loopx/control_plane/coordination/postgresql_authority_store.ts index 78d5fbd2c2..64162efcac 100644 --- a/loopx/control_plane/coordination/postgresql_authority_store.ts +++ b/loopx/control_plane/coordination/postgresql_authority_store.ts @@ -6,6 +6,7 @@ import type { AuthorityStoreCommitResult, AuthorityStoreIdentityResult, AuthorityStoreLoadResult, + AuthorityStoreHead, AuthorityStoreReadFailure, AuthorityStoreReceiptResult, AuthorityStoreScanResult, @@ -652,6 +653,44 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { } } + /** Retain the store-incarnation and Goal-head locks through a synchronous + * external checkpoint append. No authority mutation or revision is created; + * as with local providers, SQL rollback cannot undo external run files. */ + async withCheckpointHead(save: (head: AuthorityStoreHead, identity: string) => JsonObject): Promise { + const connection = await this.connect(); + let transactionOpen = false; + let releaseError: Error | undefined; + try { + await beginTenantTransaction(connection, this.tenantId, {readOnly: false}); + transactionOpen = true; + // Match commitAuthority/rotation lock order; FOR SHARE excludes a Goal + // writer without serializing independent checkpoint readers or Goals. + await lockStoreIdentityTransaction(connection, "shared"); + const identity = await requireStoreIdentity(connection); + const value = oneRow(await connection.query(`${SELECT_HEAD_SQL} FOR SHARE`, + [this.tenantId, this.goalId]), "checkpoint PostgreSQL authority head"); + if (value === null) throw new AuthorityStoreProtocolError("checkpoint authority head is missing"); + const head = decodeHeadRow(value); + if (head.head === null) throw new AuthorityStoreProtocolError("checkpoint authority head is missing"); + const result = save({head: structuredClone(head.head), cursor: head.cursor, + provider_revision: providerRevisionToken(identity, head.provider_revision)}, identity); + if (result instanceof Promise) throw new AuthorityStoreProtocolError("checkpoint save must be synchronous"); + const rollbackError = await rollback(connection); + transactionOpen = false; + if (rollbackError !== null) { + releaseError = rollbackError; + throw rollbackError; + } + return result; + } catch (error) { + if (transactionOpen) releaseError = (await rollback(connection)) ?? undefined; + else releaseError ??= asError(error); + throw error; + } finally { + await connection.release(releaseError); + } + } + async commitAuthority(commit: AuthorityStoreCommit): Promise { let normalized: AuthorityStoreCommit; let expectedRevision: ReturnType; diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 9aab62b060..e94e42a990 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -36,6 +36,7 @@ import { } from "./runtime_decode.ts"; +import type {LocalAuthorityProviderDependencies} from "./coordination/local_authority_provider.ts"; import type {TurnJournalInspectionRequest} from "./turn_driver/turn_journal.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; @@ -55,6 +56,8 @@ function lazyHandler( export interface EffectRuntimeHandlerContext { fingerprint: string; + /** Embedding-service provider hooks; never supplied in Agent RPC params. */ + readonly authorityProvider?: LocalAuthorityProviderDependencies; /** Physical publication path observed by the managed server, not its caller. */ readonly infoPath?: string; requestShutdown: () => void; @@ -364,8 +367,8 @@ export function createEffectRuntimeHandlers( ["work_item.delivery_response.project", lazyHandler(() => import("./work_items/delivery_history.ts"), ({projectDeliveryResponse}) => projectDeliveryResponse)], ["work_item.delivery_claim.validate", lazyHandler(() => import("./work_items/delivery_outcome.ts"), ({validateDeliveryClaim}) => validateDeliveryClaim)], ["goal.vision_checkpoint.evaluate", lazyHandler(() => import("./goals/vision_checkpoint.ts"), ({buildVisionCheckpoint}) => buildVisionCheckpoint)], - ["goal.checkpoint_read_context.resolve", lazyHandler(() => import("./goals/checkpoint_authority.ts"), ({resolveCheckpointReadContext}) => resolveCheckpointReadContext)], - ["goal.checkpoint_read_context.commit", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({commitCheckpoint}) => commitCheckpoint)], + ["goal.checkpoint_read_context.resolve", lazyHandler(() => import("./goals/checkpoint_authority.ts"), ({resolveCheckpointReadContext}) => params => resolveCheckpointReadContext(params, context.authorityProvider))], + ["goal.checkpoint_read_context.commit", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({commitCheckpoint}) => params => commitCheckpoint(params, context.authorityProvider))], ["goal.checkpoint_read_context.inspect_replay", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointReplay}) => inspectCheckpointReplay)], ["goal.checkpoint_read_context.inspect_attempt", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointAttempt}) => inspectCheckpointAttempt)], ["goal.vision_wait.coverage", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectVisionWaitCoverage}) => projectVisionWaitCoverage)], diff --git a/loopx/control_plane/goals/checkpoint_authority.ts b/loopx/control_plane/goals/checkpoint_authority.ts index 2f39c9d1eb..2403beddf2 100644 --- a/loopx/control_plane/goals/checkpoint_authority.ts +++ b/loopx/control_plane/goals/checkpoint_authority.ts @@ -1,11 +1,13 @@ -/** Same-head checkpoint facts and the two shipped local provider fences. - * Not an AuthorityStore extension contract or a checkpoint authority migration. */ +/** Same-head checkpoint facts and provider-owned writer fences. + * Credentials and provider construction remain with the embedding service. */ import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {authorityStoreSourceAuthority} from "../coordination/authority_store.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; import {FileAuthorityStore} from "../coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../coordination/sqlite_authority_store.ts"; +import {PostgreSqlAuthorityStore} from "../coordination/postgresql_authority_store.ts"; +import type {LocalAuthorityProviderDependencies} from "../coordination/local_authority_provider.ts"; import {openRuntimeAuthorityStore, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence} from "../coordination/legacy_writer_fence.ts"; import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; @@ -42,16 +44,18 @@ export async function withCheckpointAuthority( requireCanonical = false, resultOnly = false, completeTodoSource = false, + dependencies: LocalAuthorityProviderDependencies = {}, ): Promise { const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "failed") throw new Error(fence.reason); const source = requireJsonObject(facts.source, "checkpoint source"); if (fence.status === "missing") { - if (requireCanonical) throw new Error("first delivery freshness requires File or SQLite canonical authority"); + if (requireCanonical) throw new Error("first delivery freshness requires canonical authority"); return save({...facts, source: {...source, authority: "legacy_markdown", store_identity: null}}); } - const store = await openRuntimeAuthorityStore(root, goalId, {}); - if (!(store instanceof FileAuthorityStore) && !(store instanceof SqliteAuthorityStore)) { + const store = await openRuntimeAuthorityStore(root, goalId, dependencies); + if (!(store instanceof FileAuthorityStore) && !(store instanceof SqliteAuthorityStore) && + !(store instanceof PostgreSqlAuthorityStore)) { if (resultOnly && authorityStoreSourceAuthority(store) === "postgresql_v0") { const [head, identity] = await Promise.all([store.loadAuthority(), store.storeIdentity()]); if (head.status !== "loaded" || identity.status !== "available") { @@ -59,7 +63,7 @@ export async function withCheckpointAuthority( } return save(checkpointProviderFacts(goalId, facts, head, identity.store_identity, "postgresql_v0", true)); } - throw new Error("checkpoint supplement requires a supported local provider fence"); + throw new Error("checkpoint requires a supported provider fence"); } return await store.withCheckpointHead((head, identity) => { return save(checkpointProviderFacts(goalId, facts, head, identity, authorityStoreSourceAuthority(store), requireCanonical || resultOnly || completeTodoSource)); @@ -68,7 +72,9 @@ export async function withCheckpointAuthority( /** Called while the Python adapter holds the local source locks. Optimistic * receipts are allowed to go stale after this operation returns. */ -export async function resolveCheckpointReadContext(value: unknown): Promise { +export async function resolveCheckpointReadContext(value: unknown, + dependencies: LocalAuthorityProviderDependencies = {}, +): Promise { const request = requireJsonObject(value, "checkpoint read context request"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); const identity = requireJsonObject(request.identity, "identity"); @@ -79,7 +85,7 @@ export async function resolveCheckpointReadContext(value: unknown): Promise line.trim()).map(line => requireJsonObject(JSON.parse(line), "run index row")); const work = request.purpose === "delivery_result"; const matching = rows.filter(row => jsonObject(row.settlement_identity)?.effect_id === identity.effect_id && - jsonObject(row.vision_checkpoint)?.satisfied === true && - (work ? row.result_work_context != null : jsonObject(row.vision_checkpoint)?.read_context != null)); + (work ? row.result_work_context != null : jsonObject(row.vision_checkpoint)?.satisfied === true && + jsonObject(row.vision_checkpoint)?.read_context != null)); const path = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts", `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id") + (work ? ":delivery_result" : "")).digest("hex")}.json`); let receipt: JsonObject | null = null; @@ -205,7 +208,9 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { return {ok: true, status: "absent", read_context_id: receipt.read_context_id, receipt: unstarted}; } -export async function commitCheckpoint(value: unknown): Promise { +export async function commitCheckpoint(value: unknown, + dependencies: LocalAuthorityProviderDependencies = {}, +): Promise { const request = requireJsonObject(value, "checkpoint commit"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); const binding = requireJsonObject(request.identity, "settlement identity"); @@ -252,7 +257,8 @@ export async function commitCheckpoint(value: unknown): Promise { const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); if (recovery.decision !== "replay" && recovery.decision !== "repair_receipt") return null; const prior = requireJsonObject(readback.writeback_run, "committed checkpoint"); - if (jsonObject(prior.vision_checkpoint)?.satisfied !== true || + if ((!resultWork && jsonObject(prior.vision_checkpoint)?.satisfied !== true) || + (resultWork && prior.result_work_context == null) || jsonObject(prior.refresh_recovery)?.vision_request_digest !== recovery.vision_request_digest) { unknown("checkpoint replay does not identify a committed decision"); } @@ -377,13 +383,13 @@ export async function commitCheckpoint(value: unknown): Promise { for (const projected of [record, row]) { if (canonicalAuthoritySha256(projected.settlement_identity) !== canonicalAuthoritySha256(binding) || canonicalAuthoritySha256(projected.refresh_recovery) !== canonicalAuthoritySha256(recovery) || - jsonObject(projected.vision_checkpoint)?.satisfied !== true) { + (!resultWork && jsonObject(projected.vision_checkpoint)?.satisfied !== true)) { throw new EffectRuntimeRequestError("checkpoint projection does not match typed admission"); } if (resultWork) { - if (projected.delivery_outcome !== "outcome_progress" || - jsonObject(projected.vision_checkpoint)?.delivery_boundary !== "in_flight_continuation") { - throw new EffectRuntimeRequestError("Work-basis append requires an in-flight progress writeback"); + if (projected.delivery_outcome !== retry.delivery_outcome || + jsonObject(projected.vision_checkpoint)?.delivery_boundary !== retry.delivery_boundary) { + throw new EffectRuntimeRequestError("Work-basis append must match the admitted result writeback"); } projected.result_work_context = context; } else projected.vision_checkpoint = {...requireJsonObject(projected.vision_checkpoint, "vision checkpoint"), read_context: context}; @@ -405,7 +411,7 @@ export async function commitCheckpoint(value: unknown): Promise { } catch { unknown("checkpoint append outcome is uncertain"); } return {ok: true, replayed: false, context, json_path: jsonPath, markdown_path: markdownPath}; }, firstDelivery && !checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), false, - firstDelivery || resultWork || checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt))); + firstDelivery || resultWork || checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)), dependencies); } finally { // The effect owns the end of the handed-off critical section. Releasing the // markers here also handles a caller that timed out but is still alive. diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index c931137a67..7087325210 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -946,8 +946,7 @@ def refresh_state_run( ) default_checkpoint = checkpoint_continuation_required(settlement_readback) result_work_checkpoint = ( - not checkpoint_supplement and not first_delivery_checkpoint - and normalized_delivery_boundary == "in_flight_continuation" + refresh_recovery["decision"] == "append" and not first_delivery_checkpoint and result_work_required(settlement_readback) ) if result_work_checkpoint: diff --git a/tests/control_plane/test_inflight_result_freshness.py b/tests/control_plane/test_inflight_result_freshness.py index c8f5d13586..bb0f5464f8 100644 --- a/tests/control_plane/test_inflight_result_freshness.py +++ b/tests/control_plane/test_inflight_result_freshness.py @@ -41,7 +41,7 @@ def test_inflight_rejects_changed_basis_then_revalidates_same_turn(provider, vis contexts = {p: p.read_bytes() for p in (runtime / 'goals' / GOAL_ID / 'checkpoint-contexts').glob('*.json')} original_state = state.read_bytes() rc, rejected = _run_cli(registry, runtime, *args, cwd=project) - assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', rejected + assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', json.dumps(rejected, ensure_ascii=False) assert _runs(runtime) == before assert state.read_bytes() == original_state assert all(p.read_bytes() == data for p, data in contexts.items()) @@ -195,3 +195,79 @@ def change_after_preview(method, request): # Reserving empty paths is not a committed run; the index must stay unchanged. assert _runs(runtime).get('index.jsonl') == before.get('index.jsonl') assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('outcome', ['outcome_progress', 'outcome_gap']) +def test_semantic_writeback_also_checks_original_work_basis(provider, outcome, tmp_path, monkeypatch): + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + args = tuple('semantic_closeout' if arg == 'in_flight_continuation' else + outcome if arg == 'outcome_progress' else arg for arg in args) + if outcome == 'outcome_gap': + args += ('--progress-result-class', 'blocked', '--progress-work-item-id', TODO_ID, + '--progress-blocker-id', 'blocker_fixture', '--progress-evidence-id', 'evidence_fixture') + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement B\nUse the revised contract.\n', encoding='utf-8') + before = _runs(runtime) + rc, rejected = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', json.dumps(rejected, ensure_ascii=False) + assert _runs(runtime) == before + rc, reread = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], '--purpose', 'delivery_result', cwd=project) + assert rc == 0, reread + rc, saved = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and saved['result_work_context']['read_context_id'] == reread['read_context_id'], saved + # The original blocked-retry cadence stays exempt from direction review, + # while its result still passes the work-basis check. + assert saved['vision_checkpoint']['satisfied'] is (outcome == 'outcome_gap') + assert saved['vision_checkpoint']['decision'] == ('not_required' if outcome == 'outcome_gap' else 'missing_required') + committed = _runs(runtime) + rc, denied = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], '--purpose', 'delivery_result', cwd=project) + assert rc == 1 and denied['error_code'] == 'delivery_result_already_committed', denied + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + assert _runs(runtime) == committed + assert _spend_run_count(runtime) == 0 + if outcome == 'outcome_progress': + # Work protection must coexist with a later direction decision on the + # same Turn; these two indexed effects are not conflicting writes. + rc, direction = _run_cli(registry, runtime, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', context['settlement_identity']['turn_instance_id'], cwd=project) + assert rc == 0, direction + supplement = (*args, '--checkpoint-read-context', direction['read_context_id'], + '--vision-summary', 'Deliver the revised owner contract.', + '--vision-acceptance', 'The retained result matches the revised contract.') + rc, settled = _run_cli(registry, runtime, *supplement, cwd=project) + assert rc == 0 and settled['vision_checkpoint']['satisfied'], settled + rc, replay = _run_cli(registry, runtime, *supplement, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_each_new_turn_retains_its_own_work_basis(provider, tmp_path, monkeypatch): + project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) + first_turn = context['settlement_identity']['turn_instance_id'] + rc, first = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, first + rc, spent = _run_cli(registry, runtime, 'quota', 'spend-slot', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, '--turn-instance-id', first_turn, + '--slots', '1', '--source', 'heartbeat', '--execute', cwd=project) + assert rc == 0, spent + rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', TODO_ID, '--turn-instance-id', 'next-protected-turn', cwd=project) + assert rc == 0, guard + second = guard['delivery_result_context'] + assert second['read_context_id'] != context['read_context_id'] + state.write_text(state.read_text(encoding='utf-8') + '\n## Requirement B\nUse the revised contract for Turn two.\n', encoding='utf-8') + before = _runs(runtime) + second_args = tuple('next-protected-turn' if arg == first_turn else arg for arg in args) + rc, rejected = _run_cli(registry, runtime, *second_args, cwd=project) + assert rc == 1 and rejected.get('error_code') == 'checkpoint_read_context_stale', json.dumps(rejected, ensure_ascii=False) + assert _runs(runtime) == before + # A historical success is still replayable, but never satisfies Turn two. + rc, replay = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + assert _runs(runtime) == before diff --git a/tests/control_plane_ts/checkpoint_provider_head.test.ts b/tests/control_plane_ts/checkpoint_provider_head.test.ts index 8476580542..44b2416339 100644 --- a/tests/control_plane_ts/checkpoint_provider_head.test.ts +++ b/tests/control_plane_ts/checkpoint_provider_head.test.ts @@ -1,5 +1,6 @@ +import {inspectCheckpointAttempt} from "../../loopx/control_plane/goals/checkpoint_commit.ts"; import assert from "node:assert/strict"; -import {mkdtemp, rm} from "node:fs/promises"; +import {mkdir, mkdtemp, rm} from "node:fs/promises"; import {writeFileSync, readFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {join} from "node:path"; @@ -65,3 +66,24 @@ test("SQLite concurrent requests in one runtime do not busy-wait on an awaiting assert.equal(JSON.parse(readFileSync(join(directory, "checkpoint.json"), "utf8")).head.value, 1); assert.ok(performance.now() - started < 4000, "must not incur the SQLite 5000ms busy timeout"); }); + + +for (const work of [true, false]) { + test(`checkpoint rejects competing ${work ? "work" : "direction"} proofs on one Turn`, async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-conflict-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + await mkdir(runs, {recursive: true}); + const identity = {goal_id: "goal", agent_id: "agent", todo_id: "todo", turn_instance_id: "turn", effect_id: "effect"}; + const rows = ["read-one", "read-two"].map(read_context_id => ({settlement_identity: identity, + refresh_recovery: {}, json_path: join(runs, "result.json"), markdown_path: join(runs, "result.md"), + vision_checkpoint: work ? {decision: "missing_required", satisfied: false} : + {satisfied: true, read_context: {read_context_id}}, + ...(work ? {result_work_context: {read_context_id}} : {})})); + writeFileSync(join(runs, "index.jsonl"), JSON.stringify(rows[0]) + "\n"); + assert.equal(inspectCheckpointAttempt({runtime_root: root, identity: {...identity, effect_id: "another-turn"}}).status, "absent"); + writeFileSync(join(runs, "index.jsonl"), rows.map(row => JSON.stringify(row)).join("\n") + "\n"); + assert.throws(() => inspectCheckpointAttempt({runtime_root: root, identity, + purpose: work ? "delivery_result" : "first_delivery"}), (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + }); +} 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 4841898d76..9f6ed9f2dd 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -1,10 +1,21 @@ +import {spawnSync} from "node:child_process"; +import {readFileSync} from "node:fs"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; +import {legacyCoordinationWriterFencePath} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {localAuthorityProviderPaths} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} from "../../loopx/control_plane/effect_runtime_handlers.ts"; +import {settlementIdentity, settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {acquireFileMutationLock, releaseFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; +import {shadowMaintenanceLockPath} from "../../loopx/control_plane/coordination/shadow_management.ts"; +import {legacyCoordinationTodoLockPath} from "../../loopx/control_plane/coordination/legacy_writer_lock_paths.ts"; +import {refreshRecovery, decodeRefreshRetry} from "../../loopx/control_plane/quota/refresh_recovery.ts"; import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import assert from "node:assert/strict"; import { createHash, randomUUID } from "node:crypto"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import {dirname, join} from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; import { Pool, type PoolClient } from "pg"; @@ -670,3 +681,123 @@ test("PostgreSQL provider evicts and awaits cleanup-uncertain read connections", }); assert.equal(queries.filter((query) => query === "ROLLBACK").length, 1); }); + + +if (database && installed) { + test("PostgreSQL checkpoint holds Goal and incarnation locks through external save", async t => { + await installed; + const tenant = `checkpoint-${randomUUID()}`, goal = "checkpoint-lock"; + const store = new PostgreSqlAuthorityStore(database, {tenant_id: tenant, goal_id: goal}); + t.after(() => cleanScope(tenant, goal)); + assert.equal((await store.commitAuthority(commit(null, "seed", 1, 1))).status, "applied"); + const before = await store.loadAuthority(); + assert.equal(before.status, "loaded"); + if (before.status !== "loaded") return; + const probe = `import {Pool} from 'pg'; + const p=new Pool({connectionString:process.env.LOOPX_TEST_POSTGRES_URL}); + const c=await p.connect(); await c.query('BEGIN'); + const lock=await c.query('SELECT pg_try_advisory_xact_lock($1::bigint) AS acquired',['5522768808912313929']); + if(lock.rows[0].acquired) process.exitCode=2; + try {await c.query('SELECT head FROM loopx_control_plane.authority_heads WHERE tenant_id=$1 AND goal_id=$2 FOR UPDATE NOWAIT', process.argv.slice(1)); process.exitCode=3;} + catch(e){if(e.code!=='55P03') throw e;} + await c.query('ROLLBACK'); c.release(); await p.end();`; + const result = await store.withCheckpointHead((head, identity) => { + assert.equal(head.provider_revision, before.provider_revision); + assert.equal(identity, STORE_IDENTITY); + // Separate process: a timer in this event loop cannot observe the lock + // during the synchronous save callback. NOWAIT is the semantic oracle. + const peer = spawnSync(process.execPath, ["--input-type=module", "-e", probe, tenant, goal], + {encoding: "utf8", timeout: 10000}); + assert.equal(peer.status, 0, peer.stderr); + return {ok: true}; + }); + assert.equal(result.ok, true); + assert.deepEqual(await store.loadAuthority(), before); + await assert.rejects(store.withCheckpointHead(() => {throw new Error("save failed");}), /save failed/); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.commitAuthority(commit(before.provider_revision, "after-save", 2, 2))).status, "applied"); + const missing = new PostgreSqlAuthorityStore(database, {tenant_id: tenant, goal_id: "absent"}); + await assert.rejects(missing.withCheckpointHead(() => {throw new Error("must not save");}), /head is missing/); + }); + + test("PostgreSQL production checkpoint effects protect each Turn and preserve original work reads", async t => { + await installed; + const root = await mkdtemp(join(tmpdir(), "checkpoint-pg-turns-")); + const tenant = `checkpoint-${randomUUID()}`, goal = "checkpoint-turns", agent = "agent", todo = "todo_task"; + t.after(async () => {await cleanScope(tenant, goal); await rm(root, {recursive: true, force: true});}); + const store = new PostgreSqlAuthorityStore(database, {tenant_id: tenant, goal_id: goal}); + const projection = (text: string) => authorityProjectionFixture(goal, [{todo_id: todo, role: "agent", + status: "open", done: false, claimed_by: agent, archive_state: "active", text}]); + const seeded = await store.commitAuthority({expected_provider_revision: null, operation_id: "seed", + next_projection: projection("Requirement A"), events: [], receipts: []}); + assert.equal(seeded.status, "applied"); + const marker = localAuthorityProviderPaths(root, goal).marker; + const fence = legacyCoordinationWriterFencePath(root, goal); + await mkdir(dirname(marker), {recursive: true}); await mkdir(dirname(fence), {recursive: true}); + await writeFile(marker, JSON.stringify({schema_version: "loopx_local_authority_provider_v0", provider: "postgresql", + goal_id: goal, tenant_id: tenant, store_identity: STORE_IDENTITY})); + await writeFile(fence, JSON.stringify({schema_version: "loopx_legacy_coordination_writer_fence_v0", state: "engaged", + goal_id: goal, fence_id: "checkpoint", source_version: "synthetic", source_projection_sha256: "fixture", + expected_shadow_provider_revision: "fixture"})); + const handlers = createEffectRuntimeHandlers({fingerprint: "pg-checkpoint-test", requestShutdown() {}, + authorityProvider: {openPostgresqlStore: () => store}}); + const effect = (method: string, request: JsonObject) => dispatchEffectRuntimeMethod(handlers, method, request) as Promise; + const goalDir = join(root, "goals", goal), runs = join(goalDir, "runs"), contexts = join(goalDir, "checkpoint-contexts"); + await mkdir(runs, {recursive: true}); await mkdir(contexts); + const state = join(root, "state.md"), registry = join(root, "registry.json"), index = join(runs, "index.jsonl"); + await writeFile(state, "Requirement A"); await writeFile(registry, "{}"); await writeFile(index, ""); + const facts = {source: {state_file: state, runtime_root: root}, frontmatter: {objective: "deliver"}, + goal_prose: "Deliver the owner requirement", agent_vision: null, acceptance: null, todos: []}; + const hash = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + const log = join(goalDir, "rollout-event-log.jsonl"); + let logText = ""; + for (const turn of ["one", "two"]) { + const identity = settlementIdentityPayload(settlementIdentity({goal_id: goal, agent_id: agent, todo_id: todo, turn_instance_id: turn})); + logText += JSON.stringify({schema_version: "loopx_rollout_event_v0", event_id: turn, event_kind: "quota_should_run", + goal_id: goal, agent_id: agent, run_id: turn, details: {todo_id: todo, settlement_effect_id: identity.effect_id, + result_freshness: "turn_result_freshness_required_v1"}}) + "\n"; + await writeFile(log, logText); + const read = async (key: string): Promise => ({...await effect("goal.checkpoint_read_context.resolve", {runtime_root: root, + phase: "read", purpose: "delivery_result", result_only: true, identity, admitted_turn: true, + read_context_id: key, facts}), read_context_id: key}); + let context = await read(`work-${turn}`); + const receiptPath = join(contexts, `${createHash("sha256").update(identity.effect_id + ":delivery_result").digest("hex")}.json`); + await writeFile(receiptPath, JSON.stringify(context.receipt)); + const retry = {vision: null, unchanged_reason: null, merge_patch: false, workspace_requested: false, mutation: {}, + delivery_outcome: "outcome_progress", delivery_batch_scale: "implementation", delivery_boundary: "in_flight_continuation", + progress_observation: null}; + const recovery = refreshRecovery(decodeRefreshRetry(retry)!, null, false, undefined, false); + const row: JsonObject = {goal_id: goal, agent_id: agent, todo_id: todo, turn_instance_id: turn, + classification: "validated_progress", generated_at: "2026-01-01T00:00:00Z", settlement_identity: identity, + delivery_outcome: "outcome_progress", delivery_batch_scale: "implementation", refresh_recovery: recovery, + vision_checkpoint: {required: false, satisfied: true, decision: "not_required", delivery_boundary: "in_flight_continuation"}, + json_path: join(runs, `${turn}.json`), markdown_path: join(runs, `${turn}.md`)}; + const submit = async () => { + const targets = [index, registry, shadowMaintenanceLockPath(root, goal), legacyCoordinationTodoLockPath(root, goal), state]; + const locks = []; + for (const target of targets) locks.push(await acquireFileMutationLock(target, process.pid)); + try { + return await effect("goal.checkpoint_read_context.commit", {runtime_root: root, state_file: state, + registry_path: registry, registry_sha256: hash(registry), state_sha256: hash(state), index_sha256: hash(index), + identity, purpose: "delivery_result", read_context_id: context.read_context_id, facts, refresh_retry: retry, + record: {...row}, index_record: {...row}, markdown: "Validated progress", + locks: locks.map((lock, i) => ({target: targets[i], pid: process.pid, token: lock.token}))}); + } finally {for (const [i, lock] of locks.entries()) await releaseFileMutationLock(targets[i]!, lock.token);} + }; + if (turn === "two") { + const head = await store.loadAuthority(); assert.equal(head.status, "loaded"); + if (head.status !== "loaded") return; + assert.equal((await store.commitAuthority({expected_provider_revision: head.provider_revision, operation_id: "change", + next_projection: projection("Requirement B"), events: [], receipts: []})).status, "applied"); + const before = readFileSync(index, "utf8"); + assert.equal((await submit()).error_code, "checkpoint_read_context_stale"); + assert.equal(readFileSync(index, "utf8"), before); + context = await read("work-two-reread"); await writeFile(receiptPath, JSON.stringify(context.receipt)); + } + const saved = await submit(); assert.equal(saved.ok, true, JSON.stringify(saved)); assert.equal(saved.replayed, false); + assert.equal((saved.context as JsonObject).read_context_id, context.read_context_id); + assert.equal((await submit()).replayed, true); + } + assert.equal(readFileSync(index, "utf8").trim().split("\n").length, 2); + }); +} From 82604687ed68a1d5301c75b43c39ea5a27106a94 Mon Sep 17 00:00:00 2001 From: Tartar Date: Sun, 25 Oct 2026 02:00:09 +0800 Subject: [PATCH 27/80] fix(freshness): restore owner document and fixture prerequisites Signed-off-by: Tartar --- loopx/contract.py | 86 ++++++++- .../goals/checkpoint_authority.ts | 28 +++ .../goals/checkpoint_context_io.py | 3 +- .../goals/checkpoint_read_context.ts | 21 ++- .../test_checkpoint_goal_documents.py | 163 +++++++++++++++++ .../test_default_result_freshness.py | 4 +- .../checkpoint_read_context.test.ts | 18 ++ ...gresql_authority_store.integration.test.ts | 14 +- ...test_contract_public_fixture_allowances.py | 169 ++++++++++++++++++ 9 files changed, 498 insertions(+), 8 deletions(-) create mode 100644 tests/control_plane/test_checkpoint_goal_documents.py create mode 100644 tests/test_contract_public_fixture_allowances.py diff --git a/loopx/contract.py b/loopx/contract.py index 911dcd6b52..d1dea9a90b 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import re @@ -250,6 +251,30 @@ def _credential_hits_are_all_references(line: str) -> bool: return all(_credential_match_is_reference(line, match) for match in matches) +def _credential_hits_are_reviewed_placeholders(line: str, placeholders: list[str]) -> bool: + def reviewed(match: re.Match[str]) -> bool: + if _credential_match_is_reference(line, match): + return True + # Only complete assignment values qualify. In particular, matching the + # keyword alone or a prefix of a different secret must grant nothing. + if match.group(0).lower() not in {"tok" + "en=", "pass" + "word="}: + return False + value = line[match.end():].lstrip(" \t") + if value[:1] in {"\"", "'", "`"}: + quote = value[0] + end = value.find(quote, 1) + remainder = value[end + 1:].lstrip() + return ( + end > 0 and value[1:end] in placeholders + and (not remainder or remainder[0] in ",)]};&") + ) + literal = re.match(r"[^\s&\"'`]+", value) + return literal is not None and literal.group(0) in placeholders + + matches = list(LEAK_RULES["credential"].pattern.finditer(line)) + return bool(matches) and all(reviewed(match) for match in matches) + + DEFAULT_SCAN_SUFFIXES = {".md", ".py", ".toml", ".json", ".yaml", ".yml", ".sh"} DEFAULT_SKIP_DIRS = { ".git", @@ -342,7 +367,7 @@ def _is_local_private_state_path(path: Path, scan_root: Path) -> bool: return bool(parts & LOCAL_PRIVATE_STATE_PARTS) or path.name in LOCAL_PRIVATE_STATE_FILE_NAMES -def _public_boundary_policy(registry: dict[str, Any]) -> dict[str, str]: +def _public_boundary_policy(registry: dict[str, Any]) -> dict[str, Any]: policy = registry.get("public_boundary") if isinstance(registry, dict) else None if not isinstance(policy, dict): policy = registry.get("privacy") if isinstance(registry, dict) else None @@ -357,10 +382,21 @@ def _public_boundary_policy(registry: dict[str, Any]) -> dict[str, str]: if str(raw_mode).lower() == "allow" or policy.get("allow_tracked_private_doc_urls") is True: tracked_private_doc_urls = "allow" - return { + result: dict[str, Any] = { "schema_version": "loopx_public_boundary_policy_v0", "tracked_private_doc_urls": tracked_private_doc_urls, } + # Explicit owner-reviewed public fixtures remain scoped to the exact + # scanned content and rule. A path or test-directory name grants nothing. + if isinstance(policy.get("public_fixture_allowances"), dict) and policy["public_fixture_allowances"]: + result["public_fixture_allowances"] = policy["public_fixture_allowances"] + placeholders = policy.get("public_credential_placeholders") + if isinstance(placeholders, list) and placeholders and all( + isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9._-]{1,64}", value) + for value in placeholders + ): + result["public_credential_placeholders"] = placeholders + return result def _hit_allowed_by_policy(name: str, git: dict[str, Any], policy: dict[str, str]) -> bool: @@ -1074,6 +1110,11 @@ def scan_public_boundary( ``hits`` retains rule locations; ``blocking_hits`` excludes ambiguous IP advisories. ``ok`` requires no blocker or unread input. It never certifies an unrequested file, generated text or future publication. + + Explicit ``public_fixture_allowances`` entries bind a relative path and + ``rule_ids`` to either a whole-file ``content_sha256`` or exact UTF-8 line + digests in ``line_sha256s`` (excluding line terminators). Line allowances + permit edits elsewhere, but never additions to the reviewed line itself. """ hits: list[str] = [] blocking_hits: list[str] = [] @@ -1197,8 +1238,37 @@ def public_files() -> Iterator[Path]: ] if not candidate_rules: continue + allowance = policy.get("public_fixture_allowances", {}).get( + path.relative_to(root).as_posix() if path.is_relative_to(root) else "" + ) + fixture_rules: set[str] = set() + fixture_line_hashes: set[str] = set() + if isinstance(allowance, dict): + reviewed_lines = allowance.get("line_sha256s") + if isinstance(reviewed_lines, list) and reviewed_lines and all( + isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) + for value in reviewed_lines + ): + fixture_line_hashes = set(reviewed_lines) + if ( + isinstance(allowance, dict) + and allowance.get("content_sha256") == hashlib.sha256(text.encode("utf-8")).hexdigest() + and isinstance(allowance.get("rule_ids"), list) + ): + fixture_rules = { + name for name in allowance["rule_ids"] if isinstance(name, str) and name in LEAK_RULES + } for line_no, line in enumerate(text.splitlines(), start=1): folded_line = _prefilter_fold(line) + # Exact reviewed lines survive unrelated edits; changed or extended + # lines still require review. Paths alone never exempt content. + line_fixture_rules = ( + allowance.get("rule_ids", []) + if fixture_line_hashes + and hashlib.sha256(line.encode("utf-8")).hexdigest() in fixture_line_hashes + and isinstance(allowance.get("rule_ids"), list) + else [] + ) for name, rule in candidate_rules: if not rule.is_candidate(folded_line): continue @@ -1207,9 +1277,21 @@ def public_files() -> Iterator[Path]: scan_line = _PUBLIC_LARK_DEVELOPER_CONSOLE_HOST.sub("", scan_line) if rule.pattern.search(scan_line): hit = f"{rel_or_abs(path, root)}:{line_no}: {name}" + if name in fixture_rules or name in line_fixture_rules: + allowed_hits.append(hit) + continue if name == "credential" and _credential_hits_are_all_references(line): credential_reference_hits.append(hit) continue + if ( + name == "credential" + and policy.get("public_credential_placeholders") + and _credential_hits_are_reviewed_placeholders( + line, policy["public_credential_placeholders"] + ) + ): + allowed_hits.append(hit) + continue if ( name == "private_doc_url" and policy.get("tracked_private_doc_urls") == "allow" diff --git a/loopx/control_plane/goals/checkpoint_authority.ts b/loopx/control_plane/goals/checkpoint_authority.ts index 2403beddf2..f993117e16 100644 --- a/loopx/control_plane/goals/checkpoint_authority.ts +++ b/loopx/control_plane/goals/checkpoint_authority.ts @@ -1,6 +1,9 @@ /** Same-head checkpoint facts and provider-owned writer fences. * Credentials and provider construction remain with the embedding service. */ import type {JsonObject} from "../effect_program.ts"; +import {readFileSync} from "node:fs"; +import {resolve} from "node:path"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {authorityStoreSourceAuthority} from "../coordination/authority_store.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; @@ -16,12 +19,36 @@ import {readGoalAcceptance} from "./acceptance_contract.ts"; import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; import type {AuthorityStoreHead} from "../coordination/authority_store.ts"; +function primaryGoalDocuments(source: JsonObject): JsonObject[] { + const goal = jsonObject(source.registry_goal); + if (!Array.isArray(goal?.authority_sources)) return []; + const paths = new Set(); + for (const value of goal.authority_sources) { + const declaration = requireJsonObject(value, "Goal authority source"); + if (declaration.kind !== "goal_doc") continue; + const path = resolve(requireNonEmptyString(goal.repo, "Goal project"), + requireNonEmptyString(declaration.path, "primary Goal document path")); + if (path !== resolve(String(source.state_file))) paths.add(path); + } + return [...paths].sort().map(path => { + try { + // Match the source reader's universal-newline text contract. + return {path, prose: readFileSync(path, "utf8").replace(/\r\n?/g, "\n")}; + } catch { + throw new EffectRuntimeRequestError( + "A registered primary Goal document is unavailable; preserve the current state and locate the owner's current source, not an old plan.", + "checkpoint_goal_source_unavailable"); + } + }); +} + /** The same provider facts feed checkpoint append and Todo result CAS checks. */ export function checkpointProviderFacts( goalId: string, facts: JsonObject, head: AuthorityStoreHead, storeIdentity: string, sourceAuthority: string, protectedDelivery: boolean, ): JsonObject { const source = requireJsonObject(facts.source, "checkpoint source"); + const documents = primaryGoalDocuments(source); const projection = indexCoordinationProjection(head.head, goalId); validateCoordinationTodoReadModel(head.head, goalId); const acceptance = readGoalAcceptance(head.head, goalId); @@ -29,6 +56,7 @@ export function checkpointProviderFacts( const lease = protectedDelivery && typeof todoId === "string" ? projection.leases.get(todoId) : null; const expiry = typeof lease?.expires_at === "string" ? parseIsoTimestamp(lease.expires_at) : null; return {...facts, + ...(documents.length ? {goal_documents: documents} : {}), ...(protectedDelivery ? {execution_lease: lease ? {...lease, active: lease.status === "active" && expiry !== null && expiry > new Date()} : null} : {}), todos: projection.todo_ids.map(id => projection.todos.get(id)!), diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index c8e010ea22..c9d4e9ec4f 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -769,7 +769,8 @@ def read_checkpoint_context( "instructions": ("Read this basis and revalidate the retained candidate or in-flight progress. Retry the original submission; todo complete requires --delivery-read-context. This read does not complete the Todo. " if purpose == "delivery_result" else "Read this basis and judge the direction again. Echo read_context_id as --checkpoint-read-context in the direction refresh for this exact Turn. ") + "A new checkpoint-context read replaces this receipt; do not run parallel confirmations " - "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend."} + "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend." + + (" " + result["instructions"] if result.get("instructions") else "")} @contextmanager diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index 11724f16c2..0a2cd9a0f7 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -1,6 +1,7 @@ /** Read basis for a missing decision/qualification checkpoint, not an execution/permission lease. * The commit effect holds source/index claims and the real provider fence. */ import {settlementIdentity, type JsonObject} from "../effect_program.ts"; +import {createHash} from "node:crypto"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; @@ -14,6 +15,12 @@ import {terminalValidationRecovery} from "../todos/completion_validation_plan.ts import {decodeTodoCompletionFenceRequest, evaluateTodoCompletionFence, TODO_COMPLETION_FENCE_REQUEST_SCHEMA} from "../todos/completion_fence.ts"; +const checkpointAuthorityInstructions = + "Current primary Goal documents govern requirements; plans, Todos and prior read contexts are derived records. " + + "A changed Goal is not proof of projection corruption. Reconcile the candidate and derived work with the current requirements; " + + "never restore an existing Goal document from an old plan or read context to make a result pass. " + + "Conflicting primary documents require owner resolution. File write isolation belongs to the host; a read receipt grants no document-edit authority."; + const RECEIPT_SCHEMA = "checkpoint_read_context_v1"; const FIRST_RECEIPT_SCHEMA = "checkpoint_read_context_v2"; export type CheckpointReadPurpose = "supplement_checkpoint" | "first_delivery" | "delivery_result"; @@ -115,11 +122,15 @@ export function checkpointBasisSnapshot(request: JsonObject): JsonObject { selected.set(id, todo); pending.push(...dependencies(todo)); } + const documents = facts.goal_documents as JsonObject[] | undefined; const goal = { frontmatter: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "updated_at")), - prose: facts.goal_prose, + prose: documents?.length ? documents.map(document => document.prose).join("\n\n") : facts.goal_prose, acceptance: facts.acceptance, user_todos: records.filter(todo => todo.role === "user"), + ...(documents?.length ? {state_prose: facts.goal_prose, + primary_documents: documents.map(document => ({path: document.path, + sha256: createHash("sha256").update(String(document.prose)).digest("hex")}))} : {}), }; const basis: JsonObject = { todo: task ?? records, @@ -212,11 +223,16 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { error: "This Turn's result is already committed. Keep its original read identity for replay and read first_delivery for the pending direction."}; } const prior = jsonObject(request.prior); + const ownerReview = jsonObject(projected.basis)?.goal as JsonObject; + const previousGoal = jsonObject(jsonObject(request.receipt)?.versions)?.goal; + const reviewAuthority = ownerReview.primary_documents !== undefined || + (previousGoal !== undefined && previousGoal !== jsonObject(projected.versions)?.goal); const originalOutcome = prior === null ? null : Object.fromEntries([ "classification", "delivery_outcome", "delivery_batch_scale", "recommended_action", "vision_checkpoint", "autonomous_replan_ack", "settlement_identity", "progress_observation"].filter(key => prior[key] !== undefined) .map(key => [key, prior[key]])); - return {ok: true, ...projected, ...(originalOutcome ? {original_outcome: originalOutcome} : {}), + return {ok: true, ...projected, ...(reviewAuthority ? {instructions: checkpointAuthorityInstructions} : {}), + ...(originalOutcome ? {original_outcome: originalOutcome} : {}), ...(use !== "delivery_result" ? {vision_authoring: visionAuthoringContract()} : {}), receipt: { schema_version: use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA, ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: request.decision_scope} : {}), @@ -449,6 +465,7 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { return terminalValidationRecovery(request, receipt); } if (request.phase === "restore") return {ok: true, basis, + ...(jsonObject(basis.goal)?.primary_documents !== undefined ? {instructions: checkpointAuthorityInstructions} : {}), read_context_id: readId, settlement_identity: identity}; const terminal = jsonObject(request.terminal_input); const candidate = terminal ? terminalResultCandidate(terminal) : requireJsonObject(request.candidate, "result candidate"); diff --git a/tests/control_plane/test_checkpoint_goal_documents.py b/tests/control_plane/test_checkpoint_goal_documents.py new file mode 100644 index 0000000000..9dde4892f4 --- /dev/null +++ b/tests/control_plane/test_checkpoint_goal_documents.py @@ -0,0 +1,163 @@ +"""Primary Goal documents remain independent of derived Agent state.""" +from __future__ import annotations + +import json +import hashlib +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from tests.control_plane.test_default_result_freshness import _default_fixture +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _spend_run_count + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_owner_change_survives_agent_state_rollback(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch, handoff_mode="hard_lease") + owner = project / "GOAL.md" + owner.write_text("Export records without an envelope.\n", encoding="utf-8") + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [ + {"kind": "goal_doc", "path": "GOAL.md", "role": "primary_goal_document"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + rc, lease = _run_cli(registry, runtime, "task-lease", "acquire", "--goal-id", GOAL_ID, + "--todo-id", TODO_ID, "--owner", AGENT_ID, "--idempotency-key", "owner-document-work", + "--ttl-seconds", "600", cwd=project) + assert rc == 0, lease + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, cwd=project) + assert rc == 0, admitted + original = admitted["delivery_result_context"] + assert original["basis"]["goal"]["primary_documents"] == [ + {"path": str(owner.resolve()), "sha256": hashlib.sha256(owner.read_text(encoding="utf-8").encode()).hexdigest()}] + assert original["basis"]["goal"]["prose"] == owner.read_text(encoding="utf-8") + turn = original["settlement_identity"]["turn_instance_id"] + command = ["loopx", "todo", "complete", *identity, "--turn-instance-id", turn, + "--delivery-read-context", original["read_context_id"], + "--task-lease-idempotency-key", "owner-document-work", "--task-lease-expected-version", "1"] + + # Only the owner document changes. No Todo/provider mutation can explain + # this stale result, and an old Agent plan must not hide the new source. + updated = "Export versioned event envelopes, including initialization.\n" + owner.write_text(updated, encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *command[1:], "--evidence", "Old candidate", cwd=project) + assert rc != 0 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert stale["checkpoint_read_context"]["changed_components"] == ["goal"] + assert _spend_run_count(runtime) == 0 + + # An Agent can still edit a writable state file; doing so cannot replace + # the separately registered owner's requirements in the next read. + state.write_text(state.read_text(encoding="utf-8") + "\nAgent plan: export records without an envelope.\n", encoding="utf-8") + assert "without an envelope" in state.read_text(encoding="utf-8") + rc, current = _run_cli(registry, runtime, "checkpoint-context", *identity, + "--turn-instance-id", turn, "--purpose", "delivery_result", cwd=project) + assert rc == 0, current + assert current["basis"]["goal"]["prose"] == updated + assert "without an envelope" not in current["basis"]["goal"]["prose"] + assert isinstance(current["basis"]["goal"]["state_prose"], str) + assert current["read_context_id"] != original["read_context_id"] + assert current["settlement_identity"] == original["settlement_identity"] + assert owner.read_text(encoding="utf-8") == updated + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_missing_primary_document_cannot_be_rebuilt_from_agent_state(provider, tmp_path, monkeypatch): + project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [ + {"kind": "goal_doc", "path": "missing-goal.md", "role": "primary_goal_document"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + before = state.read_bytes() + rc, rejected = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + assert rc != 0 and rejected["error_code"] == "checkpoint_goal_source_unavailable", rejected + assert state.read_bytes() == before + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_owner_change_during_native_capture_is_rechecked_at_provider_fence(provider, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + owner = project / "GOAL.md" + owner.write_text("Initial requirement.\n", encoding="utf-8") + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [ + {"kind": "goal_doc", "path": "GOAL.md", "role": "primary_goal_document"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + assert rc == 0, admitted + wrapper = project / "capture_race.py" + wrapper.write_text(f''' +from pathlib import Path +from loopx.control_plane.goals import checkpoint_context_io +original = checkpoint_context_io.delivery_result_context_input +def race(**kwargs): + captured = original(**kwargs) + Path({str(owner)!r}).write_text("Changed owner requirement.\\n", encoding="utf-8") + Path("capture_race_seen").touch() + return captured +checkpoint_context_io.delivery_result_context_input = race +from loopx.cli import main +raise SystemExit(main()) +''', encoding="utf-8") + original = admitted["delivery_result_context"] + complete = ["--format", "json", "--registry", str(registry), "--runtime-root", str(runtime), + "todo", "complete", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", original["settlement_identity"]["turn_instance_id"], + "--delivery-read-context", original["read_context_id"]] + result = subprocess.run([sys.executable, str(wrapper), *complete, + "--evidence", "Candidate from before owner change"], cwd=project, capture_output=True, + text=True, env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[2])}) + assert result.stdout, result.stderr + rc, rejected = result.returncode, json.loads(result.stdout) + assert (project / "capture_race_seen").exists() + assert rc != 0 and rejected["error_code"] == "checkpoint_read_context_stale", rejected + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_independent_owner_change_is_checked_on_each_inflight_turn(provider, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + owner = project / "GOAL.md" + owner.write_text("Export direct records.\n", encoding="utf-8") + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["authority_sources"] = [{"kind": "goal_doc", "path": "GOAL.md"}] + registry.write_text(json.dumps(registration), encoding="utf-8") + identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + def guard(turn): + rc, result = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + *identity, "--turn-instance-id", turn, cwd=project) + assert rc == 0, result + return result["delivery_result_context"] + def writeback(turn): + return _run_cli(registry, runtime, "refresh-state", *identity, "--turn-instance-id", turn, + "--classification", "verified_inflight_candidate", "--delivery-outcome", "outcome_progress", + "--delivery-batch-scale", "implementation", "--delivery-boundary", "in_flight_continuation", + "--delivery-workspace-path", str(project), "--no-global-sync", "--suppress-external-sinks", cwd=project) + first = guard("owner-first-turn") + rc, committed = writeback("owner-first-turn") + assert rc == 0 and committed["ok"], committed + rc, spent = _run_cli(registry, runtime, "quota", "spend-slot", *identity, + "--turn-instance-id", "owner-first-turn", "--slots", "1", "--source", "heartbeat", "--execute", cwd=project) + assert rc == 0 and spent["ok"], spent + second = guard("owner-second-turn") + assert second["read_context_id"] != first["read_context_id"] + owner.write_text("Export versioned event envelopes.\n", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = index.read_bytes() + rc, stale = writeback("owner-second-turn") + assert rc != 0 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert index.read_bytes() == before + assert _spend_run_count(runtime) == 1 + rc, reread = _run_cli(registry, runtime, "checkpoint-context", *identity, + "--turn-instance-id", "owner-second-turn", "--purpose", "delivery_result", cwd=project) + assert rc == 0 and reread["basis"]["goal"]["prose"] == owner.read_text(encoding="utf-8"), reread + rc, saved = writeback("owner-second-turn") + assert rc == 0 and saved["ok"], saved + assert saved["result_work_context"]["read_context_id"] == reread["read_context_id"] + assert saved["vision_checkpoint"]["required"] is False diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index bc4ca1f066..a23d3760d1 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -12,7 +12,7 @@ ) -def _default_fixture(provider, tmp_path, monkeypatch): +def _default_fixture(provider, tmp_path, monkeypatch, *, handoff_mode="soft_claim"): isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry = _write_fixture(tmp_path) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" @@ -22,7 +22,7 @@ def _default_fixture(provider, tmp_path, monkeypatch): "archive_state": "active", "source_section": "Agent Todo"} if provider != "legacy": initialize_canonical_authority(runtime, GOAL_ID, - build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo], handoff_mode="soft_claim"), + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo], handoff_mode=handoff_mode), state_path=state, provider=provider) return project, runtime, registry, state diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index 7571600741..66ba6d3f99 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -62,6 +62,24 @@ const check = (receipt: unknown, overrides: JsonObject = {}) => evaluate({ phase: "check", identity, facts, read_context_id: "read-A", receipt, ...overrides, }); +test("primary owner requirements stay in the Goal basis when an Agent rolls back derived state", () => { + const a = [{path: "/owner/goal.md", prose: "Export records without envelopes"}]; + const b = [{path: "/owner/goal.md", prose: "Export versioned envelopes"}]; + const first = read({facts: {...facts, goal_documents: a}}); + assert.equal(first.ok, true); + const stale = check(first.receipt, {facts: {...facts, goal_documents: b}}); + assert.equal(stale.error_code, "checkpoint_read_context_stale"); + assert.deepEqual(stale.changed_components, ["goal"]); + const fresh = read({facts: {...facts, goal_prose: "Agent restored its old plan", goal_documents: b}}); + const goal = (fresh.basis as JsonObject).goal as JsonObject; + assert.equal(goal.prose, b[0].prose); + assert.equal(goal.state_prose, "Agent restored its old plan"); + assert.equal((goal.primary_documents as JsonObject[])[0].path, b[0].path); + assert.notDeepEqual((fresh.versions as JsonObject).goal, (first.versions as JsonObject).goal); + // No separately registered source preserves the historical basis shape. + assert.equal(Object.hasOwn((read().basis as JsonObject).goal as object, "primary_documents"), false); +}); + test("Todo-only recovery cannot supply a complete Goal checkpoint basis", () => { const incomplete = {...facts, frontmatter: {...facts.frontmatter, goal_source_completeness: "todo_projection_only"}}; 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 9f6ed9f2dd..43bd33ce7b 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -746,7 +746,10 @@ if (database && installed) { await mkdir(runs, {recursive: true}); await mkdir(contexts); const state = join(root, "state.md"), registry = join(root, "registry.json"), index = join(runs, "index.jsonl"); await writeFile(state, "Requirement A"); await writeFile(registry, "{}"); await writeFile(index, ""); - const facts = {source: {state_file: state, runtime_root: root}, frontmatter: {objective: "deliver"}, + const owner = join(root, "owner.md"); + await writeFile(owner, "Owner requirement A"); + const facts = {source: {state_file: state, runtime_root: root, + registry_goal: {repo: root, authority_sources: [{kind: "goal_doc", path: "owner.md"}]}}, frontmatter: {objective: "deliver"}, goal_prose: "Deliver the owner requirement", agent_vision: null, acceptance: null, todos: []}; const hash = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); const log = join(goalDir, "rollout-event-log.jsonl"); @@ -785,6 +788,15 @@ if (database && installed) { } finally {for (const [i, lock] of locks.entries()) await releaseFileMutationLock(targets[i]!, lock.token);} }; if (turn === "two") { + // An owner-file change alone must invalidate the original result, + // even though the canonical Todo head has not changed. + await writeFile(owner, "Owner requirement B"); + const beforeOwner = readFileSync(index, "utf8"); + const ownerRejected = await submit(); + assert.equal(ownerRejected.error_code, "checkpoint_read_context_stale"); + assert.equal(readFileSync(index, "utf8"), beforeOwner); + context = await read("owner-two-reread"); await writeFile(receiptPath, JSON.stringify(context.receipt)); + assert.equal(((context.basis as JsonObject).goal as JsonObject).prose, "Owner requirement B"); const head = await store.loadAuthority(); assert.equal(head.status, "loaded"); if (head.status !== "loaded") return; assert.equal((await store.commitAuthority({expected_provider_revision: head.provider_revision, operation_id: "change", diff --git a/tests/test_contract_public_fixture_allowances.py b/tests/test_contract_public_fixture_allowances.py new file mode 100644 index 0000000000..eb242d2cb5 --- /dev/null +++ b/tests/test_contract_public_fixture_allowances.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from loopx.contract import scan_public_boundary + +PASSWORD_ASSIGN = "pass" + "word=" +PRIVATE_ADDRESS = "192." + "168.3.4" + + +def test_reviewed_public_fixture_is_bound_to_content_path_and_rule( + tmp_path: Path, +) -> None: + fixture = tmp_path / "fixture.py" + original = ( + 'header = "Author' + + 'ization: Basic QWxhZGRpbjpvcGVu"\naddress = "' + + PRIVATE_ADDRESS + + '"\n' + ) + fixture.write_text(original, encoding="utf-8") + allowance = { + "content_sha256": hashlib.sha256(original.encode()).hexdigest(), + "rule_ids": ["credential", "private_ip"], + } + registry = { + "public_boundary": {"public_fixture_allowances": {"fixture.py": allowance}} + } + + # Existing private content stays blocked unless the owner explicitly binds it. + assert len(scan_public_boundary([tmp_path])["hits"]) == 2 + reviewed = scan_public_boundary([tmp_path], registry=registry) + assert reviewed["ok"] is True + assert reviewed["files"] == 1 + assert len(reviewed["allowed_hits"]) == 2 + + allowance["rule_ids"] = ["private_ip"] + scoped = scan_public_boundary([tmp_path], registry=registry) + assert scoped["hits"] == ["fixture.py:1: credential"] + + allowance["rule_ids"] = ["credential", "private_ip"] + copy = tmp_path / "other.py" + copy.write_text(original, encoding="utf-8") + assert len(scan_public_boundary([tmp_path], registry=registry)["hits"]) == 2 + copy.unlink() + + fixture.write_text(original + PASSWORD_ASSIGN + '"hunter2"\n', encoding="utf-8") + changed = scan_public_boundary([tmp_path], registry=registry) + assert len(changed["hits"]) == 3 + assert changed["allowed_hits"] == [] + + +@pytest.mark.parametrize( + "allowance", + [ + None, + "fixture.py", + {}, + {"content_sha256": "bad"}, + {"content_sha256": "bad", "rule_ids": "credential"}, + ], +) +def test_malformed_fixture_allowance_grants_nothing( + tmp_path: Path, allowance: object +) -> None: + (tmp_path / "fixture.py").write_text( + PASSWORD_ASSIGN + '"hunter2"\n', encoding="utf-8" + ) + registry = { + "public_boundary": {"public_fixture_allowances": {"fixture.py": allowance}} + } + + result = scan_public_boundary([tmp_path], registry=registry) + + assert result["hits"] == ["fixture.py:1: credential"] + assert result["allowed_hits"] == [] + + +TOKEN_ASSIGN = "tok" + "en=" + + +@pytest.mark.parametrize("value", ["secret", '"secret"', "'secret'", "secret&other=value"]) +def test_explicit_placeholder_survives_new_and_edited_public_files(tmp_path: Path, value: str) -> None: + registry = {"public_boundary": {"public_credential_placeholders": ["secret"]}} + fixture = tmp_path / "new.py" + fixture.write_text(TOKEN_ASSIGN + value + "\n", encoding="utf-8") + assert scan_public_boundary([tmp_path])["hits"] == ["new.py:1: credential"] + result = scan_public_boundary([tmp_path], registry=registry) + assert result["ok"] and result["allowed_hits"] == ["new.py:1: credential"] + # Editing a file requires no growing list of content-hash exemptions. + fixture.write_text("# edited\n" + TOKEN_ASSIGN + value + "\n", encoding="utf-8") + assert scan_public_boundary([tmp_path], registry=registry)["ok"] + + +@pytest.mark.parametrize("value", [ + "secret-real", "secret123", "Secret", "secret/real", "secret%32", "secret+real", + '"secret real"', '"secret&other"', '"secret\\nreal"', '"secret" + payload', + '"secret".suffix', '"secret"payload', "hunter2", + "secret&" + TOKEN_ASSIGN + "real-value", + "secret " + PASSWORD_ASSIGN + "hunter2", + "secret&key=AK" + "IA1234567890ABCDEF", +]) +def test_placeholder_never_grants_prefix_or_mixed_credential_permission(tmp_path: Path, value: str) -> None: + registry = {"public_boundary": {"public_credential_placeholders": ["secret"]}} + (tmp_path / "new.py").write_text(TOKEN_ASSIGN + value + "\n", encoding="utf-8") + result = scan_public_boundary([tmp_path], registry=registry) + assert result["hits"] == ["new.py:1: credential"] + assert result["allowed_hits"] == [] + + +@pytest.mark.parametrize("policy", [None, "secret", {}, [], [None], ["secret", None], ["*"]]) +def test_missing_or_malformed_placeholder_policy_preserves_default(tmp_path: Path, policy: object) -> None: + (tmp_path / "fixture.py").write_text(TOKEN_ASSIGN + "secret\n", encoding="utf-8") + baseline = scan_public_boundary([tmp_path]) + result = scan_public_boundary([tmp_path], registry={ + "public_boundary": {"public_credential_placeholders": policy}, + }) + assert result == baseline + + +def test_placeholder_does_not_exempt_unrelated_findings_or_files(tmp_path: Path) -> None: + registry = {"public_boundary": {"public_credential_placeholders": ["secret"]}} + (tmp_path / "new.py").write_text(TOKEN_ASSIGN + "secret\n" + PRIVATE_ADDRESS + "\n", encoding="utf-8") + (tmp_path / "other.md").write_text(PASSWORD_ASSIGN + "hunter2\n", encoding="utf-8") + result = scan_public_boundary([tmp_path], registry=registry) + assert result["files"] == result["scanned_files"] == 2 + assert result["hits"] == ["new.py:2: private_ip", "other.md:1: credential"] + + +def test_reviewed_line_survives_unrelated_edits_without_exempting_new_values(tmp_path: Path) -> None: + line = 'address = "' + PRIVATE_ADDRESS + '"' + fixture = tmp_path / "fixture.py" + fixture.write_text(line + "\n", encoding="utf-8") + allowance = { + "line_sha256s": [hashlib.sha256(line.encode()).hexdigest()], + "rule_ids": ["private_ip"], + } + registry = {"public_boundary": {"public_fixture_allowances": {"fixture.py": allowance}}} + assert scan_public_boundary([tmp_path])["hits"] == ["fixture.py:1: private_ip"] + fixture.write_text("# unrelated edit\n" + line + "\nassert True\n", encoding="utf-8") + result = scan_public_boundary([tmp_path], registry=registry) + assert result["ok"] and result["allowed_hits"] == ["fixture.py:2: private_ip"] + # A line copied to an unreviewed path has no allowance. + (tmp_path / "other.py").write_text(line + "\n", encoding="utf-8") + assert scan_public_boundary([tmp_path], registry=registry)["hits"] == ["other.py:1: private_ip"] + (tmp_path / "other.py").unlink() + # New lines, changed values and appended credentials stay blocked. + fixture.write_text(line + "\n" + PRIVATE_ADDRESS.replace("3.4", "3.5") + "\n", encoding="utf-8") + assert scan_public_boundary([tmp_path], registry=registry)["hits"] == ["fixture.py:2: private_ip"] + fixture.write_text(line + " " + PASSWORD_ASSIGN + '"hunter2"\n', encoding="utf-8") + result = scan_public_boundary([tmp_path], registry=registry) + assert len(result["hits"]) == 2 and result["allowed_hits"] == [] + allowance["rule_ids"] = ["credential"] + fixture.write_text(line + "\n", encoding="utf-8") + assert scan_public_boundary([tmp_path], registry=registry)["hits"] == ["fixture.py:1: private_ip"] + + +@pytest.mark.parametrize("hashes", [None, "*", [], [None], ["bad"], ["0" * 64, None]]) +def test_invalid_reviewed_line_digests_preserve_default(tmp_path: Path, hashes: object) -> None: + (tmp_path / "fixture.py").write_text(PRIVATE_ADDRESS + "\n", encoding="utf-8") + registry = {"public_boundary": {"public_fixture_allowances": {"fixture.py": { + "line_sha256s": hashes, "rule_ids": ["private_ip"], + }}}} + result = scan_public_boundary([tmp_path], registry=registry) + assert result["hits"] == ["fixture.py:1: private_ip"] + assert result["allowed_hits"] == [] From 5b70655d429f8cc1752cb481185dbf6ce0c698f6 Mon Sep 17 00:00:00 2001 From: Tartar Date: Sun, 25 Oct 2026 02:00:10 +0800 Subject: [PATCH 28/80] docs: preserve owner authority during freshness recovery Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 22 +++++++++++++++++++ skills/loopx-self-repair/SKILL.md | 6 +++++ .../references/repair-patterns.md | 1 + 3 files changed, 29 insertions(+) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 52b8345766..29173f9caa 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -1081,6 +1081,28 @@ admitted heartbeat receipt; failed basis reads do not dispatch work or spend. Native Todo read/write and lease operations keep their existing authority. No automatic reconstruction of missing Goal narrative is claimed. +Protected reads also bind separately registered `authority_sources` of kind +`goal_doc`, using the existing primary-document declaration. Their current text +is returned as `basis.goal.prose`, with paths and normalized-text digests in +`basis.goal.primary_documents`. The previous state prose remains available as +`basis.goal.state_prose`; it does not replace the primary requirements. All three +participate in Goal freshness, without duplicating primary document bodies. +Rewriting the Agent state file cannot remove that independent requirement basis; +an unavailable primary document blocks the read without reconstructing it from +old plans. With no separate document, the existing state-file basis is unchanged. +Plans and Todos remain derived records. A changed requirement alone is not +projection corruption or permission to restore older requirements. This is not +shell write isolation: hosts needing that guarantee must keep owner documents +outside Agent-writable files **and directories**, while leaving Todo state writable. + +受保护读取同时绑定已登记的独立 `goal_doc` 原始目标文档,内容随 +`basis.goal.prose` 返回,路径与规范化文本摘要放在 `basis.goal.primary_documents`, +原状态正文保留在 `basis.goal.state_prose`,三者均参与新鲜度校验。修改 Agent 状态文件不能 +替换这份独立依据;原始文档不可用时拒绝读取,不从旧计划重建。未登记独立文档时 +保留原状态文件依据。计划和 Todo 是派生记录,需求变化不构成投影损坏或回滚授权。 +这不是 shell 写权限隔离;需要硬保护的 Host 必须同时保护 owner 文件及其父目录, +并让 Todo 状态保持可写。 + The initial default quota JSON packet grew because it carries the work basis consumed by the Agent. Matched fixed-fixture measurements against the pre-adoption checkpoint branch were 18,096 to 22,993 characters (small), 34,553 to 37,912 diff --git a/skills/loopx-self-repair/SKILL.md b/skills/loopx-self-repair/SKILL.md index 2eeb7ec626..9a524a6cab 100644 --- a/skills/loopx-self-repair/SKILL.md +++ b/skills/loopx-self-repair/SKILL.md @@ -34,6 +34,12 @@ not only an apology or a one-off explanation. - active-state authoring gap; - benchmark harness mismatch; - docs/process hygiene gap. + A changed owner requirement is not evidence of projection corruption. + Compare the current primary document with the derived Todo before choosing + repair: update the plan or implementation to follow current requirements. + Never overwrite an existing owner document from an old plan, Todo or read + context. Restore missing source only from a verified current owner source; + conflicting owner sources require resolution, not an inferred rollback. 5. **Repair at the lowest durable layer.** - If it is a one-off agent mistake, write back the correct state/todo and size the next scoped effort to its verifiable result, evidence and risk. diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 5fcaf556a0..167349dd9b 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -18,6 +18,7 @@ teaches a reusable control-plane lesson. | `premerge_binary_visual_coverage` | A first-screen PNG makes premerge fail with invalid UTF-8, although its text checks are clean. | Exact changed paths, text scan coverage, raster evidence and the existing exact-head review boundary. | The premerge adapter feeds binary images into a text-only scan and loses the distinction between text readability and visual privacy. | Keep the text scanner unchanged; explicitly list changed images as outside text coverage and retain the existing manual-review hold, including image-only changes. Unknown binary/text decode failures remain failures. Inspect pixels, metadata and provenance in the ordinary exact-head review; never silently skip images, certify them with a text pass, add a bypass flag or demand another user presentation approval. | | `planning_visibility_requires_declared_complexity` | A flat runnable backlog hides the Goal or broader work view because no dependency, blocked work or acceptance gap has been registered. | Same-source default/opt-out planning packets, original Goal, bounded work summary, omission counts and full-read routes. | A supplemental-context condition is mistaken for evidence that no strategic planning is needed; byte reduction removes useful visibility. | Show Goal and bounded work context by default at applicable preselection boundaries, including flat backlogs. Preserve omission counts, complete-read routes and action authority. Reduce exact duplication instead of hiding work; qualify input-cost changes with matched fixtures and keep model benefit unproven until measured. | | `stale_objective_repeated_as_current` | Current planning repeatedly projects an obsolete objective despite newer direct user direction. | Recorded source versus owner-applied objective revision, current canonical basis and each peer's adoption. | Fresh file bytes are mistaken for current intent; a registry/display edit cannot establish peer adoption. | Prepare an evidence-linked operating-objective revision through the existing Goal update surface. Preserve original commitments and use owner apply. Reconcile each peer's work and record its own exact adoption before new execution; do not revise for ordinary progress/diagnostics/age, impersonate peers, or interrupt in-flight settlement. Reuse a pending proposal; adoption is once per objective revision, not per Turn or peer/work write. Verify native tool invocation, current projection and stale-execution refusal, not only serializer output. | +| `owner_requirement_misclassified_as_projection_corruption` | An Agent sees changed owner requirements beside stale derived Todos, searches for projection overwrites or stale goal prose, then restores an older plan into the current owner document. | Current registered primary documents, original read basis, exact document write, projection-only scope and later reread. | A legitimate requirement change was treated as projection corruption; old Agent plans were incorrectly promoted into owner authority. | Preserve the current owner source and revise derived work. Freshness binds separately registered goal_doc content as the primary prose through the final provider check. Never reconstruct an existing requirement from a prior read; missing or conflicting owner sources require verified recovery or owner resolution. Hosts enforce document and parent-directory write isolation. | | `history_artifact_observation_failure` | History or status readback fails with `ENAMETOOLONG`, permission denial or an I/O error while checking a recorded artifact path. | Synthetic index row, failing filesystem observation, retained Run evidence, index digest and next-read recovery. | A derived artifact availability lookup propagates an OS error and hides otherwise readable durable history on older Python versions. | Isolate filesystem lookup errors in the shared artifact observer; report availability as unconfirmed through the existing false flag, retain Run evidence and retry on the next read. Do not suppress errors reading the index itself or rewrite evidence. Cover real history/status CLI readback with File and SQLite fixtures. | | `status_cache_decode_failure` | Opt-in cached status or quota readback fails with a UTF-8 decoding error while live collection works. | Cache miss metadata and a disposable malformed-cache reproduction through both CLI consumers. | The shared cache reader handles JSON and filesystem errors but lets text decoding abort live fallback. | Classify invalid UTF-8 as an unreadable cache miss in the existing IO owner; preserve damaged bytes on reads, collect live facts, and rewrite only with the explicit cache-write option. Test current Todo selection and successful reuse after repair; never suppress canonical source failures. | | `lock_holder_decode_hides_timeout` | Lock diagnostics or a contended acquisition raises `UnicodeDecodeError` instead of reporting unreadable metadata or the typed timeout. | Synthetic damaged holder bytes, liveness readback, and a real held-lock timeout followed by normal reacquisition. | Holder text decoding fails before the JSON/error guard, masking advisory failure and incident recording. | Treat invalid UTF-8 as unreadable at the shared holder reader; retain valid alternate records and kernel ownership. Cover the incident, inspection guidance and recovery after the actual holder exits; never delete the lock as repair. | From ced32e6794d37824d7937c54385f390321c57752 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 26 Oct 2026 19:31:00 +0800 Subject: [PATCH 29/80] fix(freshness): bind superseded work to its original Turn Persist the admitted Turn identity atomically when retiring a Todo so post-result direction review and settlement can continue. Keep retirement distinct from accepted completion and reject conflicting identities. Cover retirement, replay, successor preservation and false acceptance through real CLI and provider regression tests. Signed-off-by: Tartar --- docs/project-agent-todo-contract.md | 7 +++ loopx/cli_commands/todo.py | 3 ++ .../coordination/todo_terminal_lifecycle.ts | 24 +++++++++- .../goals/checkpoint_read_context.ts | 3 +- loopx/control_plane/todos/mutation_api.py | 2 + .../todos/provider_terminal_lifecycle.py | 6 +-- .../test_superseded_turn_settlement_cli.py | 26 ++++++++++- .../checkpoint_read_context.test.ts | 2 + .../terminal_source_conformance.ts | 45 +++++++++++++++++++ 9 files changed, 110 insertions(+), 8 deletions(-) diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 2bd97bec7d..931494734f 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -1009,6 +1009,13 @@ the same monitor wait. Use `todo supersede` when the current open todo should be retired and replaced: +For work already admitted by a heartbeat, retain its original `--agent-id` and +`--turn-instance-id` when superseding, together with any required execution lease. +The atomic retirement keeps that Turn identity for post-result direction review +and settlement; it does not run or pass the retired deliverable's validation. +Finish the original Turn before selecting the successor. A later caller cannot +claim an unscoped historical retirement or replay it as accepted completion. + ```bash loopx todo supersede \ --goal-id \ diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 8a914e4f1b..702a9286df 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -774,7 +774,10 @@ def handle_todo_command( goal_ref=goal_ref, ) if args.turn_instance_id else None ) + retirement_identity = supersede_readback.identity.value if supersede_readback is not None else None payload = supersede_goal_todo( + completion_turn_key=retirement_identity.effect_id if retirement_identity is not None else None, + completion_identity_source="turn_settlement" if retirement_identity is not None else None, registry_path=registry_path, runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index efad55b08b..b65110b360 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1044,6 +1044,11 @@ function terminalTarget( // status/quota consumers do not infer an unclassified terminal record. next.completion_continuation = "active_goal"; if (successorIds.length > 0) next.superseded_by = successorIds[0]; + // Retirement is a committed result of this Turn, not deliverable acceptance. + // Never bind a previously unscoped terminal record to a later caller. + if (todo.status !== "done" && input.requested_completion_turn_key !== null) { + next.completion_turn_key = input.requested_completion_turn_key; + } } if (completion?.decision === "commit") { Object.assign(next, completion.metadata_updates); @@ -1675,7 +1680,22 @@ export async function executeCoordinationTodoTerminalLifecycle( } let completion: ReturnType | null = null; let terminalUpgradeReceipt: JsonObject | null = null; + if (input.command === "supersede" && input.requested_completion_turn_key !== null) { + if (input.requested_completion_identity_source !== "turn_settlement") { + return terminalFailure("invalid_todo_completion_transaction", + "Turn-scoped retirement requires the original turn_settlement identity"); + } + if (todo.completion_turn_key != null && todo.completion_turn_key !== input.requested_completion_turn_key) { + return terminalFailure("invalid_todo_completion_transaction", + "todo is already completed under a different completion_turn_key"); + } + } if (input.command === "complete") { + if (todo.status === "done" && typeof todo.completion_turn_key === "string" && + !(typeof todo.completion_receipt_id === "string" && todo.completion_receipt_id.length > 0)) { + return terminalFailure("invalid_todo_completion_transaction", + "Terminal work has no accepted completion receipt; settle its original retirement instead"); + } const validationRequired = todo.completion_validation_required === true; const validationSha256 = todo.completion_validation_sha256; if (normalized.operation_identity.kind === "completion_turn" && input.requested_no_followup && input.defer_no_followup !== true) { @@ -2051,9 +2071,9 @@ export async function executeCoordinationTodoTerminalLifecycle( } : {}), completion_policy: completionPolicy, completion_identity_key: - completion === null ? null : completion.completion_identity_key, + completion === null ? target.todo.completion_turn_key ?? null : completion.completion_identity_key, completion_identity_source: - completion === null ? null : completion.completion_identity_source, + completion === null ? input.requested_completion_identity_source : completion.completion_identity_source, completed_at: target.todo.completed_at, completion_receipt_id: target.todo.completion_receipt_id ?? null, ...(completionResult === null ? {} : {completion_result: completionResult}), diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index 0a2cd9a0f7..8850860f25 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -326,7 +326,8 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { (candidate.successor_todo_ids == null || (Array.isArray(candidate.successor_todo_ids) && candidate.successor_todo_ids.length === 0)) && candidate.clear_claim !== true && candidate.self_merged !== true && (candidate.role == null || candidate.role === todo.role); - if (originalReplay && unchanged) { + if (originalReplay && unchanged && + typeof todo.completion_receipt_id === "string" && todo.completion_receipt_id.length > 0) { const fence = evaluateTodoCompletionFence(decodeTodoCompletionFenceRequest({ schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, projection_source: "materialized", todo, goal_id: identity.goal_id, todo_id: identity.todo_id, diff --git a/loopx/control_plane/todos/mutation_api.py b/loopx/control_plane/todos/mutation_api.py index afa749bd43..0a246c7728 100644 --- a/loopx/control_plane/todos/mutation_api.py +++ b/loopx/control_plane/todos/mutation_api.py @@ -485,6 +485,8 @@ def complete_goal_todo( def supersede_goal_todo( *, + completion_turn_key: str | None = None, + completion_identity_source: str | None = None, registry_path: Path, goal_id: str, runtime_root_arg: str | None = None, diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 9892ae358f..892ac93e26 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -121,10 +121,8 @@ def route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any] completion_result_file=call.get("completion_result_file") if complete else None, note=call.get("note") if complete else "superseded", reason=None if complete else call.get("reason"), - completion_turn_key=call.get("completion_turn_key") if complete else None, - completion_identity_source=( - call.get("completion_identity_source") if complete else None - ), + completion_turn_key=call.get("completion_turn_key"), + completion_identity_source=call.get("completion_identity_source"), review_basis=call.get("terminal_review_basis"), expected_todo_text_sha256=call.get("expected_todo_text_sha256"), require_captured_todo_text=call.get("require_captured_todo_text") is True, diff --git a/tests/control_plane/test_superseded_turn_settlement_cli.py b/tests/control_plane/test_superseded_turn_settlement_cli.py index f80d804aee..cc29d6fd55 100644 --- a/tests/control_plane/test_superseded_turn_settlement_cli.py +++ b/tests/control_plane/test_superseded_turn_settlement_cli.py @@ -39,7 +39,7 @@ def _future_monitor(run, lease, *, link=True): @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("unscoped_first", [False, True]) @pytest.mark.parametrize("direct_link", [False, True]) -def test_supersede_existing_future_monitor_settles_only_original_turn( +def test_supersede_turn_binding_preserves_existing_future_monitor( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, unscoped_first: bool, direct_link: bool, ) -> None: @@ -75,11 +75,32 @@ def test_supersede_existing_future_monitor_settles_only_original_turn( assert retired["settlement_identity"] == identity assert retired["changed"] is (not unscoped_first) assert not marker.exists() # Superseding is not deliverable acceptance. + if not unscoped_first: + code, not_accepted = run("todo", "complete", "--goal-id", cli.GOAL_ID, *binding) + assert code == 1 and not_accepted.get("completed") is not True, not_accepted + assert not marker.exists() + code, direction = run("checkpoint-context", "--goal-id", cli.GOAL_ID, *binding, + "--purpose", "first_delivery") + if unscoped_first: + # Legacy retirement carries no original Turn proof. A later caller must + # not adopt it merely because it now has an admitted heartbeat. + assert code == 1 and direction["error_code"] == "checkpoint_result_not_committed", direction + code, listed = run("todo", "list", "--goal-id", cli.GOAL_ID) + assert code == 0, listed + source = next(t for t in listed["todos"] if t["todo_id"] == cli.TODO_ID) + monitor = next(t for t in listed["todos"] if t["todo_id"] == monitor_id) + assert source.get("completion_turn_key") is None + assert source.get("completion_receipt_id") is None + assert monitor["status"] == "open" and monitor["next_due_at"] == due + assert cli._spend_run_count(runtime) == 0 + return + assert code == 0 and direction["basis"]["todo"]["completion_turn_key"] == identity["effect_id"], direction refresh_args = ( "refresh-state", "--goal-id", cli.GOAL_ID, *binding, "--classification", "original_window_superseded", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--vision-state", "active", + "--first-delivery", "--checkpoint-read-context", direction["read_context_id"], "--vision-summary", "Retired an expired task while preserving its future observation.", "--vision-acceptance", "The future monitor remains open with its original due time.", "--no-global-sync", "--suppress-external-sinks", @@ -109,6 +130,9 @@ def test_supersede_existing_future_monitor_settles_only_original_turn( monitor = next(t for t in listed["todos"] if t["todo_id"] == monitor_id) assert source["status"] == "done" and source["successor_todo_ids"] == [monitor_id] assert source["completion_continuation"] == "active_goal" + # Turn-scoped retirement is durable, but does not fabricate acceptance. + assert source.get("completion_turn_key") == (None if unscoped_first else identity["effect_id"]) + assert source.get("completion_receipt_id") is None assert source["completion_validation_sha256"] == original["completion_validation_sha256"] assert source["required_write_scopes"] == original["required_write_scopes"] assert monitor["status"] == "open" and monitor["next_due_at"] == due diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index 66ba6d3f99..a0ae2410da 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -43,6 +43,8 @@ test("managed result replay cannot submit a different candidate, successor or cl {next_excluded_agents: ["other"]}, {no_followup: true}, {role: "user"}, {clear_claim: true}]) { assert.equal(replay({...candidate, ...changed}), null); } + assert.equal(work({phase: "completion_identity", goal_id: "goal", agent_id: "agent", + todo_id: "todo_original", todo: {...todo, completion_receipt_id: null}, identity: scoped, candidate}).replay, null); }); const facts = { todos: [ diff --git a/tests/control_plane_ts/terminal_source_conformance.ts b/tests/control_plane_ts/terminal_source_conformance.ts index 9dd8fc14ab..ad3484be95 100644 --- a/tests/control_plane_ts/terminal_source_conformance.ts +++ b/tests/control_plane_ts/terminal_source_conformance.ts @@ -491,6 +491,51 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut assert.deepEqual(await store.readReceipt(request.operation_identity.operation_id), receipt); } }); + test(`${provider}: scoped retirement binds its Turn without accepting a deliverable (${schema})`, async t => { + const {store, contender} = await factory(t); + const goal = "scoped-retirement"; + const task: JsonObject = {todo_id: "todo_obsolete", role: "agent", status: "open", done: false, + text: "Implement the previous requirement", task_class: "advancement_task", claimed_by: "agent-a", + archive_state: "active", completion_validation_required: true, + completion_validation_sha256: "a".repeat(64)}; + const successor = {...task, todo_id: "todo_replacement", text: "Implement the current requirement"}; + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: authorityProjectionFixture(goal, [task, successor], [], schema, {handoff_mode: "soft_claim"}), + events: [], receipts: []}); + const request: CoordinationTodoTerminalLifecycleInput = { + goal_id: goal, todo_id: String(task.todo_id), expected_role: "agent", command: "supersede", + actor_agent_id: "agent-a", registered_agents: ["agent-a"], lifecycle_grants: [], + authority_reason: null, decision_outcome: null, operation_identity: {kind: "explicit", operation_id: "retire-turn"}, + lease_idempotency_key: null, lease_expected_version: null, allow_user_gate_auto_acquire: false, + requested_no_followup: false, requested_completion_turn_key: "original-turn", + requested_completion_identity_source: "turn_settlement", linked_successor_todo_ids: [String(successor.todo_id)], + successor_intents: [], note: "superseded", evidence: null, reason: "Owner requirement changed", + clear_claim: false, validation_declaration: null, validation_receipt: null, completion_policy_request: null, + dry_run: false, now: new Date("2026-09-05T23:00:00Z"), + }; + const priorSuccessor = ((await loaded(store)).head.todos as JsonObject[]).find(t => t.todo_id === successor.todo_id); + const retired = await execute(store, request); + assert.equal(retired.changed, true, JSON.stringify(retired)); + assert.equal(retired.completion_identity_key, "original-turn"); + assert.equal(retired.validation_receipt, null); + assert.equal(retired.completion_receipt_id, null); + assert.equal(retired.completion_policy, null); + const after = await loaded(store); + const row = (after.head.todos as JsonObject[]).find(t => t.todo_id === task.todo_id)!; + assert.equal(row.status, "done"); + assert.equal(row.completion_turn_key, "original-turn"); + assert.equal(row.completion_continuation, "active_goal"); + assert.equal(row.completion_result, undefined); + assert.deepEqual((after.head.todos as JsonObject[]).find(t => t.todo_id === successor.todo_id), priorSuccessor); + assert.equal((await execute(contender, request)).status, "replayed"); + const foreign = await execute(store, {...request, operation_identity: {kind: "explicit", operation_id: "foreign-turn"}, + requested_completion_turn_key: "foreign-turn"}); + assert.equal(foreign.reason_code, "invalid_todo_completion_transaction"); + const falseAcceptance = await execute(store, {...request, command: "complete", + operation_identity: {kind: "explicit", operation_id: "false-acceptance"}}); + assert.equal(falseAcceptance.reason_code, "invalid_todo_completion_transaction"); + assert.deepEqual(await loaded(store), after); + }); test(`${provider}: supersede links existing work and retires its original lease atomically (${schema})`, async t => { const {store, contender} = await factory(t); const goal = "terminal-existing-successor"; From cfdffeffe5ab32a5b41883f0e44dd58c997cf0db Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 09:51:52 +0800 Subject: [PATCH 30/80] Merge evaluated freshness snapshot and combined scenario report Use the evaluated a49afcd6c file tree while retaining the previous PR5677 head as the second parent. The snapshot update is intentional; it does not claim a new mixed runtime implementation was evaluated. Add one public-safe report for the selected unchanged-task and goal-change comparisons, retaining invalid runs, native scores, full available token/time accounting and attribution limits. No new solver or judge was run. Signed-off-by: Tartar --- benchmark/edgebench/README.md | 8 + ...ness-regular-and-goal-change-evaluation.md | 255 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 docs/research/freshness-regular-and-goal-change-evaluation.md diff --git a/benchmark/edgebench/README.md b/benchmark/edgebench/README.md index b094e70260..deb289b8db 100644 --- a/benchmark/edgebench/README.md +++ b/benchmark/edgebench/README.md @@ -793,3 +793,11 @@ the truth of supplied policy metadata. The recorded task/source hashes do not prove that metadata matches the private task configuration; that provenance still needs operator review. This is an S11/E2 terminal-report repair, with no change to native scoring, integrity qualification, or study promotion. + +## Freshness diagnostic evaluation + +The [combined regular-task and goal-change evaluation report](../../docs/research/freshness-regular-and-goal-change-evaluation.md) +records the selected main and repaired freshness snapshots, original quality, +available token/time costs, adoption observations and distinct terminal failures. +It preserves invalid runs and unqualified countability; it is not a leaderboard +submission, a repeated matched study or validation of a later integration head. diff --git a/docs/research/freshness-regular-and-goal-change-evaluation.md b/docs/research/freshness-regular-and-goal-change-evaluation.md new file mode 100644 index 0000000000..99fab11403 --- /dev/null +++ b/docs/research/freshness-regular-and-goal-change-evaluation.md @@ -0,0 +1,255 @@ +# Freshness 评测报告:常规场景与目标变更场景 + +本报告合并两个场景中已选定的 main 与修复后 freshness 数据,只使用各次运行的原生评分与原始用量账本。它是一次诊断结果汇总,不是正式榜单成绩、重复实验结论或 freshness 单一机制的因果证明。 + +**目标变更场景中,修复后 freshness 更早发现并采用新需求,原始分数较高;常规场景中,main 原始分数较高。两次 freshness 最终都异常退出,因此质量表现与运行可靠性必须分开判断。** + +English summary: This report combines one unchanged-task diagnostic comparison +and one silent goal-change comparison. The repaired freshness snapshot has a +higher original score in the goal-change scenario and a lower original score +in the unchanged-task scenario. Both treatment runs remain `runner_invalid`. +All four runs remain unqualified for countable score claims. Different complete +source snapshots, a historical target baseline, single attempts, incomplete +economic cost and evaluator sensitivity limit attribution. No aggregate win +rate, leaderboard result or general superiority claim is made. + +## 1. 范围、版本与测量口径 + +| 条件 | main | 修复后 freshness | +| --- | --- | --- | +| 产品源码 | `9c9b5f5555beafb7ec7d6984b18e1fcfb63be292` | `a49afcd6c6e881d85592d5542d3fa4873ace8647` | +| 共同 runner 源码 | `c5cd8abb4883f021755e99a2c40a27a7b979b93a` | 相同 | +| 模型 / 推理设置 | `gpt-6.1-sol` / `high` | 相同 | +| 执行路径 | LoopX `heartbeat-resume` | 相同 | +| 单次原生预算上限 | 7,200 秒 | 相同 | +| 自动评测间隔 / 提交冷却 | 1,800 秒 / 216 秒 | 相同 | +| 原生评分 | 五阶段通过率按 2 / 2 / 3 / 2 / 1 加权 | 相同 | +| 工作 / 评分资源上限 | 4 CPU、16 GiB / 4 CPU、8 GiB | 相同 | +| 合成凭据规则提醒 | 相同的已批准占位符提醒 | 相同;扫描策略未放宽 | + +main 也是 LoopX 产品快照,并非裸模型基线。两个产品快照包含多项差异,不能把全部分差归因于 freshness 检查。两小时是这次比较的预算上限,不代表完成了原官方十二小时评测。 + +**分支与受测源码的关系:** 本次本地整合以 freshness 的受测文件树 +`a49afcd6c` 为准,并保留 PR #5677 旧分支头 `6e03113c7` 的提交历史。 +报告与入口链接是评测后增加的文档;这不等于对整合提交重新运行了评测, +也不证明与之后更新的 main 兼容或已达到合并条件。 + +每个场景各保留一次 main 与一次修复后 freshness 结果。目标场景的 main +是已存在的参照运行,修复后仅重新运行了 freshness;常规场景则串行运行 +main、freshness 各一次。两场景都不是重复采样;目标场景尤其不构成同步 +重跑的严格配对。本文不展示修复前 freshness 的结果,也不把这四次运行 +汇总为胜率或总体平均提升。 + +### 资格独立于原始分数 + +所有运行均保持 `score_countable=false`、`matched_pair_countable=false`。 +严格运行时隔离,尤其是求解器 shell 与模型提供商凭据的隔离,没有获得 +完整见证;原生反馈传输等资格也未因此补齐。有效的原生评分报告只证明 +某次提交被原评分器评分,不代表求解运行有效、需求全部验收通过或结果 +可进入正式榜单。两次 freshness 均保留 `runner_invalid`,不改写成成功。 + +## 2. 两场景结果总览 + +以下分数都是原生最佳及最新提交的分数;本表四次运行的最佳与最新分数分别相同。 + +| 场景 | 指标 | main | 修复后 freshness | +| --- | --- | ---: | ---: | +| 目标变更 | 原始分数 | 50.1548% | 71.5833% | +| 目标变更 | 总 token | 48,614,137 | 46,728,979 | +| 目标变更 | 未缓存输入 token | 683,402 | 580,372 | +| 目标变更 | 输出 token | 195,055 | 184,063 | +| 目标变更 | 原生执行时间 | 7,058.702 秒 | 6,855.989 秒 | +| 目标变更 | 整次进程墙钟 | 7,267.179 秒 | 6,925.531 秒 | +| 目标变更 | 求解器退出码 | 0 | 2 | +| 常规原题 | 原始分数 | 76.7262% | 73.1548% | +| 常规原题 | 总 token | 67,781,522 | 68,961,858 | +| 常规原题 | 未缓存输入 token | 782,865 | 617,460 | +| 常规原题 | 输出 token | 233,729 | 210,510 | +| 常规原题 | 原生执行时间 | 7,058.972 秒 | 7,058.968 秒 | +| 常规原题 | 整次进程墙钟 | 7,142.429 秒 | 7,135.510 秒 | +| 常规原题 | 求解器退出码 | 0 | 2 | + +目标变更场景的 freshness 原始分数高 **21.4285 个百分点**,总 token 少 +约 **3.88%**。常规场景的 freshness 原始分数低 **3.5714 个百分点**, +总 token 多约 **1.74%**,但未缓存输入少约 **21.13%**、输出少约 +**9.93%**。这些是选定样本的观察差异,不是统计显著性或货币收益。 + +## 3. 目标场景:运行中静默替换完整需求 + +最初任务是离线 OpenAPI 兼容性比较接口与命令行工具;约第 900 秒, +受保护的权威需求文档静默替换为报告基础设施、校验后 hook、pytest +集成、示例生成与格式/覆盖控制五个目标。两边都要自行读取变化并调整工作。 +评分器始终按照最终需求评分;需求变化前的提交也使用同一评分器。 +实际发布延迟分别为 901.005 秒与 901.370 秒。 + +### 3.1 原生质量与阶段进展 + +| 原评分阶段 | main | 修复后 freshness | 权重 | +| --- | ---: | ---: | ---: | +| Hooks | 11/12 | 11/12 | 2 | +| Report writers | 13/14 | 14/14 | 2 | +| Pytest reporting | 3/8 | 3/8 | 3 | +| Examples | 0/11 | 9/9 | 2 | +| Email / body generation | 2/10 | 2/10 | 1 | + +Examples 的分母按原报告保留,不把它当成完全相同的逐检查集合直接相减。 +总体比较沿用原生加权分数。freshness 的主要新增交付是 writers 与 +examples;hooks、pytest 和 generation 仍有缺口。完整通过阶段数分别 +为 0/5、2/5,它们不是总体质量分数,也不能被本地测试通过数替代。 + +| 自动评测捕获时间附近 | main | 修复后 freshness | +| --- | ---: | ---: | +| 30 分钟 | 0.020000 | 0.203333 | +| 60 分钟 | 0.203333 | 0.360476 | +| 90 分钟 | 0.464048 | 0.515833 | +| 原始最佳 / 最新 | 0.501548 | 0.715833 | + +这些是原自动评分样本,没有插值或追加评分。freshness 在共同时间窗口 +已有较高分数,但其 0.715833 只观察到一次,不能宣称稳定重复达到。 +由于提前退出,freshness 没有 120 分钟样本。主动/自动评分数分别为 +main 5/3、freshness 7/3,共 18 份原生评分报告。 + +两边最终归档的完整字节与最后一次主动提交不同;逐文件核验支持主要 +源码、测试、文档及配置相同,freshness 核对范围为 1,320 个文件。 +没有给最终归档追加评分,因此分数仍属于原始提交。 + +### 3.2 从读取变化到实际采用 + +以下时间均从新需求发布开始计算: + +| 行为 | main | 修复后 freshness | +| --- | ---: | ---: | +| 首次完整读取新需求 | 958.439 秒 | 45.559 秒 | +| 首次明确确认需求变化 | 985.457 秒 | 44.588 秒 | +| 首次成功修改新需求源码 | 1,707.271 秒 | 638.543 秒 | +| 后续实际实施模块 | hooks、writers、pytest | hooks、writers、pytest、examples | + +freshness 在 22.288 秒时已从普通交付结果上下文接触新需求标题,随后 +确认变化,再完整读取文档,所以确认略早于完整读取。两侧均未观察到 +`provider_revision_mismatch` 返回,不能把提前感知解释成一次过期依据硬拒绝。 + +旧任务被替换时,freshness 保留了原 Turn 的身份;原轮次的结果、方向 +与额度结算完成后,才进入新的实施任务。随后多个模块产生源码、验证与 +原生评分。这支持本次真实运行走通了原轮次结算和后续采用,不能只凭 +新建任务或写出重规划记录宣称成功。最终评分缺口仍然保留。 + +main 保留已有初始任务实现后推进新需求;freshness 将初始候选保留为 +独立检查点,再恢复原源码推进新需求。初始实现本身不满足新需求,但 +通用探测经验可能复用;不能把变化前全部 token 等同于浪费。 + +### 3.3 终态失败 + +main 共 10 条宿主回执,包含一次模型容量不足和尾端超时,之后仍继续 +推进并最终退出 0。退出 0 不表示全部新需求通过验收。 + +freshness 共 9 条宿主回执,其中三次失败均已调用宿主、退出 1,且未 +标记超时;明确错误是模型容量不足。最后一次发生在自主重规划阶段, +原操作效果无法确定,外部调度 worker 为避免重复执行而保留 +`replan_outcome_unknown`,最终求解器退出 2。此时距原生上限尚余 +344.011 秒,因此这次目标场景的停止原因是容量错误与未知结果恢复边界, +不能归为预算耗尽。两侧都未观察到合成凭据造成的宿主调用前拒绝。 + +## 4. 常规场景:完整原题始终不变 + +完整报告与校验增强任务从首次指令保持到终态;没有初始替代任务、定时 +发布器或中途需求编辑。main 后 freshness 串行各跑一次。两次求解和 +原生评分均已终态,分别产生 10 份与 12 份原生评分报告,共 22 份。 + +### 4.1 原生质量 + +| 原评分阶段 | main | 修复后 freshness | 权重 | +| --- | ---: | ---: | ---: | +| Hooks | 11/12 | 11/12 | 2 | +| Report writers | 12/14 | 13/14 | 2 | +| Pytest reporting | 3/8 | 3/8 | 3 | +| Examples | 9/9 | 9/9 | 2 | +| Email / body generation | 10/10 | 5/10 | 1 | + +freshness 在 writers 多过一项,在最后阶段少过五项。两边最佳与最新 +源树不同,但最新原始评分的生产源码与最终归档逐文件一致;没有把未评分 +的最终代码当成额外提升。main 完整通过 2/5 阶段,freshness 为 1/5; +这些阶段完成比例不是 76.7262% / 73.1548% 的加权分数。 + +终态后的源码与既有静态检查语义比对提示:两边都实现了邮箱域名转小写, +但内部辅助函数命名不同,部分隐藏检查对该名字敏感;五项失分与这个差异 +吻合。因此不能直接把五项失分描述为五项业务功能没做。该解释仍是静态 +推断,缺少本次逐检查错误日志的直接确认;原始分数没有修改,也没有重跑 +评分器。公开报告不包含隐藏检查实现、函数名或原始评分输出。 + +### 4.2 实际推进与未完成结算 + +freshness 在启动后有实际任务完成与后续模块实施,不是仅接通启动; +但这些过程事实不证明质量收益。原题没有变化,本场景也没有提供测量 +“发现需求变化并正确采用”的机会。 + +连续六轮工作后触发周期性重规划。模型发现报告配置从 True 更新到 +False 的问题,形成验证记录和后续方向;发现问题不等于已经修复。 +重规划写回成功,但返回仍要求登记本轮额度消耗并完成结算。约四秒后 +宿主超时,退出码 124;轨迹和持久事件未见对应结算完成。 + +调度器保留了“该重规划已启动”,但自己的记录没有完整原轮次结算身份; +模型后来补齐的身份没有同步回调度器记录。恢复代码只处理带完整身份的 +记录,因此跳过了这次恢复。防重复保护随后阻止用新轮次重做未知操作, +以 `replan_outcome_unknown` 停止,求解器退出 2。源码中的预算收尾 +预留与模型临近截止才写回共同形成时间窗口;实际恢复仍有缺口。 +这不是已经自动恢复成功,也不是一次需求版本过期拒绝。 + +main 末尾也有宿主超时,但后继预算耗尽唤醒未调用宿主,worker 最终 +退出 0。常规场景的 freshness 运行状态保持无效;其原始评分仍可作为 +这次提交的诊断观察,不能因此升级为有效运行。 + +## 5. 完整可得模型用量与成本边界 + +用量以响应 ID 去重,包含规划和后续宿主轮次;不重复计入镜像会话文件。 +各运行增量账本与末次累计账本核对一致。缓存输入是输入的子集,推理 +输出是输出的子集,不能再次相加。 + +| 用量 | 目标 main | 目标 freshness | 常规 main | 常规 freshness | +| --- | ---: | ---: | ---: | ---: | +| 输入 token,含缓存 | 48,419,082 | 46,544,916 | 67,547,793 | 68,751,348 | +| 缓存输入 token | 47,735,680 | 45,964,544 | 66,764,928 | 68,133,888 | +| 未缓存输入 token | 683,402 | 580,372 | 782,865 | 617,460 | +| 输出 token,含推理 | 195,055 | 184,063 | 233,729 | 210,510 | +| 推理输出,已含于输出 | 88,608 | 80,361 | 117,809 | 110,910 | +| 总 token | 48,614,137 | 46,728,979 | 67,781,522 | 68,961,858 | +| 去重响应数 | 272 | 274 | 422 | 378 | +| 原 judge 服务耗时之和 | 1,645.150 秒 | 575.725 秒 | 123.665 秒 | 251.967 秒 | + +目标场景的末次旧式 token 快照比响应账本分别少 355,410、357,766; +常规场景也存在快照口径差异。本报告使用已经核对连续性与累计值的去重 +响应账本,不混用旧快照。 + +judge 耗时与求解及进程运行重叠,不能直接加到墙钟上。进程减原生耗时 +包含准备、归档、排空和清理,不能全部算作控制平面开销。目标 freshness +墙钟少约 4.70%,其中含提前失败,不能全部解释为提速;常规墙钟差约 +6.919 秒,也不足以证明性能提升。 + +**完整经济成本仍未知。** 操作者模型用量和注意力、未报告的失败请求 +费用、judge 模型成本、共享准备和审查 CPU/墙钟、闲置与能耗,以及实际 +计费价格没有完整记录。未知项没有按零处理。大量输入命中缓存,因此 +总 token 增减也不能直接推导到账单增减。 + +## 6. 结论、验证与后续边界 + +- 目标变更场景支持这次样本中更早读取、确认与实际采用新需求,以及较高 + 原始质量分数;它没有证明正式有效成绩或稳定可靠续行。 +- 常规场景中 main 原始分数较高;freshness writers 略多通过、未缓存输入 + 和输出较少,但总 token 略多,且末轮结算失败。命名敏感检查限制了分差 + 的功能解释。 +- 两次 freshness 的停止触发不同:目标场景是模型容量错误,常规场景是 + 宿主超时;两者都暴露未知重规划效果的恢复边界,不应混成同一故障原因。 +- 单次样本、历史参照、不同完整源码快照、串行时序、未资格隔离及未知 + 完整费用,都限制因果和经济结论。不发布跨场景总体胜负或普遍收益。 +- 质量、持续控制、完整结算和运行有效性分别核对;保留失败及未测区别, + 不以进程退出码、任务数量、测试通过数或记录写回替代需求验收。 + +本报告复用终态后的原始评分、源码核验和响应账本分析。目标场景 18 份、 +常规场景 22 份原生评分均保留原值;没有启动新求解、替补运行或额外 judge。 +公开内容仅含经整理的汇总数字、通用机制观察与源码版本,不含原始任务、 +私有运行标识、会话、凭据、隐藏检查或机器路径。私有证据保留用于授权 +审计;本文不是第三方可独立重放的公开实验包。 + +后续修复应让调度器在启动重规划前持久化完整结算身份,并在原身份下 +恢复尚未完成的步骤,同时给临近截止的重规划留下收尾时间。还需分别 +确认评分接口的兼容性、当前默认产品入口的采用和重复运行表现。这些是 +当前证据指出的待验证方向,本次整合没有把它们记为已修复或已通过。 From 870434864afaa0d583b8a8e889af751b368680e1 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 12:33:16 +0800 Subject: [PATCH 31/80] fix: recover proved result work through original Turn settlement Signed-off-by: Tartar --- loopx/cli_commands/turn_run_once.py | 1 + loopx/collaboration_mcp.py | 5 ++- .../goals/checkpoint_context_io.py | 19 +++++++---- .../goals/checkpoint_read_context.ts | 21 ++++++++++-- loopx/state_refresh.py | 9 ++++- .../test_default_result_freshness.py | 34 +++++++++++-------- .../checkpoint_read_context.test.ts | 25 ++++++++++++++ 7 files changed, 90 insertions(+), 24 deletions(-) diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 49fc1b6f60..60163844f6 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -311,6 +311,7 @@ def writeback( effect_ref: str, ) -> dict[str, object]: require_effect_ref(effect_ref, SettlementStepKind.DURABLE_WRITEBACK) + pending_judgment = current_checkpoint.enabled() and not result.get("checkpoint_read_context_id") refresh = refresh_candidate(result, completion_todo_id=completion_todo_id, completion_turn_key=completion_turn_key) if refresh.get("ok") and ( diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 82a954479e..3bbc6a1d40 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -659,7 +659,10 @@ def resume(self, operation_id: str, *, prepared_source_request_id: str | None = return self.read(operation_id) if should_spawn: if publish_committed: - self.execute(operation_id) + if prepared_source_request_id is None: + self.execute(operation_id) + else: + self.execute(operation_id, prepared_source_request_id=prepared_source_request_id) elif prepared_source_request_id is None: self._spawn(operation_id) else: diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index c9d4e9ec4f..b35fe6fad8 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -449,7 +449,7 @@ def first_delivery_progress( except (ValueError, OSError): direction = {} unknown = True - return effect_runtime_result("turn.first_delivery.evaluate", { + progress = effect_runtime_result("turn.first_delivery.evaluate", { "phase": "project", "result_committed": committed, "checkpoint_continuation": checkpoint_continuation_required(readback), "completion_event": readback.completion_event, @@ -470,6 +470,7 @@ def first_delivery_progress( "status", "no_followup", "completion_continuation", "completion_requested_continuation", "completion_turn_key")}, }) + return {**progress, "settlement_identity": identity.as_dict()} def _delivery_observation_todos(root: Path, goal_id: str, registry_path: Path | None) -> list[dict[str, Any]]: @@ -743,14 +744,18 @@ def read_checkpoint_context( previous_receipt = json.loads(_receipt_path(root, identity, purpose).read_text(encoding="utf-8")) except FileNotFoundError: pass + prior = readback.writeback_run if purpose == "delivery_result" and (previous_receipt or {}).get("commit_attempt") is not None: + # An indexed progress writeback may precede an uncommitted + # terminal result. Inspect its artifacts first; the typed + # owner then distinguishes that proof from a done Todo. attempt = _inspect_attempt(root, identity, purpose) - if attempt.get("status") == "committed": - raise CheckpointReadContextRejected({"ok": False, - "error_code": "delivery_result_already_committed", "reread_required": False, - "error": "Progress is already committed. Replay its original writeback; do not replace its work basis."}) previous_receipt = _read_context_receipt(_receipt_path(root, identity, purpose)) - result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run, + if attempt.get("status") == "committed": + # Inspection verified this exact row against the index + # and artifacts; a later direction row is a different effect. + prior = previous_receipt["commit_attempt"]["index_record"] + result = _resolve(root, phase="read", identity=identity.as_dict(), prior=prior, writeback_committed=readback.writeback.failure is None, quota_spend_committed=readback.spend_run is not None or readback.spend_event is not None, semantic_replan_guard=readback.semantic_replan_guard, @@ -758,6 +763,8 @@ def read_checkpoint_context( receipt=previous_receipt, result_only=purpose == "delivery_result" and result_work_required(readback), checkpoint_continuation=continuation, + settlement_complete=readback.settlement.failure is None, + quota_spent=readback.spend_run is not None, full_frontier=continuation and purpose != "delivery_result", admitted_turn=readback.heartbeat_receipt is not None, read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index 8850860f25..cf2ed60127 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -182,8 +182,25 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { error: "Original validation may have executed; reconcile its outcome before replacing the work read."}; } if (use !== "supplement_checkpoint") { - if (jsonObject(request.receipt)?.commit_attempt != null) { - return {...rejected("checkpoint_commit_unknown"), reread_required: false, + const receipt = jsonObject(request.receipt); + const attempt = jsonObject(receipt?.commit_attempt); + const indexed = jsonObject(attempt?.index_record); + const prior = jsonObject(request.prior); + // A proved writeback is not a completed Todo. Historical result-only + // Turns and settled delegated progress may still need terminal validation. + // Its original append/artifacts are inspected by the IO owner before + // this read; only matching proof on those lanes may release the marker. + // Pending current checkpoint lanes retain exact writeback replay. + const committedWork = use === "delivery_result" && request.result_only === true && + prior !== null && indexed !== null && + jsonObject(prior.result_work_context)?.read_context_id === receipt?.read_context_id && + canonicalAuthoritySha256(prior.settlement_identity) === canonicalAuthoritySha256(identity) && + canonicalAuthoritySha256(prior.refresh_recovery) === canonicalAuthoritySha256(indexed.refresh_recovery) && + canonicalAuthoritySha256(prior.result_work_context) === canonicalAuthoritySha256(indexed.result_work_context); + if (receipt?.commit_attempt != null && (!committedWork || + (request.checkpoint_continuation !== false && + !(request.settlement_complete === true && request.quota_spent === true)))) { + return {...rejected(committedWork ? "delivery_result_already_committed" : "checkpoint_commit_unknown"), reread_required: false, error: "A first delivery append may have started; inspect the original Turn and artifacts before another judgment."}; } if ((request.prior != null && !(use === "delivery_result" && request.result_only === true)) || request.admitted_turn !== true) { diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 7087325210..e383b378db 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -108,7 +108,7 @@ from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs from .control_plane.goals.checkpoint_context_io import ( checkpoint_commit_guard, commit_checkpoint_run, require_complete_checkpoint_index, inspect_checkpoint_replay, - first_delivery_context_enrolled, result_work_required, checkpoint_continuation_required, result_work_read_id, + first_delivery_context_enrolled, result_work_required, checkpoint_continuation_required, result_work_read_id, TurnResultWork, ) from .registry import registry_goals as registry_goals, resolve_state_file as resolve_state_file from .runtime import validate_goal_id_path_segment @@ -949,6 +949,13 @@ def refresh_state_run( refresh_recovery["decision"] == "append" and not first_delivery_checkpoint and result_work_required(settlement_readback) ) + if (result_work_checkpoint and completion_todo_id == settlement_identity.todo_id + and completion_turn_key == settlement_identity.turn_instance_id + and TurnResultWork(registry_path, runtime_root, settlement_identity, goal_ref).result_committed()): + # The typed Todo fence already proves this original result. + # A disposable work read cannot reopen its completed validation. + # Existing writeback recovery still owns the remaining effects. + result_work_checkpoint = False if result_work_checkpoint: work_read_id = result_work_read_id(runtime_root, settlement_identity) if first_delivery != first_delivery_checkpoint and not default_checkpoint: diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index a23d3760d1..1c825fd0a5 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -31,7 +31,7 @@ def _default_fixture(provider, tmp_path, monkeypatch, *, handoff_mode="soft_clai def test_default_guard_restores_actual_basis_and_result_commit(provider, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) - guard_args = ("quota", "should-run", "--codex-app", *identity, "--scan-path", str(project)) + guard_args = ("quota", "should-run", "--codex-app", *identity) rc, first = _run_cli(registry, runtime, *guard_args, cwd=project) assert rc == 0 and "delivery_result_context" in first, json.dumps(first) context = first["delivery_result_context"] @@ -52,14 +52,14 @@ def test_default_guard_restores_actual_basis_and_result_commit(provider, tmp_pat assert rc == 0 and saved["completed"], saved # Completing the Todo alone does not close its original refresh/spend. rc, pending = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--scan-path", str(project), cwd=project) + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, cwd=project) assert pending["heartbeat_receipt"]["turn_instance_id"] == context["settlement_identity"]["turn_instance_id"], pending state.write_text(state.read_text(encoding="utf-8") + "\nRequirement after success.\n", encoding="utf-8") rc, replay = _run_cli(registry, runtime, *complete, cwd=project) assert rc == 0 and replay["idempotent_replay"], replay -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_concurrent_default_reads_share_original_turn_and_basis(provider, tmp_path, monkeypatch): from concurrent.futures import ThreadPoolExecutor from threading import Barrier @@ -68,7 +68,7 @@ def test_concurrent_default_reads_share_original_turn_and_basis(provider, tmp_pa def read(): barrier.wait() return _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--scan-path", str(project), cwd=project) + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) with ThreadPoolExecutor(max_workers=2) as pool: results = list(pool.map(lambda _: read(), range(2))) assert all(rc == 0 for rc, _ in results), json.dumps(results, indent=2) @@ -120,13 +120,12 @@ def test_successful_result_replay_precedes_later_goal_source_loss(provider, sour assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_already_bound_historical_receipt_retains_original_policy(provider, tmp_path, monkeypatch): from tests.control_plane.test_quota_settlement_cli import _historical_turn project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, - "--scan-path", str(project)) + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) rc, first = _run_cli(registry, runtime, *args, cwd=project) assert rc == 0, first # Synthetic pre-default persisted admission; never rewrite active state. @@ -146,7 +145,7 @@ def test_default_commit_keeps_basis_after_unrelated_todo_revision(provider, tmp_ project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) rc, work = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, - "--scan-path", str(project), cwd=project) + cwd=project) assert rc == 0, work rc, peer = _run_cli(registry, runtime, "todo", "add", "--goal-id", GOAL_ID, "--role", "agent", "--text", "Validate an independent follow-up.", "--claimed-by", AGENT_ID, cwd=project) @@ -157,11 +156,11 @@ def test_default_commit_keeps_basis_after_unrelated_todo_revision(provider, tmp_ assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_damaged_work_input_blocks_default_dispatch_and_preserves_admission(provider, tmp_path, monkeypatch): project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--scan-path", str(project)) + "--agent-id", AGENT_ID, "--todo-id", TODO_ID) rc, first = _run_cli(registry, runtime, *args, cwd=project) assert rc == 0, first path = next((runtime / "goals" / GOAL_ID / "checkpoint-contexts").glob("*.json")) @@ -218,7 +217,7 @@ def test_historical_result_only_turn_retains_candidate_and_revalidates(provider, args = ("turn", "run-once", "--host", "generic-cli", "--goal-id", goal, "--agent-id", agent, "--project", str(project), "--host-adapter-command-json", json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, str(validator_path)]), - "--scan-root", str(project), "--no-global-sync", "--execute") + "--no-global-sync", "--execute") # Gap1 admissions already in flight keep result-only semantics after the # new checkpoint default ships. Seed the real old receipt, never downgrade # a receipt admitted under current_agent_v1. @@ -343,7 +342,7 @@ def lose_response(*args, **kwargs): context_path.write_text("{", encoding="utf-8") rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", goal, "--agent-id", agent, "--todo-id", "todo_fixture0001", - "--scan-path", str(project), cwd=project) + cwd=project) assert rc == 0 and guard.get("ok") is True, guard assert guard["heartbeat_receipt"]["settlement_identity"]["turn_instance_id"] == failed["turn_key"] assert guard.get("result_committed") is True and "delivery_result_context" not in guard, guard @@ -435,16 +434,23 @@ def lose_result_response(args, **kwargs): assert result["ok"] is True, result # Ordinary successful settlement releases the next default work identity. next_work = json.loads(control.should_run()) + # Main requires claiming an unowned successor before admitting its work. + selected = next_work.get("selected_todo") or {} + assert selected.get("claim_required_before_work") is True, next_work + claimed = json.loads(control.claim_task(selected["todo_id"], agent)) + assert claimed["ok"] is True, claimed + next_work = json.loads(control.should_run()) assert next_work.get("delivery_result_context"), next_work assert next_work["delivery_result_context"]["settlement_identity"]["turn_instance_id"] != original["settlement_identity"]["turn_instance_id"] @pytest.mark.parametrize("operation", ["add", "complete"]) -def test_legacy_work_read_does_not_restore_retired_mutation_authority(operation, tmp_path, monkeypatch): +def test_unpromoted_work_read_does_not_restore_retired_mutation_authority(operation, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture("legacy", tmp_path, monkeypatch) rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) - assert rc == 0 and "delivery_result_context" in admitted, admitted + assert rc == 1 and admitted["error_code"] == "canonical_authority_required", admitted + assert "delivery_result_context" not in admitted original = state.read_bytes() args = (("--role", "agent", "--text", "Independent candidate.") if operation == "add" else ("--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--note", "Verified candidate.")) diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index a0ae2410da..e0b4a515d8 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -64,6 +64,31 @@ const check = (receipt: unknown, overrides: JsonObject = {}) => evaluate({ phase: "check", identity, facts, read_context_id: "read-A", receipt, ...overrides, }); +test("only matching historical or settled progress permits another read for an unfinished result", () => { + const original = read({purpose: "delivery_result", admitted_turn: true, result_only: true, prior: null}); + assert.equal(original.ok, true); + const prior = {settlement_identity: identity, result_work_context: {read_context_id: "read-A"}, + refresh_recovery: {mutation_digest: "original"}}; + const receipt = {...original.receipt as JsonObject, commit_attempt: {index_record: prior}}; + const reread = (overrides: JsonObject = {}) => read({purpose: "delivery_result", admitted_turn: true, checkpoint_continuation: false, + result_only: true, read_context_id: "read-B", prior, receipt, ...overrides}); + assert.equal(reread().ok, true); + assert.equal((reread().receipt as JsonObject).commit_attempt, undefined); + assert.equal(reread({checkpoint_continuation: true}).error_code, "delivery_result_already_committed"); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: true, quota_spent: true}).ok, true); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: false}).error_code, "delivery_result_already_committed"); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: true, quota_spent: false}).error_code, "delivery_result_already_committed"); + assert.equal(reread({checkpoint_continuation: true, settlement_complete: true, quota_spent: true, prior: null}).error_code, "checkpoint_commit_unknown"); + for (const changed of [null, {...prior, settlement_identity: {...identity, agent_id: "other"}}, + {...prior, result_work_context: {read_context_id: "another-read"}}, + {...prior, refresh_recovery: {mutation_digest: "replacement"}}]) { + assert.equal(reread({prior: changed}).error_code, "checkpoint_commit_unknown"); + } + assert.equal(reread({facts: {...facts, todos: facts.todos.map(todo => todo.todo_id === "task" + ? {...todo, status: "done", completion_turn_key: identity.turn_instance_id} : todo)}}).error_code, + "delivery_result_already_committed"); +}); + test("primary owner requirements stay in the Goal basis when an Agent rolls back derived state", () => { const a = [{path: "/owner/goal.md", prose: "Export records without envelopes"}]; const b = [{path: "/owner/goal.md", prose: "Export versioned envelopes"}]; From 49a6e976ba04aaea9896e1bce1066f3ead5edfb8 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 12:41:31 +0800 Subject: [PATCH 32/80] fix: preserve sealed lifecycle closeout within the admission second Signed-off-by: Tartar --- loopx/control_plane/work_items/replan_semantics.ts | 3 ++- .../lifecycle_admission_snapshot.test.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 1ea2c388df..f83fbe6d0e 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -174,6 +174,7 @@ function admittedLifecycleSource(request: JsonObject, obligation: JsonObject): J const current = Array.isArray(request.items) ? request.items.map(object) : []; const timestamp = typeof request.guard_recorded_at === "string" ? parseTodoTimestampMicros(request.guard_recorded_at) : null; + const admissionSecond = timestamp === null ? null : timestamp / 1_000_000n * 1_000_000n; const stable = ["todo_id", "index", "status", "done", "task_class", "archive_state", "claimed_by", "last_actor_agent_id", "completed_at", "completion_turn_key", "successor_todo_ids", "superseded_by"]; @@ -197,7 +198,7 @@ function admittedLifecycleSource(request: JsonObject, obligation: JsonObject): J item.completion_recovery === "lifecycle_reentry_terminal_closeout" && (item.updated_at === prior.updated_at || (typeof item.updated_at === "string" && parseTodoTimestampMicros(item.updated_at) !== null && - parseTodoTimestampMicros(item.updated_at)! >= timestamp)))); + parseTodoTimestampMicros(item.updated_at)! >= admissionSecond!)))); }); source = {items: valid ? items : [], settled: valid && items.every(item => item.no_followup === true)}; } diff --git a/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts index c549f044a7..cf34fc5427 100644 --- a/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts +++ b/tests/control_plane_ts/lifecycle_admission_snapshot.test.ts @@ -11,8 +11,8 @@ const current=admitted.map(item=>({...item,no_followup:true,completion_continuat const source={obligation_id:"replan-1111111111111111",resolution_mode:"todo_lifecycle_settlement", lifecycle_admission_items:admitted,triggers:admitted.map(item=>({kind:"completed_advancement_without_successor", agent_id:"agent-a",todo_id:item.todo_id,completion_turn_key:item.completion_turn_key}))}; -function run(obligation=source,items=current){return projectReplanSemantics({operation:"lifecycle_obligation", - obligation,selected_obligation_id:source.obligation_id,agent_id:"agent-a",guard_recorded_at:"2026-09-02T00:00:00Z",items});} +function run(obligation=source,items=current,guard="2026-09-02T00:00:00Z"){return projectReplanSemantics({operation:"lifecycle_obligation", + obligation,selected_obligation_id:source.obligation_id,agent_id:"agent-a",guard_recorded_at:guard,items});} test("sealed original admission recovers closeout without rewriting old Todo timestamps",()=>{ assert.equal((run().obligation as Record).resolution_mode,"receipt_bound_replan_settlement"); }); @@ -50,3 +50,12 @@ test("sealed admission accepts the lifecycle owner's closeout timestamp", () => items[0].updated_at = "2026-09-01T12:00:00Z"; assert.throws(() => run(source, items), /lifecycle source does not match/); }); + +test("sealed admission accepts a later closeout in the guard's whole second", () => { + const items = current.map(item => ({...item, updated_at: "2026-09-02T00:00:00Z"})); + const guard = "2026-09-02T00:00:00.654321Z"; + assert.equal((run(source, items, guard).obligation as Record).resolution_mode, + "receipt_bound_replan_settlement"); + items[0].updated_at = "2026-09-01T23:59:59Z"; + assert.throws(() => run(source, items, guard), /lifecycle source does not match/); +}); From 93eab46d23eb61294fa49a99499e89cfe49c6ae0 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 12:52:57 +0800 Subject: [PATCH 33/80] test: align freshness recovery journeys with current main contracts Signed-off-by: Tartar --- .../checkpoint-recovery-browser-smoke.mjs | 4 +- .../smoke/checkpoint-recovery-http-fixture.py | 2 +- .../test_checkpoint_provider_fence.py | 11 +- .../test_checkpoint_read_context.py | 47 ++- .../test_declared_terminal_settlement_cli.py | 310 +++--------------- .../test_default_checkpoint_continuation.py | 10 +- .../test_default_guard_admission.py | 28 +- .../test_default_turn_recovery.py | 2 +- .../control_plane/test_first_delivery_mcp.py | 12 +- .../test_inflight_result_freshness.py | 6 +- .../test_native_journal_settlement.py | 2 - .../test_quota_settlement_cli.py | 8 +- .../test_receipt_bound_lifecycle_recovery.py | 34 +- .../test_turn_completion_source.py | 27 +- .../default_parent_transport.test.mjs | 14 +- 15 files changed, 167 insertions(+), 350 deletions(-) diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs index 739e4a6edc..3f4d61d3d1 100644 --- a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs @@ -20,7 +20,7 @@ process.env.LOOPX_PERSONAL_WORKSPACE_PORT=String(probe.address().port); await new Promise((done,reject)=>probe.close(error=>error?reject(error):done())); process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED="1"; const {repoRoot,port,startServer}=await import("../../../../examples/personal-workspace-browser/fixture.mjs"); -const {openWorkspacePage}=await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const {openWorkspacePage,fetchWorkspaceBackend}=await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); const fixture=spawn(resolveTestPython({repoRoot}),["-u","apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py",resolve(out,"backend")], {cwd:repoRoot,stdio:["pipe","pipe","pipe"],windowsHide:true}); const exited=once(fixture,"exit"); @@ -45,7 +45,7 @@ try { await page.route(`**/api/chat/sessions/${session.session_id}/loopx`,async route=>{ const request=route.request(); const body=request.method()==="POST"?request.postDataJSON():null; - const response=await route.fetch({url:`${origin}/api/chat/sessions/${session.session_id}/loopx`}); + const response=await fetchWorkspaceBackend(route,origin); requests.push({method:request.method(),body,status:response.status(),response:await response.json()}); await route.fulfill({response}); }); diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py index 64d6f2ff3a..a2f8d21b69 100644 --- a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py @@ -65,11 +65,11 @@ def main(): 'config_digest':hashlib.sha256(config.read_bytes()).hexdigest()} store.update_session(sid,loopx_tools=True,native_goal={'status':'paused','tokensUsed':0}, loopx_mode={'enabled':True,'paused':True,'settings':settings}) + patches.setenv('LOOPX_CHAT_CODEX_HOME',str(isolated/'codex-home')) controller=ChatRuntimeController(store=store,codex_bin='missing-codex',registry_path=registry) adapter=SimpleNamespace(goal_driver=SimpleNamespace(stopped=threading.Event()), session=SimpleNamespace(),upstream_thread_id='synthetic-original-sdk', close_session=lambda:None,healthcheck=lambda:True) - patches.setattr(controller,'_check_codex_home',lambda _:None) patches.setattr(controller,'_ensure_adapter_locked',lambda *a,**k:adapter) controller.adapters[sid]=adapter dispatches=[]; failures=[]; decisions=[] diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index 5c17bf7fb6..4733ad8990 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -9,7 +9,7 @@ import time import pytest -from canonical_authority_fixture import advance_canonical_fixture_authority, isolate_sqlite_runtime +from canonical_authority_fixture import initialize_canonical_authority, advance_canonical_fixture_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.effect_runtime import EffectRuntimeConflict @@ -94,14 +94,17 @@ def _replace_source_goal(registry: Path, instance_id: str) -> None: def fixture(tmp_path, monkeypatch, provider, *, first_delivery=False): isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry, binding, delivery, original = _missing(tmp_path, provider=provider, defer=first_delivery, historical=True) - state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" todos = [{"schema_version": "todo_item_v0", "todo_id": name, "index": index, "role": "agent", "status": "open", "done": False, "text": f"Synthetic page work {name}", "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo", "claimed_by": AGENT_ID} for index, name in enumerate((TODO_ID, "todo_dependency", "todo_unrelated"), 1)] projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode="hard_lease", leases=[]) - advance_canonical_fixture_authority(runtime, GOAL_ID, projection, operation_id="fixture-checkpoint-fences") + def initialize(project, runtime, _registry): + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + project, runtime, registry, binding, delivery, original = _missing( + tmp_path, defer=first_delivery, historical=True, initialize=initialize) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" for todo in todos: rc, lease = _run_cli(registry, runtime, "task-lease", "acquire", "--goal-id", GOAL_ID, "--todo-id", todo["todo_id"], "--owner", AGENT_ID, diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py index 06e35db0b6..e7c84f9a8b 100644 --- a/tests/control_plane/test_checkpoint_read_context.py +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -11,11 +11,16 @@ from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, REPO_ROOT, _run_cli, _write_fixture, _spend_run_count, + _initialize_fixture_authority, ) -def _missing(root: Path, *, provider: str = "sqlite", defer=False, historical=False): - project, runtime, registry = _write_fixture(root, provider=provider) +def _missing(root: Path, *, provider: str = "sqlite", defer=False, historical=False, initialize=None): + project, runtime, registry = _write_fixture(root) + if initialize is None: + _initialize_fixture_authority(project, runtime, provider=provider) + else: + initialize(project, runtime, registry) binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) for args in ( ("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, @@ -46,7 +51,8 @@ def _missing(root: Path, *, provider: str = "sqlite", defer=False, historical=Fa def test_checkpoint_recovery_is_not_a_fresh_turn_preflight(tmp_path): - project, runtime, registry = _write_fixture(tmp_path, provider="sqlite") + project, runtime, registry = _write_fixture(tmp_path) + _initialize_fixture_authority(project, runtime) binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) rc, admitted = _run_cli(registry, runtime, "quota", "should-run", @@ -80,7 +86,7 @@ def test_historical_explicit_first_delivery_cli_rejects_stale_and_replays_succes SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID), semantic_replan_guard_scoped=True, semantic_replan_obligation_id=None) rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, - "--scan-path", str(project), cwd=project) + cwd=project) assert rc == 0, json.dumps(guard) read = ("checkpoint-context", *binding, "--purpose", "first_delivery") rc, context = _run_cli(registry, runtime, *read, cwd=project) @@ -129,7 +135,7 @@ def test_delivery_result_basis_precedes_cas_and_replays_before_current_freshness build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo, peer], handoff_mode="soft_claim"), state_path=state, provider=provider) binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, - "--scan-path", str(project), cwd=project) + cwd=project) assert rc == 0, guard read = ("checkpoint-context", *binding, "--purpose", "delivery_result") rc, context = _run_cli(registry, runtime, *read, cwd=project) @@ -188,12 +194,12 @@ def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(t "--checkpoint-read-context", a["read_context_id"], cwd=project) assert rc == 1 and replaced["error_code"] == "checkpoint_read_context_unknown_or_replaced", replaced - # A synthetic source edit represents a peer's completed update. The separate - # concurrency test proves the cooperating writer cannot cross the commit guard. + # Update the canonical Todo owner, not its Markdown display. rc, updated = _run_cli(registry, runtime, "todo", "update", "--goal-id", GOAL_ID, "--todo-id", TODO_ID, "--agent-id", AGENT_ID, "--text", "Validate the updated delivery scope.", - "--execute", cwd=project) - assert rc == 0 and updated["ok"], updated + "--update-operation-id", "checkpoint-peer-scope", "--update-expected-provider-revision", + b["provider_revision"], cwd=project) + assert rc == 0, updated rc, stale = _run_cli(registry, runtime, *delivery, *vision, "--checkpoint-read-context", b["read_context_id"], cwd=project) assert rc == 1 and stale["error_code"] == "checkpoint_read_context_stale", stale @@ -221,22 +227,14 @@ def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(t def test_large_goal_context_keeps_complete_basis_and_checkpoint_only_recovery( tmp_path, monkeypatch, provider, ): - from canonical_authority_fixture import advance_canonical_fixture_authority, isolate_sqlite_runtime - from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from canonical_authority_fixture import isolate_sqlite_runtime from loopx.control_plane.effect_runtime import MAX_REQUEST_BYTES if provider == "sqlite": isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry, binding, delivery, original = _missing(tmp_path, provider=provider) + project, runtime, registry, binding, delivery, original = _missing(tmp_path, + initialize=lambda project, runtime, _: _initialize_fixture_authority(project, runtime, provider=provider)) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" - if provider != "legacy": - todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, - "role": "agent", "status": "open", "done": False, - "text": "Validate and settle the selected delivery.", - "task_class": "advancement_task", "archive_state": "active", - "source_section": "Agent Todo"} - projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo]) - advance_canonical_fixture_authority(runtime, GOAL_ID, projection, operation_id="fixture-checkpoint-context") # The original failure came from complete source prose, not from the # small prior Turn receipt. Keep the source intact and cross both 2 MiB # request and response boundaries through the real local CLI/runtime. @@ -247,8 +245,7 @@ def test_large_goal_context_keeps_complete_basis_and_checkpoint_only_recovery( rc, context = _run_cli(registry, runtime, "checkpoint-context", *binding, cwd=project) assert rc == 0, {key: value for key, value in context.items() if key != "basis"} assert context["basis"]["goal"]["prose"].endswith(large_prose.strip()) - assert context["basis"]["source"]["authority"] == ( - "legacy_markdown" if provider == "legacy" else f"{provider}_v0") + assert context["basis"]["source"]["authority"] == f"{provider}_v0" state.write_text(state.read_text(encoding="utf-8") + "\nUpdated acceptance context.\n", encoding="utf-8") supplement = (*delivery, @@ -279,7 +276,6 @@ def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkey isolate_sqlite_runtime(tmp_path, monkeypatch) import tempfile monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) - project, runtime, registry, _, _, _ = _missing(tmp_path, provider=provider) todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, "role": "agent", "status": "done", "done": True, "text": "Canonical delivered result", "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo"} @@ -290,7 +286,10 @@ def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkey projection["goal_acceptance"] = {"schema_version": "loopx_goal_acceptance_v0", "enabled": True, "revision": 1, "digest": hashlib.sha256(canonical_bytes(document)).hexdigest(), "document": document, "bindings": [], "verification": None} - advance_canonical_fixture_authority(runtime, GOAL_ID, projection, operation_id="fixture-checkpoint-context") + def initialize(project, runtime, _registry): + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + project, runtime, registry, _, _, _ = _missing(tmp_path, historical=True, initialize=initialize) context = read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) assert context["basis"]["todo"]["text"] == "Canonical delivered result" diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index ecfe47964b..f7be333ffc 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -9,7 +9,6 @@ from hashlib import sha256 from pathlib import Path from typing import Any -from urllib.parse import quote import pytest import test_quota_settlement_cli as cli @@ -48,10 +47,6 @@ def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, registry = json.loads(registry_path.read_text()) registry["goals"][0]["coordination"]["write_scope"] = ["src/**"] registry_path.write_text(json.dumps(registry)) - if provider == "legacy": - state.write_text(state.read_text().replace( - "status: active-read-only", "handoff_mode: hard_lease\nstatus: active-read-only", 1, - )) cli._initialize_git_checkout(project) # The private registry must not be tracked or made push-allowed just to # admit the synthetic Goal. Only the public-safe state fixture is committed. @@ -67,19 +62,18 @@ def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, def run(*args: str) -> tuple[int, dict[str, Any]]: return cli._run_cli(registry_path, runtime, *args, cwd=workspace) - if provider != "legacy": - projection = build_todo_runtime_shadow_projection( - goal_id=cli.GOAL_ID, todos=read_fixture_todos(state), handoff_mode="hard_lease", leases=[], - ) - # Actor audit is canonical-only; legacy Markdown import cannot author it. - historical = [row for row in projection["todos"] if row["todo_id"].startswith("todo_historical_")] - assert len(historical) == historical_terminals - for row in historical: - row["last_actor_agent_id"] = cli.AGENT_ID - projection["todo_read_model"]["records_sha256"] = sha256(canonical_bytes(projection["todos"])).hexdigest() - initialize_canonical_authority( - runtime, cli.GOAL_ID, projection, state_path=state, provider=provider, - ) + projection = build_todo_runtime_shadow_projection( + goal_id=cli.GOAL_ID, todos=read_fixture_todos(state), handoff_mode="hard_lease", leases=[], + ) + # Actor audit is canonical-only; legacy Markdown import cannot author it. + historical = [row for row in projection["todos"] if row["todo_id"].startswith("todo_historical_")] + assert len(historical) == historical_terminals + for row in historical: + row["last_actor_agent_id"] = cli.AGENT_ID + projection["todo_read_model"]["records_sha256"] = sha256(canonical_bytes(projection["todos"])).hexdigest() + initialize_canonical_authority( + runtime, cli.GOAL_ID, projection, state_path=state, provider=provider, + ) code, acquired = run( "task-lease", "acquire", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, "--owner", cli.AGENT_ID, "--idempotency-key", "declared-terminal-fixture", @@ -102,23 +96,14 @@ def run(*args: str) -> tuple[int, dict[str, Any]]: f"p=Path({str(marker)!r}); p.write_text((p.read_text() if p.exists() else '')+'run\\n'); " f"raise SystemExit(4 if Path({str(fail)!r}).exists() else 0)" )] - if provider == "legacy": - # Establish the original declaration before Turn admission. Legacy - # Markdown has no canonical revision transaction to bind afterwards. - encoded = quote(json.dumps(argv, separators=(",", ":")), safe="") - state.write_text(state.read_text().replace( - "required_write_scopes=src/**", - "required_write_scopes=src/** validation_command_argv=" + encoded, - )) - else: - code, bound = run( - "todo", "update", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, - "--agent-id", cli.AGENT_ID, "--validation-command-json", json.dumps(argv), - "--validation-label", "Original declared deliverable check", *lease_args, - "--update-operation-id", "original-declaration-fixture", - "--update-expected-provider-revision", acquired["provider_revision"], - ) - assert code == 0, bound.get("error") or bound + code, bound = run( + "todo", "update", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, + "--agent-id", cli.AGENT_ID, "--validation-command-json", json.dumps(argv), + "--validation-label", "Original declared deliverable check", *lease_args, + "--update-operation-id", "original-declaration-fixture", + "--update-expected-provider-revision", acquired["provider_revision"], + ) + assert code == 0, bound.get("error") or bound code, original = run("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID) assert code == 0, original return project, workspace, runtime, registry_path, run, lease_args, marker, fail, original["todo"] @@ -304,7 +289,7 @@ def test_lease_change_during_validator_still_blocks_completion( binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID) code, guard = run("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, - "--scan-path", str(project), *binding) + *binding) assert code == 0, guard code, rejected = run("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, "--evidence", "Validator renews the lease before final completion.") @@ -321,7 +306,7 @@ def test_lease_change_during_validator_still_blocks_completion( def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monkeypatch,provider,closeout_source): project,workspace,runtime,registry,run,lease,marker,_,original=_fixture(tmp_path,monkeypatch,provider) binding=('--agent-id',cli.AGENT_ID,'--todo-id',cli.TODO_ID,'--turn-instance-id',cli.TURN_ID) - code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,'--scan-path',str(project),*binding, + code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,*binding, '--available-capability','shell','--available-capability','filesystem_write') assert code==0,guard identity=guard['heartbeat_receipt']['settlement_identity'] @@ -347,7 +332,7 @@ def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monk assert code==0 and replay['idempotent_replay'] is True,replay assert marker.read_text()=='run\n' lifecycle=('todo','complete','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID,'--agent-id',cli.AGENT_ID, - '--completion-identity-key',identity['effect_id'],'--no-follow-up','--evidence',complete[-1]) + '--completion-identity-key',identity['effect_id'],'--no-follow-up') code,early=run(*lifecycle) assert code==1,early code,after=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) @@ -370,8 +355,7 @@ def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monk '--delivery-outcome','outcome_progress','--no-global-sync','--suppress-external-sinks') code,refreshed=run(*refresh) assert code==0,refreshed - spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute', - '--scan-path',str(project)) + spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute') code,spent=run(*spend) assert code==0,spent assert cli._spend_run_count(runtime)==1 @@ -381,7 +365,10 @@ def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monk ('--evidence','Replacement evidence must be rejected.'), ('--agent-id','unregistered-cold-actor')): rejected=list(lifecycle) - rejected[rejected.index(field)+1]=replacement + if field in rejected: + rejected[rejected.index(field)+1]=replacement + else: + rejected.extend((field,replacement)) code,failure=run(*rejected) assert code==1,failure code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) @@ -389,13 +376,14 @@ def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monk assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 terminal=complete if closeout_source=='turn' else lifecycle code,closed=run(*terminal) - assert code==0 and closed['completion_continuation']=='no_followup',closed + assert code==0 and closed['completion_continuation']=='no_followup',json.dumps(closed) for args in (complete,refresh,spend,terminal): code,replay=run(*args) assert code==0 and replay['idempotent_replay'] is True,replay assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 code,final=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) assert code==0 and final['todo']['completion_continuation']=='no_followup',final + assert final['todo']['evidence']==saved['evidence'],final assert final['todo'].get('completion_validation_sha256')==original.get('completion_validation_sha256') # A retry with changed accepted work must not borrow the historical RESULT. @@ -411,11 +399,11 @@ def test_default_declared_terminal_intent_recovers_original_result(tmp_path,monk @pytest.mark.parametrize('provider', ['file', 'sqlite']) -@pytest.mark.parametrize('fault', ['rename', 'crash']) +@pytest.mark.parametrize('fault', ['holder_publication', 'crash']) def test_declared_validation_survives_pre_result_failure(tmp_path, monkeypatch, provider, fault): project, workspace, runtime, registry, run, lease, marker, _, original = _fixture(tmp_path, monkeypatch, provider) binding = ('--agent-id', cli.AGENT_ID, '--todo-id', cli.TODO_ID, '--turn-instance-id', cli.TURN_ID) - code, guard = run('quota', 'should-run', '--codex-app', '--goal-id', cli.GOAL_ID, '--scan-path', str(project), *binding, + code, guard = run('quota', 'should-run', '--codex-app', '--goal-id', cli.GOAL_ID, *binding, '--available-capability', 'shell', '--available-capability', 'filesystem_write') assert code == 0, guard complete = ('todo', 'complete', '--goal-id', cli.GOAL_ID, *binding, *lease, '--no-follow-up', '--evidence', 'Original candidate and original validation effect.') @@ -423,11 +411,12 @@ def test_declared_validation_survives_pre_result_failure(tmp_path, monkeypatch, fault_seen = tmp_path / 'fault-seen' launcher.write_text('import os, sys\nfrom pathlib import Path\n' + f'marker = Path({str(marker)!r})\nseen = Path({str(fault_seen)!r})\n' - + ('original = os.replace\ndef replace(source, target, *args, **kwargs):\n' - ' if Path(target).name == "index.jsonl.lock.holder.json" and marker.exists() and not seen.exists():\n' + + ('import loopx.file_lock as locks\noriginal = locks._persist_holder_record\n' + 'def publish(lock_file, **kwargs):\n' + ' if kwargs["record"].get("operation") == "delivery-result-capture" and marker.exists() and not seen.exists():\n' ' seen.write_text("post-validation holder publication rejected")\n' - ' raise PermissionError(13, "Injected original Windows holder publication failure", str(target))\n' - ' return original(source, target, *args, **kwargs)\nos.replace = replace\n' if fault == 'rename' else + ' raise PermissionError(13, "Injected holder publication failure", str(kwargs["holder_path"]))\n' + ' return original(lock_file, **kwargs)\nlocks._persist_holder_record = publish\n' if fault == 'holder_publication' else 'import loopx.control_plane.todos.completion_validation as validation\n' 'original = validation.run_declared_completion_validation_effect\n' 'def run_effect(**kwargs):\n' @@ -445,7 +434,7 @@ def test_declared_validation_survives_pre_result_failure(tmp_path, monkeypatch, assert code == 0 and not before['todo']['done'], before assert before['todo']['completion_validation_sha256'] == original['completion_validation_sha256'] assert cli._spend_run_count(runtime) == 0 - if fault == 'rename': + if fault == 'holder_publication': for flag, replacement in (('--evidence', 'Replaced candidate.'), ('--agent-id', 'unregistered-agent'), ('--task-lease-expected-version', '999')): changed = list(complete) changed[changed.index(flag) + 1] = replacement @@ -456,7 +445,7 @@ def test_declared_validation_survives_pre_result_failure(tmp_path, monkeypatch, assert code == 0 and unchanged['todo'] == before['todo'], unchanged code, restored = run(*complete) assert marker.read_text() == 'run\n', 'Original validation must never execute twice after lost RESULT' - if fault == 'rename': + if fault == 'holder_publication': assert code == 0 and restored['completed'] is True, restored assert restored['completion_continuation'] == 'active_goal' assert restored['validation_receipt']['passed'] is True @@ -474,211 +463,17 @@ def test_declared_validation_survives_pre_result_failure(tmp_path, monkeypatch, assert marker.read_text() == 'run\n' and cli._spend_run_count(runtime) == 0 -@pytest.mark.parametrize('provider',['legacy']) -@pytest.mark.parametrize('closeout_source',['turn','lifecycle']) -def test_legacy_default_terminal_recovers_original_result(tmp_path,monkeypatch,provider,closeout_source): - project,workspace,runtime,registry,run,lease,marker,_,original=_fixture(tmp_path,monkeypatch,provider) - binding=('--agent-id',cli.AGENT_ID,'--todo-id',cli.TODO_ID,'--turn-instance-id',cli.TURN_ID) - code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,'--scan-path',str(project),*binding, - '--available-capability','shell','--available-capability','filesystem_write') - assert code==0,guard - identity=guard['heartbeat_receipt']['settlement_identity'] - complete=('todo','complete','--goal-id',cli.GOAL_ID,*binding,*lease,'--no-follow-up', - '--evidence','Original declared deliverable, then current post-result direction.') - code,result=run(*complete) - assert code==0,result - code,result_lease=run('task-lease','inspect','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and result_lease['lease']['status']=='released' and result_lease['lease']['version']==int(lease[-1]) and result_lease['lease']['idempotency_key']==lease[1] and result_lease['lease']['lease_epoch']==1,result_lease - assert result['completed'] is True and result['completion_continuation']=='active_goal',result - assert marker.read_text()=='run\n','RESULT must run exactly one original validator' - assert cli._spend_run_count(runtime)==0 - code,listed=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0,listed - saved=listed['todo'] - assert saved['status']=='done' and saved['completion_continuation']=='active_goal' - assert saved['completion_requested_continuation']=='no_followup',saved - assert not saved.get('successor_todo_ids') - assert saved.get('completion_validation_sha256')==original.get('completion_validation_sha256') - assert saved['completion_turn_key']==identity['effect_id'],saved - assert result['settlement_identity']==identity,result - if provider!='legacy': - assert result['completion_identity_key']==identity['effect_id'] - # A fresh original request must recover RESULT without revalidation. - code,replay=run(*complete) - assert code==0 and replay['idempotent_replay'] is True,replay - assert marker.read_text()=='run\n' - lifecycle=('todo','complete','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID,'--agent-id',cli.AGENT_ID, - '--completion-identity-key',identity['effect_id'],'--no-follow-up','--evidence',complete[-1]) - code,early=run(*lifecycle) - if provider=='legacy' and code==0: - # A historical RESULT replay is legal; it must neither represent nor - # mutate a terminal closeout. The frozen condition is no early close. - assert early['idempotent_replay'] is True and early['changed'] is False,early - assert early['completion_continuation']=='active_goal',early - else: - assert code==1,early - code,after=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and after['todo']==saved,after - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 - # New direction is authored only after the CLI returns actual committed RESULT. - code,context=run('checkpoint-context','--goal-id',cli.GOAL_ID,*binding,'--purpose','first_delivery') - assert code==0,context - assert context['settlement_identity']==identity and context['basis']['todo']['status']=='done' - decision=checkpoint_decision(context) - direction=('--checkpoint-read-context',context['read_context_id']) - if decision.get('agent_vision'): - vision=tmp_path/'direction.json' - vision.write_text(json.dumps(decision['agent_vision'])) - direction+=('--agent-vision-json',str(vision)) - else: - direction+=('--vision-unchanged-reason',decision['vision_unchanged_reason']) - refresh=('refresh-state','--goal-id',cli.GOAL_ID,*binding,*direction, - '--classification','declared_deliverable_complete','--delivery-batch-scale','implementation', - '--delivery-outcome','outcome_progress','--no-global-sync','--suppress-external-sinks') - code,refreshed=run(*refresh) - assert code==0,refreshed - spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute', - '--scan-path',str(project)) - code,spent=run(*spend) - assert code==0,spent - assert cli._spend_run_count(runtime)==1 - code,before_reentry=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0,before_reentry - for field,replacement in (('--completion-identity-key',identity['effect_id']+'-wrong'), - ('--evidence','Replacement evidence must be rejected.'), - ('--agent-id','unregistered-cold-actor')): - rejected=list(lifecycle) - rejected[rejected.index(field)+1]=replacement - code,failure=run(*rejected) - assert code==1,failure - code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and unchanged['todo']==before_reentry['todo'],unchanged - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 - terminal=complete if closeout_source=='turn' else lifecycle - code,closed=run(*terminal) - assert code==0 and closed['completion_continuation']=='no_followup',closed - for args in (complete,refresh,spend,terminal): - code,replay=run(*args) - assert code==0 and replay['idempotent_replay'] is True,replay - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 - code,final=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and final['todo']['completion_continuation']=='no_followup',final - code, retired=run('task-lease','inspect','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and retired['lease']['status']=='released' and retired['lease']['version']==int(lease[-1]),retired - assert retired['lease']['idempotency_key']==lease[1] and retired['lease']['lease_epoch']==1,retired - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1,'Original validator and spend stay exactly once' - assert final['todo'].get('completion_validation_sha256')==original.get('completion_validation_sha256') - - - -@pytest.mark.parametrize('provider',['legacy']) -@pytest.mark.parametrize('fault',['before_release','after_release']) -@pytest.mark.parametrize('closeout_source',['turn','lifecycle']) -def test_legacy_committed_result_survives_release_process_loss(tmp_path,monkeypatch,provider,closeout_source,fault): - project,workspace,runtime,registry,run,lease,marker,_,original=_fixture(tmp_path,monkeypatch,provider) - actual_run=run - injected=False - launcher=tmp_path/'lost_legacy_cli.py' - seen=tmp_path/'fault-seen' - launcher.write_text('import os,sys\nfrom pathlib import Path\nimport loopx.control_plane.todos.legacy_mutation as legacy\noriginal=legacy.release_verified_task_lease_fence\ndef release(fence,**kwargs):\n' - + (' original(fence,**kwargs)\n' if fault=='after_release' else '') - + ' if kwargs.get("committed"):\n Path('+repr(str(seen))+').write_text("Todo committed, process lost '+fault+'")\n os._exit(93)\n' - + (' original(fence,**kwargs)\n' if fault=='before_release' else '') - + 'legacy.release_verified_task_lease_fence=release\nfrom loopx.cli import main\nraise SystemExit(main(sys.argv[1:]))\n') - def run(*args): - nonlocal injected - if not injected and args[:2]==('todo','complete'): - injected=True - lost=subprocess.run([sys.executable,str(launcher),'--registry',str(registry),'--runtime-root',str(runtime),'--format','json',*args],cwd=workspace,capture_output=True,text=True,env={**os.environ,'PYTHONPATH':str(cli.REPO_ROOT)},timeout=90) - assert lost.returncode==93 and seen.exists(),(lost.stdout,lost.stderr) - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 - return actual_run(*args) - binding=('--agent-id',cli.AGENT_ID,'--todo-id',cli.TODO_ID,'--turn-instance-id',cli.TURN_ID) - code,guard=run('quota','should-run','--codex-app','--goal-id',cli.GOAL_ID,'--scan-path',str(project),*binding, - '--available-capability','shell','--available-capability','filesystem_write') - assert code==0,guard - identity=guard['heartbeat_receipt']['settlement_identity'] - complete=('todo','complete','--goal-id',cli.GOAL_ID,*binding,*lease,'--no-follow-up', - '--evidence','Original declared deliverable, then current post-result direction.') - code,result=run(*complete) - assert code==0,result - assert result['completed'] is True and result['completion_continuation']=='active_goal',result - assert marker.read_text()=='run\n','RESULT must run exactly one original validator' - assert cli._spend_run_count(runtime)==0 - code,listed=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0,listed - saved=listed['todo'] - assert saved['status']=='done' and saved['completion_continuation']=='active_goal' - assert saved['completion_requested_continuation']=='no_followup',saved - assert not saved.get('successor_todo_ids') - assert saved.get('completion_validation_sha256')==original.get('completion_validation_sha256') - assert saved['completion_turn_key']==identity['effect_id'],saved - assert result['settlement_identity']==identity,result - if provider!='legacy': - assert result['completion_identity_key']==identity['effect_id'] - # A fresh original request must recover RESULT without revalidation. - code,replay=run(*complete) - assert code==0 and replay['idempotent_replay'] is True,replay - assert marker.read_text()=='run\n' - lifecycle=('todo','complete','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID,'--agent-id',cli.AGENT_ID, - '--completion-identity-key',identity['effect_id'],'--no-follow-up','--evidence',complete[-1]) - code,early=run(*lifecycle) - if provider=='legacy' and code==0: - # A historical RESULT replay is legal; it must neither represent nor - # mutate a terminal closeout. The frozen condition is no early close. - assert early['idempotent_replay'] is True and early['changed'] is False,early - assert early['completion_continuation']=='active_goal',early - else: - assert code==1,early - code,after=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and after['todo']==saved,after - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==0 - # New direction is authored only after the CLI returns actual committed RESULT. - code,context=run('checkpoint-context','--goal-id',cli.GOAL_ID,*binding,'--purpose','first_delivery') - assert code==0,context - assert context['settlement_identity']==identity and context['basis']['todo']['status']=='done' - decision=checkpoint_decision(context) - direction=('--checkpoint-read-context',context['read_context_id']) - if decision.get('agent_vision'): - vision=tmp_path/'direction.json' - vision.write_text(json.dumps(decision['agent_vision'])) - direction+=('--agent-vision-json',str(vision)) - else: - direction+=('--vision-unchanged-reason',decision['vision_unchanged_reason']) - refresh=('refresh-state','--goal-id',cli.GOAL_ID,*binding,*direction, - '--classification','declared_deliverable_complete','--delivery-batch-scale','implementation', - '--delivery-outcome','outcome_progress','--no-global-sync','--suppress-external-sinks') - code,refreshed=run(*refresh) - assert code==0,refreshed - spend=('quota','spend-slot','--goal-id',cli.GOAL_ID,*binding,'--slots','1','--source','heartbeat','--execute', - '--scan-path',str(project)) - code,spent=run(*spend) - assert code==0,spent - assert cli._spend_run_count(runtime)==1 - code,before_reentry=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0,before_reentry - for field,replacement in (('--completion-identity-key',identity['effect_id']+'-wrong'), - ('--evidence','Replacement evidence must be rejected.'), - ('--agent-id','unregistered-cold-actor')): - rejected=list(lifecycle) - rejected[rejected.index(field)+1]=replacement - code,failure=run(*rejected) - assert code==1,failure - code,unchanged=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and unchanged['todo']==before_reentry['todo'],unchanged - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 - terminal=complete if closeout_source=='turn' else lifecycle - code,closed=run(*terminal) - assert code==0 and closed['completion_continuation']=='no_followup',closed - for args in (complete,refresh,spend,terminal): - code,replay=run(*args) - assert code==0 and replay['idempotent_replay'] is True,replay - assert marker.read_text()=='run\n' and cli._spend_run_count(runtime)==1 - code,final=run('todo','list','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and final['todo']['completion_continuation']=='no_followup',final - code,retired=run('task-lease','inspect','--goal-id',cli.GOAL_ID,'--todo-id',cli.TODO_ID) - assert code==0 and retired['lease']['status']=='released' and retired['lease']['idempotency_key']==lease[1] and retired['lease']['version']==int(lease[-1]) and retired['lease']['lease_epoch']==1,retired - assert final['todo'].get('completion_validation_sha256')==original.get('completion_validation_sha256') +def test_unpromoted_terminal_rejects_work_and_completion(tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = cli._write_fixture(tmp_path) + cli._configure_repository_write_todo(project) + binding = ("--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID) + for command in (("quota", "should-run", "--codex-app"), ("todo", "complete")): + args = binding if command[0] == "quota" else binding[:-2] + code, rejected = cli._run_cli(registry, runtime, *command, *args, cwd=project) + assert code == 1 and "canonical_authority_required" in json.dumps(rejected), rejected + assert cli._spend_run_count(runtime) == 0 @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -703,7 +498,10 @@ def call(*args: str, expected: int = 0): done = call("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, "--evidence", "Original declared source check passed.") before = call("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID)["todo"] + direction = call("checkpoint-context", "--goal-id", cli.GOAL_ID, *binding, + "--purpose", "first_delivery") call("refresh-state", "--goal-id", cli.GOAL_ID, *binding, + "--checkpoint-read-context", direction["read_context_id"], "--classification", "declared_deliverable_complete", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--vision-state", "vision_closed", "--vision-summary", "The original source deliverable passed.", @@ -728,7 +526,7 @@ def call(*args: str, expected: int = 0): recovered = call(*guard) assert recovered["heartbeat_receipt"]["settlement_identity"] == identity assert recovered.get("selected_todo") is None - assert recovered["replan_action_packet"]["settlement_only"] is True + assert recovered["replan_action_packet"].get("settlement_only") is True, json.dumps({"packet":recovered["replan_action_packet"],"guard":admitted["heartbeat_receipt"],"current":recovered.get("autonomous_replan_obligation")}) assert recovered["replan_action_packet"]["writeback_contract"]["preferred_input"] == "evidence_linked_vision_path" replan_binding = ("--agent-id", cli.AGENT_ID, "--replan-obligation-id", identity["replan_obligation_id"], "--turn-instance-id", turn) diff --git a/tests/control_plane/test_default_checkpoint_continuation.py b/tests/control_plane/test_default_checkpoint_continuation.py index e901599eee..7ce9c68dad 100644 --- a/tests/control_plane/test_default_checkpoint_continuation.py +++ b/tests/control_plane/test_default_checkpoint_continuation.py @@ -146,7 +146,7 @@ def test_managed_default_reuses_candidate_and_calls_current_agent_for_direction( rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', json.dumps([sys.executable, str(host)]), '--validation-command-json', json.dumps([sys.executable, str(validator)]), - '--scan-root', str(project), '--no-global-sync', '--execute') + '--no-global-sync', '--execute') assert rc == 0 and result.get('status') == 'committed', json.dumps(result, indent=2) assert len((project / 'direction.txt').read_text().splitlines()) == 1 rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text(encoding='utf-8').splitlines()] @@ -173,12 +173,14 @@ def run_dsh_turn(**kwargs): 'vision_unchanged_reason':'Current evidence preserves this session route.'}) pathlib.Path(kwargs['workspace'],'candidate.txt').write_text('valid') return json.dumps({'result_kind':'validated_progress','classification':'dsh_progress', - 'summary':'Verified one scoped candidate.','next_action':'Review remaining work.'}) + 'summary':'Verified one scoped candidate.','next_action':'Review remaining work.', + 'work_evidence':['artifact:candidate.txt'], + 'validation_evidence':['check:scoped-candidate-validation']}) ''', encoding='utf-8') rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'dsh', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--project', str(project), '--dsh-runner', str(runner), '--iteration-context', iteration_context, '--validation-command-json', json.dumps([sys.executable, '-c', 'pass']), - '--scan-root', str(project), '--no-global-sync', '--execute') + '--no-global-sync', '--execute') assert rc == 0 and result['status'] == 'committed', result sessions = [json.loads(line) for line in (project / 'sessions.jsonl').read_text().splitlines()] assert [row['direction'] for row in sessions] == [False, True] @@ -286,7 +288,7 @@ def test_managed_noncompletion_uses_original_outcome_or_exemption(kind, tmp_path rc, result = _run_cli(registry, 'turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', json.dumps([sys.executable, str(host)]), '--validation-command-json', json.dumps([sys.executable, '-c', 'pass']), - '--scan-root', str(project), '--no-global-sync', '--execute') + '--no-global-sync', '--execute') assert rc == 0 and result['status'] in {'committed','stopped'}, json.dumps(result, indent=2) assert (project / 'direction.txt').exists() == (kind != 'wait') rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text(encoding='utf-8').splitlines()] diff --git a/tests/control_plane/test_default_guard_admission.py b/tests/control_plane/test_default_guard_admission.py index 641b3cd14f..35cdedfb63 100644 --- a/tests/control_plane/test_default_guard_admission.py +++ b/tests/control_plane/test_default_guard_admission.py @@ -37,11 +37,11 @@ def _fixture(provider, tmp_path, monkeypatch, *, crowded=False, gated=False, pee todos=todos["agent_todos"]["items"] + todos.get("user_todos", {}).get("items", []), handoff_mode="soft_claim"), state_path=state, provider=provider) args = ("quota", "should-run", "--goal-id", GOAL_ID, - "--agent-id", AGENT_IDS[0], "--scan-root", str(project)) + "--agent-id", AGENT_IDS[0]) return project, runtime, registry, state, args -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("selector", [(), ("--include-detail", "agent-todos"), ("--include-detail", "user-todos"), ("--include-detail", "goal-boundary"), ("--include-detail", "scheduler"), ("--include-detail", "vision"), @@ -63,7 +63,7 @@ def test_first_detail_selector_admits_and_cold_replays_original_work(provider, s assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("replan", [False, True]) def test_unbound_concurrent_reads_return_selectable_identities_then_bind_exact_turn(provider, replan, tmp_path, monkeypatch): project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, crowded=True, replan=replan) @@ -95,7 +95,7 @@ def read(): # Use the returned exact selection command, not a test-created replacement ID. selection = results[0][1]["interaction_contract"]["cli_channel"]["selection_command"] selected_args = shlex.split(selection["command_args_template"].replace("{todo_id}", "todo_fixture_000")) - code, selected = _run_cli(registry, runtime, *selected_args, "--scan-root", str(project), cwd=project) + code, selected = _run_cli(registry, runtime, *selected_args, cwd=project) assert code == 0, json.dumps(selected, indent=2) identity = selected["heartbeat_receipt"]["settlement_identity"] assert identity["turn_instance_id"] == ids[0] and identity["todo_id"] == "todo_fixture_000" @@ -111,7 +111,7 @@ def read(): assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_user_gate_receipt_does_not_authorize_delivery_or_spend(provider, tmp_path, monkeypatch): project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, gated=True) for selector in [(), ("--include-detail", "all")]: @@ -124,13 +124,13 @@ def test_user_gate_receipt_does_not_authorize_delivery_or_spend(provider, tmp_pa code, rejected = _run_cli(registry, runtime, "quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_IDS[0], "--todo-id", "todo_fixture_000", "--turn-instance-id", receipt["turn_instance_id"], "--slots", "1", - "--source", "heartbeat", "--execute", "--scan-root", str(project), cwd=project) + "--source", "heartbeat", "--execute", cwd=project) assert code == 1, rejected assert _spend_run_count(runtime) == 0 assert not list((runtime / "goals" / GOAL_ID / "turns").glob("*.json")) -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_another_agent_cannot_restore_the_original_work(provider, tmp_path, monkeypatch): project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, peer=True) code, first = _run_cli(registry, runtime, *args, cwd=project) @@ -145,7 +145,7 @@ def test_another_agent_cannot_restore_the_original_work(provider, tmp_path, monk assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_admission_policy_failure_cannot_bind_an_unbound_turn(provider, tmp_path, monkeypatch): project, runtime, registry, _, args = _fixture(provider, tmp_path, monkeypatch, crowded=True) code, first = _run_cli(registry, runtime, *args, cwd=project) @@ -158,7 +158,7 @@ def unavailable(*_args, **_kwargs): monkeypatch.setattr("loopx.control_plane.goals.checkpoint_context_io.default_result_freshness_policy", unavailable) command = shlex.split(selection["route_prefix"] + " " + selection["command_args_template"].replace("{todo_id}", "todo_fixture_000"))[1:] - code, text = _invoke_cli(["--format", "json", *command, "--scan-root", str(project)]) + code, text = _invoke_cli(["--format", "json", *command]) rejected = json.loads(text) assert code == 1 and rejected["should_run"] is False, rejected assert rejected["ok"] is False and rejected["recommended_action"] @@ -168,17 +168,15 @@ def unavailable(*_args, **_kwargs): @pytest.mark.parametrize("provider", ["legacy"]) -def test_malformed_first_read_fails_closed_then_repairs_without_moving_tokens(provider, tmp_path, monkeypatch): +def test_unpromoted_display_cannot_admit_work_after_display_repair(provider, tmp_path, monkeypatch): project, runtime, registry, state, args = _fixture(provider, tmp_path, monkeypatch) original = state.read_bytes() state.write_text(state.read_text(encoding="utf-8").replace("status=open", "status=unsupported"), encoding="utf-8") code, failed = _run_cli(registry, runtime, *args, cwd=project) - assert code == 1 and failed["status_health_ok"] is False and failed["should_run"] is False, failed - assert failed["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + assert code == 1 and failed["error_code"] == "canonical_authority_required", failed assert "delivery_result_context" not in failed and _spend_run_count(runtime) == 0 state.write_bytes(original) code, admitted = _run_cli(registry, runtime, *args, cwd=project) - assert code == 0 and admitted["delivery_result_context"]["basis"]["todo"]["todo_id"] == "todo_fixture_000", admitted - code, restored = _run_cli(registry, runtime, *args, cwd=project) - assert code == 0 and restored["delivery_result_context"] == admitted["delivery_result_context"], restored + assert code == 1 and admitted["error_code"] == "canonical_authority_required", admitted + assert "delivery_result_context" not in admitted assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_default_turn_recovery.py b/tests/control_plane/test_default_turn_recovery.py index 365225bf37..7c98a06da7 100644 --- a/tests/control_plane/test_default_turn_recovery.py +++ b/tests/control_plane/test_default_turn_recovery.py @@ -57,7 +57,7 @@ def count(name): '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', json.dumps([sys.executable, str(host)]), '--validation-command-json', json.dumps([sys.executable, str(validator)]), - '--scan-root', str(project), '--no-global-sync', '--execute'] + '--no-global-sync', '--execute'] injection = '''import os,sys from loopx.cli import main from loopx.cli_commands import turn_run_once as tr diff --git a/tests/control_plane/test_first_delivery_mcp.py b/tests/control_plane/test_first_delivery_mcp.py index d22c9c3900..1f0351ece8 100644 --- a/tests/control_plane/test_first_delivery_mcp.py +++ b/tests/control_plane/test_first_delivery_mcp.py @@ -5,10 +5,8 @@ import pytest -from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection -from loopx.status import parse_active_state_todos from loopx.todos import add_goal_todo -from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID, _write_fixture, _control, _run_cli @@ -16,7 +14,7 @@ @pytest.mark.parametrize("new_obligation", [False, True]) def test_mcp_first_delivery_recovers_stale_direction_without_repeating_result(tmp_path, monkeypatch, provider, new_obligation): isolate_sqlite_runtime(tmp_path, monkeypatch) - registry, state = _write_fixture(tmp_path) + registry, state = _write_fixture(tmp_path, provider=provider) registration = json.loads(registry.read_text(encoding="utf-8")) registration["goals"][0]["adapter"] = {"kind": "fixture_v0", "status": "connected-delivery"} registry.write_text(json.dumps(registration), encoding="utf-8") @@ -25,10 +23,6 @@ def test_mcp_first_delivery_recovers_stale_direction_without_repeating_result(tm claimed_by=AGENT_ID, continuation_policy="same_agent_non_delivery") todo_id = added["todo_id"] runtime = tmp_path / "runtime" - todos = parse_active_state_todos(state.read_text(encoding="utf-8")) - initialize_canonical_authority(runtime, GOAL_ID, - build_todo_runtime_shadow_projection(goal_id=GOAL_ID, handoff_mode="soft_claim", todos=todos["agent_todos"]["items"]), - state_path=state, provider=provider) rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--vision-summary", "Verify the bounded output.", "--vision-acceptance", "Output verification passes.", "--no-global-sync", "--suppress-external-sinks") @@ -54,7 +48,7 @@ def test_mcp_first_delivery_recovers_stale_direction_without_repeating_result(tm # Dashboard and Lark projections call the shared collector, not the CLI's # Agent decorator. An unscoped read must retain the same recovery message. shared = collect_status(registry_path=registry, runtime_root_override=str(runtime), - scan_roots=[state.parent], limit=10, goal_id=GOAL_ID, include_public_boundary_scan=False) + limit=10, goal_id=GOAL_ID) shared_item = shared["attention_queue"]["items"][0] assert shared_item["first_delivery_progress"]["stage"] == "direction_pending" assert shared_item["recommended_action"] == observed["next_action"] diff --git a/tests/control_plane/test_inflight_result_freshness.py b/tests/control_plane/test_inflight_result_freshness.py index bb0f5464f8..097f48c055 100644 --- a/tests/control_plane/test_inflight_result_freshness.py +++ b/tests/control_plane/test_inflight_result_freshness.py @@ -29,7 +29,7 @@ def _runs(runtime): return {p.name: p.read_bytes() for p in (runtime / 'goals' / GOAL_ID / 'runs').glob('*') if p.is_file() and p.suffix in {'.json', '.jsonl', '.md'}} -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('vision', [False, True]) def test_inflight_rejects_changed_basis_then_revalidates_same_turn(provider, vision, tmp_path, monkeypatch): project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) @@ -68,7 +68,7 @@ def test_inflight_rejects_changed_basis_then_revalidates_same_turn(provider, vis assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) def test_inflight_unchanged_basis_does_not_require_new_direction(provider, tmp_path, monkeypatch): project, runtime, registry, _, context, args = _admit(provider, tmp_path, monkeypatch) rc, committed = _run_cli(registry, runtime, *args, cwd=project) @@ -94,7 +94,7 @@ def test_inflight_missing_work_receipt_cannot_mint_a_new_read(provider, tmp_path assert _runs(runtime) == before -@pytest.mark.parametrize('provider', ['legacy', 'file', 'sqlite']) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) def test_historical_inflight_admission_keeps_its_original_policy(provider, tmp_path, monkeypatch): from tests.control_plane.test_quota_settlement_cli import _historical_turn project, runtime, registry, state, context, args = _admit(provider, tmp_path, monkeypatch) diff --git a/tests/control_plane/test_native_journal_settlement.py b/tests/control_plane/test_native_journal_settlement.py index 227043c4cf..adf17951a3 100644 --- a/tests/control_plane/test_native_journal_settlement.py +++ b/tests/control_plane/test_native_journal_settlement.py @@ -45,8 +45,6 @@ def test_original_native_mcp_settlement_reconciles_managed_journal( json.dumps([sys.executable, str(host)]), "--validation-command-json", json.dumps([sys.executable, str(validator)]), - "--scan-root", - str(project), "--no-global-sync", "--execute", ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index c14f23ddf8..803d89b9bb 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -3995,8 +3995,7 @@ def test_same_turn_identityless_guard_upgrades_and_settles_full_chain( successor_guard_rc, successor_guard = _run_cli( registry_path, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, - "--available-capability", "network", "--scan-path", str(project), - ) + "--available-capability", "network", ) assert successor_guard_rc == 0, successor_guard assert successor_guard["selected_todo"]["todo_id"] == successor_id @@ -4798,7 +4797,7 @@ def test_default_visible_goal_admission_still_rejects_unbound_manual_spend( bound_rc, bound = _run_cli(registry_path, runtime, "quota", "should-run", "--runtime-profile", profile, "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", identity["turn_instance_id"], - "--scan-path", str(project)) + ) assert bound_rc == 0, bound plan = bound["interaction_contract"]["cli_channel"]["settlement_plan"] assert plan["identity"] == identity @@ -6940,8 +6939,7 @@ def test_default_visible_goal_todoless_replan_replays_original_admission( assert all(f"--turn-instance-id {original['turn_instance_id']}" in action for action in actions) bound_rc, bound = _run_cli(registry_path, runtime, "quota", "should-run", "--runtime-profile", "codex_app_ssh_goal", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, "--scan-path", str(project), - "--turn-instance-id", original["turn_instance_id"]) + "--agent-id", AGENT_ID, "--turn-instance-id", original["turn_instance_id"]) assert bound_rc == 0, bound assert bound["decision"] == "autonomous_replan_required", bound diff --git a/tests/control_plane/test_receipt_bound_lifecycle_recovery.py b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py index 8b0d439b62..0e94c7cfa4 100644 --- a/tests/control_plane/test_receipt_bound_lifecycle_recovery.py +++ b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py @@ -20,7 +20,7 @@ @pytest.mark.parametrize('case', ['recovered', 'unsettled', 'wrong_candidate', 'no_candidate', 'unguarded', 'wrong_source', 'no_source', 'non_boolean']) -def test_selection_boundary(case): +def test_incomplete_lifecycle_candidate_cannot_replace_receipt_source(case): candidate = {'lifecycle_settled': True, 'obligation': copy.deepcopy(RECOVERED)} source = copy.deepcopy(SOURCE) context = {'replan_obligation': {'obligation_id': 'replan-fedcba9876543210'}, @@ -43,8 +43,9 @@ def test_selection_boundary(case): with pytest.raises(ValueError, match='source identity differs'): call() return chosen, _ = call() - expected = (RECOVERED if case in ['recovered', 'no_source'] else - context['replan_obligation'] if case == 'unguarded' else SOURCE) + # A candidate's settled boolean is not native lifecycle proof. These + # intentionally incomplete objects cannot replace the admitted source. + expected = context['replan_obligation'] if case in {'unguarded', 'no_source'} else SOURCE assert chosen == expected @@ -56,7 +57,7 @@ def test_native_lifecycle_writeback_and_replay(tmp_path, monkeypatch, provider): base = ['--goal-id', cli.GOAL_ID, '--agent-id', cli.AGENT_ID] original_binding = [*base, '--todo-id', cli.TODO_ID, '--turn-instance-id', cli.TURN_ID] code, guard = run('quota', 'should-run', '--codex-app', *original_binding, - '--scan-path', str(project), '--available-capability', 'shell', '--available-capability', 'filesystem_write') + '--available-capability', 'shell', '--available-capability', 'filesystem_write') assert code == 0 and guard['ok'], guard # Only this isolated fixture seeds the already-supported historical protocol. cli._historical_turn(runtime) @@ -70,7 +71,7 @@ def test_native_lifecycle_writeback_and_replay(tmp_path, monkeypatch, provider): '--vision-summary', 'The original bounded fixture deliverable passed.', '--vision-acceptance', 'Retain original acceptance and exact result.', '--no-global-sync', '--suppress-external-sinks') assert code == 0 and refreshed['ok'], refreshed - code, spent = run('quota', 'spend-slot', *original_binding, '--execute', '--scan-path', str(project)) + code, spent = run('quota', 'spend-slot', *original_binding, '--execute', ) assert code == 0 and spent['ok'] and cli._spend_run_count(runtime) == 1, spent turn = 'receipt-bound-review' @@ -82,8 +83,7 @@ def test_native_lifecycle_writeback_and_replay(tmp_path, monkeypatch, provider): assert receipt['semantic_replan_source']['obligation']['resolution_mode'] == 'todo_lifecycle_settlement', admitted source_bytes = json.dumps(receipt['semantic_replan_source'], sort_keys=True) closeargs = ['todo', 'complete', *base, '--todo-id', cli.TODO_ID, - '--completion-identity-key', completed['completion_identity_key'], '--no-follow-up', - '--evidence', 'Original bounded fixture validator passed.'] + '--completion-identity-key', completed['completion_identity_key'], '--no-follow-up'] code, closed = run(*closeargs) assert code == 0 and closed['completion_recovery'] == 'lifecycle_reentry_terminal_closeout', closed assert closed['completion_receipt_id'] == completed['completion_receipt_id'] @@ -114,14 +114,20 @@ def test_native_lifecycle_writeback_and_replay(tmp_path, monkeypatch, provider): delta = written['autonomous_replan_ack']['semantic_delta'] assert delta['accepted'] is True and delta['obligation_id'] == oid, written assert cli._spend_run_count(runtime) == 1 - spend = ['quota', 'spend-slot', *binding, '--execute', '--scan-path', str(project)] + spend = ['quota', 'spend-slot', *binding, '--execute', ] code, settled = run(*spend) - assert code == 0 and settled['ok'] and settled['settlement_progress']['state'] == 'settled', settled - assert cli._spend_run_count(runtime) == 2 + assert code == 0 and settled['ok'], settled + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + ledger = read_heartbeat_settlement(runtime, goal_id=cli.GOAL_ID, agent_id=cli.AGENT_ID, + todo_id=None, turn_instance_id=turn, replan_obligation_id=oid, registry_path=registry) + assert ledger is not None and ledger.settlement.failure is None + # Main's blocked-retry closeout is exempt: preserve the original debit, + # with no new debit for this evidence-linked blocked outcome. + assert ledger.spend_run is None and cli._spend_run_count(runtime) == 1 for args in (refresh, spend, closeargs): code, replay = run(*args) assert code == 0 and replay['idempotent_replay'] is True, replay - assert cli._spend_run_count(runtime) == 2 and marker.read_text() == 'run\n' + assert cli._spend_run_count(runtime) == 1 and marker.read_text() == 'run\n' code, current = run('quota', 'should-run', '--codex-app', *base, '--turn-instance-id', 'lifecycle173-later') assert code == 0 and current['ok'], current assert current['heartbeat_receipt'].get('semantic_replan_obligation_id') != oid @@ -151,7 +157,7 @@ def test_original_source_cardinality(case): call=lambda: writer.qualify_replan_writeback(newest_first_runs=[],state_text='',agent_id='fixture-agent', goal_id='fixture-goal',todo_fields={},guard_scoped=True,guard_semantic_replan_obligation_id=OID, receipt_bound_guard_recorded_at='2026-08-13T05:00:00Z',receipt_bound_source=source) - if case!='matching': + if case not in {'matching', 'unsettled'}: with pytest.raises(EffectRuntimeRejected,match='lifecycle source does not match'):call() return chosen,_=call() @@ -159,4 +165,6 @@ def test_original_source_cardinality(case): if case=='matching': assert chosen['resolution_mode']=='receipt_bound_replan_settlement' assert 'fresh_vision_path_outcome' in chosen['satisfying_semantic_outcomes'] - else:assert chosen==source + else: + assert chosen['resolution_mode'] == 'todo_lifecycle_settlement' + assert chosen['satisfying_semantic_outcomes'] == SOURCE['satisfying_semantic_outcomes'] diff --git a/tests/control_plane/test_turn_completion_source.py b/tests/control_plane/test_turn_completion_source.py index 69732e7d34..81c6506ae9 100644 --- a/tests/control_plane/test_turn_completion_source.py +++ b/tests/control_plane/test_turn_completion_source.py @@ -19,11 +19,28 @@ from tests.test_loopx_turn_driver import ( _completion_host_and_validation_scripts, _turn_journal, - _turn_run_once_completion_argv, + _turn_run_once_completion_argv as _source_completion_argv, _write_live_fixture, ) +def _turn_run_once_completion_argv(project, runtime, registry, host_script, validation_script, *, turn_id=None): + # These source-witness regressions preserve the pre-freshness Turn contract. + # Default result/Agent continuation has its own real-provider regressions. + # Seed the supported historical receipt before execution, never downgrade + # an admission produced by the current default. + argv = _source_completion_argv(project, runtime, registry, host_script, validation_script) + code, preview = _cli([], *argv[:-1], *(["--turn-instance-id", turn_id] if turn_id else [])) + assert code == 0, preview + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + ensure_turn_heartbeat_settlement_receipt(runtime, SettlementIdentity( + goal_id="loopx-turn-fixture", agent_id="codex-fixture", todo_id="todo_fixture0001", + turn_instance_id=turn_id or preview["resume_turn_key"]), semantic_replan_guard_scoped=True, + semantic_replan_obligation_id=None) + return argv + + @pytest.mark.parametrize( "carrier", ["canonical_detail", "deduplicated_text", "elided_context", "recommendation_ref"], @@ -207,6 +224,7 @@ def recover_old_writer(**kwargs): # Recovery reads the original accepted completion; it must not invoke the # lifecycle writer again after its execution lease was retired. assert len(results) == 1 + assert _todo(prefix)["todo"]["completion_receipt_id"] == results[0]["completion_receipt_id"] recovered = _turn_journal(runtime) assert recovered["writeback"]["completion"] == { "todo_id": "todo_fixture0001", "continuation": "active_goal", @@ -270,7 +288,7 @@ def _update( @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("change", ["requirements", "note", "other_todo"]) @pytest.mark.parametrize("no_followup", [False, True]) -def test_native_turn_completion_checks_full_source_and_preserves_unrelated_changes( +def test_historical_native_turn_completion_checks_full_source_and_preserves_unrelated_changes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, @@ -389,7 +407,7 @@ def change_after_real_validation(*args, **kwargs): ) fresh_validator = validator.replace('== "completed"', '== "checksum:verified"') fresh = _turn_run_once_completion_argv( - project, runtime, registry, fresh_host, fresh_validator + project, runtime, registry, fresh_host, fresh_validator, turn_id="fresh-checksum-turn" ) fresh_code, accepted = _cli( [], *fresh[:-1], "--turn-instance-id", "fresh-checksum-turn", "--execute" @@ -411,7 +429,7 @@ def change_after_real_validation(*args, **kwargs): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_native_turn_recovers_accepted_completion_before_checking_new_text( +def test_historical_native_turn_recovers_accepted_completion_before_checking_new_text( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, @@ -502,6 +520,7 @@ def lose_response_after_commit(**kwargs): # Recovery reads the original accepted completion; it must not invoke the # lifecycle writer again after its execution lease was retired. assert len(completions) == 1 + assert _todo(prefix)["todo"]["completion_receipt_id"] == completions[0]["completion_receipt_id"] recovered = _turn_journal(runtime) assert recovered["writeback"]["completion"] == { "todo_id": "todo_fixture0001", "continuation": "active_goal", diff --git a/tests/control_plane_ts/default_parent_transport.test.mjs b/tests/control_plane_ts/default_parent_transport.test.mjs index 4e48f0afc0..44de109700 100644 --- a/tests/control_plane_ts/default_parent_transport.test.mjs +++ b/tests/control_plane_ts/default_parent_transport.test.mjs @@ -12,28 +12,28 @@ const {executeCoordinationTodoTerminalLifecycle:execute,completionTurnOperationI const {TODO_DOMAIN_ITEM_SCHEMA,TODO_DOMAIN_READ_RECORD_SCHEMA,TODO_DOMAIN_RECORD_CONTRACT}=await imp('loopx/control_plane/coordination/coordination_state_contract.ts'); const cases=['exact_parent','mcp_default_parent','changed_evidence','changed_note','wrong_role','foreign_actor','foreign_policy_claim','foreign_delivery_context','foreign_lease','foreign_turn','changed_validation','clear_claim']; const expanded=[...cases,...cases.map(id=>'deferred_'+id)]; -const rows=[];const root=process.env.LOOPX_PARENT_TEST_ROOT?resolve(process.env.LOOPX_PARENT_TEST_ROOT):resolve(tmpdir(),'loopx-parent-'+randomUUID());assert.ok(!existsSync(root));mkdirSync(root); +const rows=[];const root=process.env.LOOPX_PARENT_TEST_ROOT?resolve(process.env.LOOPX_PARENT_TEST_ROOT):resolve(tmpdir(),'parent-'+randomUUID().slice(0,12));assert.ok(!existsSync(root));mkdirSync(root); for(const provider of ['file','sqlite'])for(const caseId of expanded){ const deferred=caseId.startsWith("deferred_");const id=deferred?caseId.slice(9):caseId; - const dir=resolve(root,provider,caseId);mkdirSync(dir,{recursive:true});const goal='synthetic-parent-'+caseId,tid='todo_parent',owner='agent-a',agents=[owner,'agent-b']; + const dir=resolve(root,provider,caseId);mkdirSync(dir,{recursive:true});const goal='synthetic-parent',tid='todo_parent',owner='agent-a',agents=[owner,'agent-b']; const store=provider==='file'?new FileAuthorityStore(resolve(dir,'authority'),goal):new SqliteAuthorityStore(resolve(dir,'authority'),goal); const todo={schema_version:TODO_DOMAIN_ITEM_SCHEMA,todo_id:tid,role:'agent',status:'open',done:false,archive_state:'active',task_class:'advancement_task',text:'Validate a synthetic retained result',claimed_by:owner,no_followup:true};const todos=[todo]; - const seeded=await store.commitAuthority({operation_id:'seed',expected_provider_revision:null,events:[],receipts:[],next_projection:{goal_id:goal,handoff_mode:'legacy',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]}}});writeFileSync(resolve(dir,'seed-result.json'),JSON.stringify(seeded,null,2));assert.equal(seeded.status,'applied',JSON.stringify(seeded)); + const seeded=await store.commitAuthority({operation_id:'seed',expected_provider_revision:null,events:[],receipts:[],next_projection:{goal_id:goal,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]}}});writeFileSync(resolve(dir,'seed-result.json'),JSON.stringify(seeded,null,2));assert.equal(seeded.status,'applied',JSON.stringify(seeded)); const policy={schema_version:'loopx_todo_completion_policy_request_v0',goal_id:goal,agent_model:'peer_v1',claimed_by:null,registered_agents:agents,next_claimed_by:null,next_agent_todo:null,next_continuation_policy:null,next_excluded_agents:[],self_merged:false,evidence:'Synthetic validation',linked_successors:[]}; const parent={goal_id:goal,todo_id:tid,expected_role:'agent',command:'complete',actor_agent_id:owner,registered_agents:agents,lifecycle_grants:[],authority_reason:null,decision_outcome:null,operation_identity:{kind:'completion_turn'},lease_idempotency_key:null,lease_expected_version:null,allow_user_gate_auto_acquire:true,requested_no_followup:false,defer_no_followup:true,requested_completion_turn_key:'original-synthetic-turn',requested_completion_identity_source:'turn_settlement',linked_successor_todo_ids:[],successor_intents:[],note:'Original managed next action',evidence:'Synthetic validation',reason:null,clear_claim:false,validation_declaration:null,validation_receipt:null,completion_policy_request:policy,dry_run:false,now:new Date('2026-10-13T00:00:00Z'),delivery_read_context_id:'original-synthetic-result-context'}; - const basis=async()=>({ok:true});const result=await execute(store,parent,async()=>true,undefined,basis);assert.equal(result.status,'applied',JSON.stringify(result)); + const basis=async()=>({ok:true});const result=await execute(store,parent,async()=>true,undefined,undefined,basis);assert.equal(result.status,'applied',JSON.stringify(result)); const parentId=completionTurnOperationId(parent,false),receipt=await store.readReceipt(parentId);const before=await store.loadAuthority();assert.equal(before.status,'loaded');assert.equal(before.head.todos[0].status,'done');assert.equal(before.head.todos[0].completion_requested_continuation,'no_followup'); const exact={...parent,requested_no_followup:true,defer_no_followup:false};const mcp={...exact,expected_role:null,note:null,delivery_read_context_id:undefined,completion_policy_request:{...policy,claimed_by:owner}}; let request=id==='exact_parent'?exact:mcp; const patches={changed_evidence:{evidence:'Changed result'},changed_note:{note:'Changed intended note'},wrong_role:{expected_role:'user'},foreign_actor:{actor_agent_id:'agent-b'},foreign_policy_claim:{completion_policy_request:{...policy,claimed_by:'agent-b'}},foreign_delivery_context:{delivery_read_context_id:'foreign-result-context'},foreign_lease:{lease_idempotency_key:'foreign-lease',lease_expected_version:999},foreign_turn:{requested_completion_turn_key:'another-turn'},changed_validation:{validation_declaration_sha256:'f'.repeat(64)},clear_claim:{clear_claim:true}}; - if(patches[id])request={...mcp,...patches[id]};if(deferred)request={...request,defer_no_followup:true};const afterResult=await execute(store,request,async()=>true,undefined,basis);const after=await store.loadAuthority();const parentPreserved=canonicalAuthoritySha256(await store.readReceipt(parentId))===canonicalAuthoritySha256(receipt); + if(patches[id])request={...mcp,...patches[id]};if(deferred)request={...request,defer_no_followup:true};const afterResult=await execute(store,request,async()=>true,undefined,undefined,basis);const after=await store.loadAuthority();const parentPreserved=canonicalAuthoritySha256(await store.readReceipt(parentId))===canonicalAuthoritySha256(receipt); let passed;let replayResult=null; if(deferred&&(id==='exact_parent'||id==='mcp_default_parent')){ passed=afterResult.status==='replayed'&&canonicalAuthoritySha256(after)===canonicalAuthoritySha256(before)&&parentPreserved&&after.head.todos[0].no_followup===false; - if(passed){const final=await execute(store,{...request,defer_no_followup:false},async()=>true,undefined,basis);const closed=await store.loadAuthority();replayResult=await execute(store,{...request,defer_no_followup:false},async()=>{throw new Error('final replay must precede authority');},undefined,basis);passed=final.status==='applied'&&closed.head.todos[0].no_followup===true&&replayResult.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(closed);} + if(passed){const final=await execute(store,{...request,defer_no_followup:false},async()=>true,undefined,undefined,basis);const closed=await store.loadAuthority();replayResult=await execute(store,{...request,defer_no_followup:false},async()=>{throw new Error('final replay must precede authority');},undefined,undefined,basis);passed=final.status==='applied'&&closed.head.todos[0].no_followup===true&&replayResult.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(closed);} }else if(id==='exact_parent'||id==='mcp_default_parent'){ passed=afterResult.status==='applied'&&after.head.todos[0].no_followup===true&&after.head.todos[0].completion_continuation==='no_followup'&&parentPreserved; - if(passed){const replay=await execute(store,request,async()=>{throw new Error('replay must precede authority');},undefined,basis);replayResult=replay;passed=replay.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(after);} + if(passed){const replay=await execute(store,request,async()=>{throw new Error('replay must precede authority');},undefined,undefined,basis);replayResult=replay;passed=replay.status==='replayed'&&canonicalAuthoritySha256(await store.loadAuthority())===canonicalAuthoritySha256(after);} }else passed=afterResult.status==='failed'&&canonicalAuthoritySha256(after)===canonicalAuthoritySha256(before)&&parentPreserved; const witness={provider,id:caseId,passed,replayResult,parent_accepted:true,limited_basis_callback:'synthetic owner boundary; does not qualify real checkpoint or installed MCP/browser',native_result:afterResult,head_before:before,head_after:after,parent_receipt_before:receipt,parent_receipt_after:await store.readReceipt(parentId)};const path=resolve(dir,'witness.json');writeFileSync(path,JSON.stringify(witness,null,2));rows.push({provider,id:caseId,passed,status:afterResult.status,reason_code:afterResult.reason_code??null,parent_receipt_preserved:parentPreserved,witness:path}); } From f35c24a8b01ead8f97f9bd1a88b5ae3017315f45 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 12:52:58 +0800 Subject: [PATCH 34/80] docs: clarify recovery boundaries and historical evaluation provenance Signed-off-by: Tartar --- .../rfcs/loopx-overall-roadmap-v0.md | 11 +++++++++++ .../goal-vision-replan-contract-v0.md | 18 ++++++++++++++++++ ...hness-regular-and-goal-change-evaluation.md | 8 ++++---- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 2b00c163f4..cd389905a3 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -1464,6 +1464,17 @@ An independent reviewer needs a separately registered optional contract. These defaults do not close R1, certify Goal completion, establish distributed atomicity or demonstrate model-outcome gains. +Recovery qualification now includes lost or damaged work-read receipts after +original result commit and historical result-only terminal revalidation after a +proved progress writeback, and an unfinished delegated Todo after Turn settlement. +The native index proves the original work row even after a direction supplement. +Sealed lifecycle recovery compares closeout at the canonical store's timestamp +precision, preserving same-second admission/closeout without adopting old terminal work. +Unknown commits and mismatched identities remain +rejected. The packaged Chat recovery smoke uses the existing same-origin relay +and an isolated Host profile; deterministic replay evidence does not qualify +installed Lark, mixed-version rollback or live-model utility. + **R1 transaction checkpoint.** Team-plan admission and whole-batch planning now live in `work_items/team_plan.ts`. Confirmation assigns all admitted lanes in one write with a durable operation receipt; identity is proposal + lane, never Todo text. File/SQLite authority uses the existing CAS and receipt owner; legacy Markdown writes the records and immutable receipt together under its existing fence and lock. Exact replay reads historical results even after a receiver changes, completes or deletes work. A precommit failure creates no lane prefix, and pending canonical display delivery requires recovery before Chat reports verified success. The card names partial assignments and gaps; quota/stop remain advisory and an explicit enforcement claim is rejected. Agent-originated settlement binds the same state basis at its journal's first write and re-reads it at settlement; a plan whose basis is missing or moved, or whose every lane is a gap, is a typed failed receipt that creates no Todo and replays unchanged. This closes the local assignment/retry portion of F4, not R1's collaboration acceptance. Registered receivers are assigned without being impersonated as authors; agent-originated settlement cannot assign another peer without owner confirmation. Assignment does not attest receiver adoption, a lease, execution, dependency consumption or independent acceptance. Do not add a second confirmation to ordinary already-authorized work. Gap resolution requires new explicit intent; replay must not silently extend the confirmed subset. The fingerprint binds current local state and canonical revision, not a full shared Goal-intent transaction. Local registry and active-state guards now remain held through the typed commit: native token claims survive caller exit, changed source bytes reject new assignments, and expired handoffs can only replay an exact committed receipt. Registry contention returns a retryable failure without creating work; canonical display recovery runs after the source guards release. Failed retry-safe Team Plan proposals retain their original review identity after Goal or manager workspace reload; retry revalidates current source facts and recovers existing assignments rather than generating a new proposal. Stale, gated and unsafe failures remain unavailable for this recovery, including in the already-open drawer. Chat recovery resolves the current registered Goal and source before receipt lookup; a removed Goal or unreadable bound source can block recovery. Canonical display recovery follows the current registered source, so this slice does not recover a deleted source binding. R2/R3/R4 still own executor qualification, receiver adoption/result return and shared intent/authorization; the cross-host Turn lease is not a plan barrier. diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 29173f9caa..e2263a9be2 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -1299,6 +1299,17 @@ rolling back a runtime, finish or reconcile pending protected Turns with a compatible runtime; preserve original receipts and successful artifacts. This does not add cross-host atomicity, Goal certification or model-quality guarantees. +An original committed Todo result remains replayable if its disposable work-read +receipt is lost or damaged; the typed completion proof, rather than a new read, +owns that recovery. On a historical result-only Turn, a verified indexed writeback +may precede an unfinished terminal result. Only matching identity, work context +and recovery evidence allow another result read in that lane. A settled Turn with +its original quota debit may +also validate its still-unfinished Todo, using the proved original work writeback +rather than a later direction row. Pending current checkpoint lanes retain exact +writeback replay; missing or conflicting commit proof stays fail-closed. +An unclaimed successor must be claimed before its work admission. + ### 中文:本阶段边界与恢复 普通新执行默认保存结果依据并在 Todo owner 提交时检查新鲜度。 @@ -1316,6 +1327,13 @@ CLI/MCP 自动携带原 Turn 和原 key;managed 在派发前持久保存依据 相关依据变化时,未提交结果必须重新验证候选;结果已经提交时只重新读取和 判断方向。原 Turn、已提交结果和回执保持原身份,不能把旧判断贴上新 token。 索引不完整或关键提交未知时保留原 artifacts,先协调原回执,不能换 Turn 绕过。 +原 Todo 结果已提交时,可丢失的工作读取回执损坏或缺失不应阻止其精确恢复; +该恢复以 typed completion proof 为准。历史 result-only Turn 的已验证索引写回 +可能早于尚未完成的终态结果,仅身份、工作依据及恢复证据全部匹配时允许重读。 +已完成结算且有原扣额回执的 Turn 也可验证尚未完成的 Todo,恢复依据必须是已证明的原工作写回, +不能误用之后的方向记录。尚待结算的当前 checkpoint 路径仍保留精确写回重放, +缺失或冲突的提交证明继续拒绝。 +未认领的后继 Todo 必须先认领,再获得工作 admission。 明确的 `required=false/satisfied=true/decision=not_required` 沿用既有 checkpoint 豁免;缺少 checkpoint 记录不能被观察器猜成无需判断。 diff --git a/docs/research/freshness-regular-and-goal-change-evaluation.md b/docs/research/freshness-regular-and-goal-change-evaluation.md index 99fab11403..4f1f3dd793 100644 --- a/docs/research/freshness-regular-and-goal-change-evaluation.md +++ b/docs/research/freshness-regular-and-goal-change-evaluation.md @@ -29,10 +29,10 @@ rate, leaderboard result or general superiority claim is made. main 也是 LoopX 产品快照,并非裸模型基线。两个产品快照包含多项差异,不能把全部分差归因于 freshness 检查。两小时是这次比较的预算上限,不代表完成了原官方十二小时评测。 -**分支与受测源码的关系:** 本次本地整合以 freshness 的受测文件树 -`a49afcd6c` 为准,并保留 PR #5677 旧分支头 `6e03113c7` 的提交历史。 -报告与入口链接是评测后增加的文档;这不等于对整合提交重新运行了评测, -也不证明与之后更新的 main 兼容或已达到合并条件。 +**分支与受测源码的关系:** 本报告的 freshness 受测文件树为 +`a49afcd6c`。PR #5677 分支随后 rebase 到更新的 main,并修复恢复和验证问题; +报告与入口链接也是评测后增加的文档。本文仍只记录这四次历史运行, +不代表对 rebase 后源码重新运行了评测,也不证明新版本有相同分数或已达到合并条件。 每个场景各保留一次 main 与一次修复后 freshness 结果。目标场景的 main 是已存在的参照运行,修复后仅重新运行了 freshness;常规场景则串行运行 From b9ba175f71c5de625ddb52203be351f8610cd2a7 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 14:38:14 +0800 Subject: [PATCH 35/80] fix(checkpoint): recover stale progress and preserve scoped source freshness Signed-off-by: Tartar --- .../control_plane/goals/checkpoint_commit.ts | 4 +- .../goals/checkpoint_context_io.py | 46 +++----- .../goals/checkpoint_read_context.ts | 24 ++-- .../turn_driver/checkpoint_continuation.py | 7 +- loopx/control_plane/turn_driver/executor.py | 10 +- .../turn_driver/host_todo_completion.ts | 2 +- loopx/state_refresh.py | 4 +- .../test_default_checkpoint_continuation.py | 111 +++++++++++++++++- .../test_inflight_result_freshness.py | 50 ++++++++ .../checkpoint_read_context.test.ts | 21 ++++ 10 files changed, 226 insertions(+), 53 deletions(-) diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 7823e45f44..b66f53a61c 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -226,7 +226,9 @@ export async function commitCheckpoint(value: unknown, const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); const firstDelivery = request.purpose === "first_delivery"; const resultWork = request.purpose === "delivery_result"; - const registryPath = firstDelivery || resultWork ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; + const registryBound = jsonObject(jsonObject(request.facts)?.source)?.registry_goal != null; + const registryPath = firstDelivery || resultWork || registryBound + ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; const targets = [indexPath, ...(registryPath ? [registryPath] : []), shadowMaintenanceLockPath(root, identity.goal_id), legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); const owner = parseQuotaAccountingOwner({ diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index b35fe6fad8..84aaabde15 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -171,7 +171,7 @@ def check_result_work_context(*, registry_path: Path, runtime_root: Path, return _resolve(runtime_root, phase="check", purpose="delivery_result", identity=identity.as_dict(), read_context_id=context.get("read_context_id"), receipt=_read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), - facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref, first_delivery=True)) + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref)) def bind_result_validation(*, runtime_root: Path, identity: SettlementIdentity, candidate: Mapping[str, Any], @@ -387,7 +387,7 @@ def check_locked_legacy_result_work(*, runtime_root: Path, registry_path: Path, _resolve(runtime_root, phase="check", purpose="delivery_result", identity=identity.as_dict(), read_context_id=context["read_context_id"], receipt=_read_context_receipt(_receipt_path(runtime_root, identity, "delivery_result")), - facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref, first_delivery=True)) + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref)) def first_delivery_context_enrolled(root: Path, identity: SettlementIdentity, *, result_only: bool = False) -> bool: @@ -582,7 +582,7 @@ def delivery_result_context_input( with exclusive_cross_runtime_file_lock(index, operation="delivery-result-capture"): with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): result.update( - facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref, first_delivery=True), + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, goal_ref), state_sha256=hashlib.sha256(state_file.read_bytes()).hexdigest(), index_sha256=hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), ) @@ -628,7 +628,6 @@ def _source_guard(root: Path, goal_id: str, state_file: Path, registry_path: Pat def _local_source_facts( root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, goal_ref: Mapping[str, Any] | None = None, - *, first_delivery: bool = False, ) -> dict[str, Any]: try: text = state_file.read_text(encoding="utf-8") @@ -668,17 +667,16 @@ def _local_source_facts( "source": {"state_file": str(state_file.resolve()), "runtime_root": str(root.resolve()), "authority": "legacy_markdown"}, } - if first_delivery: - from ...state_refresh import resolve_goal_state, registered_agents_for_goal + from ...state_refresh import resolve_goal_state, registered_agents_for_goal - registry = load_registry(registry_path) - goal, _, current_path = resolve_goal_state(registry=registry, goal_id=identity.goal_id, - project_override=None, state_file_override=None) - if current_path.resolve() != state_file.resolve() or identity.agent_id not in registered_agents_for_goal(goal): - raise ValueError("first delivery source or Agent registration changed; reread the original Turn") - facts["source"].update(registry_path=str(registry_path.resolve()), goal_ref=goal_ref, - registry_goal=goal) - facts["checkpoint_identity"] = identity.as_dict() + registry = load_registry(registry_path) + goal, _, current_path = resolve_goal_state(registry=registry, goal_id=identity.goal_id, + project_override=None, state_file_override=None) + if current_path.resolve() != state_file.resolve() or identity.agent_id not in registered_agents_for_goal(goal): + raise ValueError("Checkpoint source or Agent registration changed; reread the original Turn") + facts["source"].update(registry_path=str(registry_path.resolve()), goal_ref=goal_ref, + registry_goal=goal) + facts["checkpoint_identity"] = identity.as_dict() return facts @@ -724,11 +722,9 @@ def read_checkpoint_context( raise ValueError("checkpoint-context requires the original committed Turn writeback") identity = readback.identity.value continuation = checkpoint_continuation_required(readback) - if continuation and purpose != "delivery_result" and decision_scope is None: - decision_scope = "goal" - elif decision_scope is None: + if decision_scope is None: decision_scope = "agent_lane" - with _source_guard(root, goal_id, path, registry_path if first_delivery else None): + with _source_guard(root, goal_id, path, registry_path): if purpose == "first_delivery": attempt = _inspect_attempt(root, identity) if attempt.get("status") == "committed": @@ -768,7 +764,7 @@ def read_checkpoint_context( full_frontier=continuation and purpose != "delivery_result", admitted_turn=readback.heartbeat_receipt is not None, read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], - facts=_local_source_facts(root, registry_path, path, identity, goal_ref, first_delivery=first_delivery)) + facts=_local_source_facts(root, registry_path, path, identity, goal_ref)) receipt = result.pop("receipt") atomic_write_json(_receipt_path(root, identity, purpose), receipt) return {**result, "read_context_id": receipt["read_context_id"], "settlement_identity": identity.as_dict(), @@ -791,7 +787,7 @@ def checkpoint_commit_guard( """Capture/preview under source locks. The native save repeats the check under the real provider fence; this preliminary check is not the commit.""" protected = purpose != "supplement_checkpoint" - with _source_guard(runtime_root, identity.goal_id, state_file, registry_path if protected else None): + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): readback = read_heartbeat_settlement(runtime_root, goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, turn_instance_id=identity.turn_instance_id, replan_obligation_id=identity.replan_obligation_id, registry_path=registry_path, goal_ref=goal_ref, @@ -812,7 +808,6 @@ def checkpoint_commit_guard( state_file, identity, goal_ref, - first_delivery=protected, )) yield result @@ -828,16 +823,14 @@ def commit_checkpoint_run( """Handoff the held locks and parsed bytes to one native save operation.""" root = runtime_root.resolve() index = root / "goals" / identity.goal_id / "runs" / "index.jsonl" - protected = purpose != "supplement_checkpoint" - targets = (index, *((registry_path,) if protected else ()), + targets = (index, registry_path, shadow_maintenance_lock_target(root, identity.goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity.goal_id), state_file) result = _checkpoint_effect("goal.checkpoint_read_context.commit", { "runtime_root": str(root), "state_file": str(state_file.resolve()), "identity": identity.as_dict(), "purpose": purpose, "decision_scope": decision_scope, "read_context_id": read_context_id, - **({"registry_path": str(registry_path.resolve()), - "registry_sha256": hashlib.sha256(registry_path.read_bytes()).hexdigest()} - if protected else {}), + "registry_path": str(registry_path.resolve()), + "registry_sha256": hashlib.sha256(registry_path.read_bytes()).hexdigest(), "locks": [cross_runtime_lock_witness(target) for target in targets], "state_sha256": hashlib.sha256(state_file.read_bytes()).hexdigest(), "index_sha256": hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), @@ -847,7 +840,6 @@ def commit_checkpoint_run( state_file, identity, goal_ref, - first_delivery=protected, ), "refresh_retry": refresh_retry, "record": record, "index_record": index_record, "markdown": markdown, **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index cf2ed60127..d7602025a8 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -98,8 +98,10 @@ export function checkpointBasisSnapshot(request: JsonObject): JsonObject { const task = typeof todoId === "string" ? byId.get(todoId) : null; if (typeof todoId === "string" && !task) throw new EffectRuntimeRequestError("checkpoint Todo is absent; restore its authoritative record before rereading"); let frontier: JsonObject[] | null = null; + const scope = request.checkpoint_continuation === true && purpose(request) !== "delivery_result" + ? (!task || originalTurnRequestsNoFollowup(task, identity) ? "goal" : "agent_lane") + : request.decision_scope; if (purpose(request) === "first_delivery" || request.full_frontier === true) { - const scope = request.decision_scope; if (scope !== "agent_lane" && scope !== "goal") { throw new EffectRuntimeRequestError("first delivery requires agent_lane or goal decision scope"); } @@ -140,7 +142,7 @@ export function checkpointBasisSnapshot(request: JsonObject): JsonObject { source: facts.source, }; if (frontier !== null) { - basis.frontier = {scope: request.decision_scope, todos: frontier}; + basis.frontier = {scope, todos: frontier}; } if (purpose(request) !== "supplement_checkpoint") { basis.execution_lease = facts.execution_lease ?? null; @@ -225,8 +227,9 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { const projected = checkpointSnapshotOrRejection(request); if (projected.ok === false) return projected; const task = jsonObject(requireJsonObject(projected.basis, "basis").todo); + const readScope = jsonObject(requireJsonObject(projected.basis, "basis").frontier)?.scope ?? request.decision_scope; if (request.checkpoint_continuation === true && use !== "delivery_result" && - (request.full_frontier !== true || request.decision_scope !== "goal")) { + request.full_frontier !== true) { return rejected("checkpoint_direction_context_invalid"); } if (use === "first_delivery" && request.checkpoint_continuation === true && @@ -252,9 +255,9 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { ...(originalOutcome ? {original_outcome: originalOutcome} : {}), ...(use !== "delivery_result" ? {vision_authoring: visionAuthoringContract()} : {}), receipt: { schema_version: use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA, - ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: request.decision_scope} : {}), + ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: readScope} : {}), read_context_id: requireNonEmptyString(token, "read_context_id"), - ...(request.full_frontier === true ? {full_frontier: true, decision_scope: request.decision_scope} : {}), + ...(request.full_frontier === true ? {full_frontier: true, decision_scope: readScope} : {}), identity, dependency_todo_ids: ids(request.dependency_todo_ids, "dependency_todo_ids"), versions: projected.versions, provider_revision: projected.provider_revision, ...(use === "delivery_result" ? {work_basis: projected.basis} : {}), @@ -273,7 +276,7 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { if (typeof token !== "string" || !token.trim()) return rejected("checkpoint_read_context_required"); const receipt = jsonObject(request.receipt); if (request.checkpoint_continuation === true && use !== "delivery_result" && - (receipt?.full_frontier !== true || receipt.decision_scope !== "goal")) { + (receipt?.full_frontier !== true || !["agent_lane", "goal"].includes(String(receipt.decision_scope)))) { return rejected("checkpoint_direction_context_invalid"); } if (receipt?.schema_version !== (use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA) || @@ -284,7 +287,7 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) { return rejected("checkpoint_read_context_identity_mismatch"); } - if (use === "first_delivery" && request.decision_scope != null && + if (use === "first_delivery" && request.checkpoint_continuation !== true && request.decision_scope != null && request.decision_scope !== receipt.decision_scope) { return rejected("checkpoint_read_context_scope_mismatch"); } @@ -390,7 +393,9 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { const purpose = receipt.purpose ?? "supplement_checkpoint"; if (!["first_delivery", "supplement_checkpoint"].includes(String(purpose)) || receipt.schema_version !== (purpose === "first_delivery" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA) || - receipt.full_frontier !== true || receipt.decision_scope !== "goal") return rejected("checkpoint_direction_context_invalid"); + receipt.full_frontier !== true || !["agent_lane", "goal"].includes(String(receipt.decision_scope))) { + return rejected("checkpoint_direction_context_invalid"); + } if (receipt.read_context_id !== decision.read_context_id || canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(request.identity)) { return rejected("checkpoint_direction_binding_mismatch"); @@ -400,6 +405,9 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { .map(([key, value]) => [key, canonicalAuthoritySha256(value)])))) { return rejected("checkpoint_direction_basis_corrupt"); } + if (jsonObject(requireJsonObject(receipt.decision_basis, "direction basis").frontier)?.scope !== receipt.decision_scope) { + return rejected("checkpoint_direction_context_invalid"); + } const vision = jsonObject(decision.agent_vision); const unchanged = typeof decision.vision_unchanged_reason === "string" ? decision.vision_unchanged_reason.trim() : ""; if ((vision !== null) === (unchanged.length > 0)) return rejected("checkpoint_direction_decision_required"); diff --git a/loopx/control_plane/turn_driver/checkpoint_continuation.py b/loopx/control_plane/turn_driver/checkpoint_continuation.py index b984489258..6a3e1daaa9 100644 --- a/loopx/control_plane/turn_driver/checkpoint_continuation.py +++ b/loopx/control_plane/turn_driver/checkpoint_continuation.py @@ -59,10 +59,9 @@ def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first receipt = _read_context_receipt(path) try: _resolve(self.root, phase='check', identity=self.identity.as_dict(), - purpose=purpose, decision_scope='goal', checkpoint_continuation=True, + purpose=purpose, checkpoint_continuation=True, read_context_id=receipt['read_context_id'], receipt=receipt, - facts=_local_source_facts(self.root, self.registry, self.state, self.identity, - first_delivery=purpose == 'first_delivery')) + facts=_local_source_facts(self.root, self.registry, self.state, self.identity)) except CheckpointReadContextRejected as error: if error.code not in {'checkpoint_read_context_stale', 'checkpoint_read_context_unknown_or_replaced'}: raise @@ -73,7 +72,7 @@ def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first context = read_checkpoint_context(registry_path=self.registry, runtime_root_override=str(self.root), goal_id=self.identity.goal_id, agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, replan_obligation_id=self.identity.replan_obligation_id, turn_instance_id=self.identity.turn_instance_id, - purpose=purpose, decision_scope='goal') + purpose=purpose) receipt = _read_context_receipt(path) else: context = {'read_context_id': receipt['read_context_id'], 'basis': receipt['decision_basis'], diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 15dabdd1ac..5c6da7b8ad 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -1345,14 +1345,14 @@ def _typed_settlement_stage( freshness_error: CheckpointReadContextRejected | None = None - def completion_effect(callback: CompletionWriteback, effect_ref: str) -> Mapping[str, Any]: + def result_effect(callback: CompletionWriteback | Writeback, effect_ref: str) -> Mapping[str, Any]: nonlocal freshness_error try: return invoke_result_effect(callback, result, effect_ref) except CheckpointReadContextRejected as error: if error.code not in {"checkpoint_read_context_stale", "delivery_validation_binding_mismatch"}: raise - # The Todo owner rejected before commit. Let the typed settlement + # The result owner rejected before commit. Let the typed settlement # owner abort its prepared effect before reopening validation. freshness_error = error return {"ok": False, "appended": False, "reason": str(error)} @@ -1373,8 +1373,8 @@ def writeback_effect(effect_ref: str) -> Mapping[str, Any]: raise ValueError( "validated_completion requires a todo lifecycle adapter" ) - return completion_effect(completion_writeback, effect_ref) - return invoke_result_effect(writeback, result, effect_ref) + return result_effect(completion_writeback, effect_ref) + return result_effect(writeback, effect_ref) def spend_effect(effect_ref: str) -> Mapping[str, Any]: try: @@ -1410,7 +1410,7 @@ def spend_effect(effect_ref: str) -> Mapping[str, Any]: terminal_checkpoint = None if terminal_closeout_required: assert terminal_closeout is not None - terminal_effect = partial(completion_effect, terminal_closeout) + terminal_effect = partial(result_effect, terminal_closeout) terminal_checkpoint = journal_adapter.checkpoint_terminal settlement_result = execute_turn_driver_settlement( diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 7c764ef7f8..044737fceb 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -512,7 +512,7 @@ function providerSteps( const purpose = request.delivery_read_context_id || request.current_agent_checkpoint ? "first_delivery" : "delivery_result"; const context: ProviderStep = {step_kind: purpose === "delivery_result" ? "delivery_context" : "direction_context", args: ["checkpoint-context", "--goal-id", request.goal_id, "--agent-id", request.agent_id, - "--todo-id", request.todo_id, "--turn-instance-id", turnId, "--purpose", purpose, "--decision-scope", "goal", + "--todo-id", request.todo_id, "--turn-instance-id", turnId, "--purpose", purpose, ...(request.goal_instance_id ? ["--goal-instance-id", request.goal_instance_id] : [])], legacy_args: null, continue_when: null}; return [...steps.slice(0, purpose === "first_delivery" ? 2 : 1), context]; diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index e383b378db..e0b0d87af6 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1537,7 +1537,7 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): goal_ref=goal_ref, source_admission=source_admission, purpose=checkpoint_purpose, - decision_scope="goal" if default_checkpoint else normalized_progress_scope if first_delivery_checkpoint else None, + decision_scope=normalized_progress_scope if first_delivery_checkpoint and not default_checkpoint else None, )) for projection in (record, index_record, payload): if result_work_checkpoint: @@ -1615,7 +1615,7 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): markdown=render_state_refresh_markdown(payload) + "\n", goal_ref=goal_ref, source_admission=source_admission, purpose=checkpoint_purpose, read_context_id=work_read_id, - decision_scope="goal" if default_checkpoint else normalized_progress_scope if first_delivery_checkpoint else None) + decision_scope=normalized_progress_scope if first_delivery_checkpoint and not default_checkpoint else None) for projection in (record, index_record, payload): if result_work_checkpoint: projection["result_work_context"] = saved["context"] diff --git a/tests/control_plane/test_default_checkpoint_continuation.py b/tests/control_plane/test_default_checkpoint_continuation.py index 7ce9c68dad..76cd731a1f 100644 --- a/tests/control_plane/test_default_checkpoint_continuation.py +++ b/tests/control_plane/test_default_checkpoint_continuation.py @@ -3,7 +3,6 @@ import json import sys -import subprocess import pytest from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime @@ -33,7 +32,7 @@ def _fixture(provider, tmp_path, monkeypatch, *, validation_command_json=None): @pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('terminal', [False, True, 'stored', 'cold_explicit']) -def test_default_mcp_uses_post_result_basis_and_original_turn(provider, terminal, tmp_path, monkeypatch): +def test_default_mcp_uses_scoped_post_result_basis_and_original_turn(provider, terminal, tmp_path, monkeypatch): registry, state, todo = _fixture(provider, tmp_path, monkeypatch) if terminal == 'stored': update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=todo, role='agent', @@ -49,7 +48,7 @@ def test_default_mcp_uses_post_result_basis_and_original_turn(provider, terminal assert first['settlement_identity'] == original context = first['context'] assert context['basis']['todo']['status'] == 'done' - assert context['basis']['frontier']['scope'] == 'goal' + assert context['basis']['frontier']['scope'] == ('goal' if terminal else 'agent_lane') # The tool caller authors only after seeing the returned post-result facts. second = json.loads(_control(registry).complete_task(todo, AGENT_ID, 'Candidate verified.', **({} if terminal == 'cold_explicit' else options), @@ -156,7 +155,7 @@ def test_managed_default_reuses_candidate_and_calls_current_agent_for_direction( @pytest.mark.parametrize('iteration_context', ['fresh', 'resume']) -def test_default_dsh_continues_the_original_agent_session(iteration_context, tmp_path, monkeypatch): +def test_default_dsh_continues_original_session_with_agent_lane_direction(iteration_context, tmp_path, monkeypatch): registry, state, _ = _fixture('file', tmp_path, monkeypatch) project = state.parent runner = project / 'runner.py' @@ -167,7 +166,7 @@ def run_dsh_turn(**kwargs): f.write(json.dumps({'session':kwargs['session_id'],'direction':direction})+'\\n') if direction: r=json.loads(prompt.split('\\n',1)[1]); c=r['checkpoint_context'] - assert c['basis']['frontier']['scope']=='goal' + assert c['basis']['frontier']['scope']=='agent_lane' assert pathlib.Path(kwargs['workspace'],'candidate.txt').read_text()=='valid' return json.dumps({'read_context_id':c['read_context_id'], 'vision_unchanged_reason':'Current evidence preserves this session route.'}) @@ -294,3 +293,105 @@ def test_managed_noncompletion_uses_original_outcome_or_exemption(kind, tmp_path rows = [json.loads(line) for line in (tmp_path / 'runtime/goals' / GOAL_ID / 'runs/index.jsonl').read_text(encoding='utf-8').splitlines()] assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == (0 if kind == 'wait' else 1) assert not any(row.get('completion_todo_id') == todo for row in rows) + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('change_at', ['work', 'validation']) +def test_managed_progress_revalidates_retained_candidate_on_original_turn( + provider, change_at, tmp_path, monkeypatch, +): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) + project, runtime = state.parent, tmp_path / 'runtime' + host = project / 'host.py' + host.write_text(f'''import json,pathlib,sys +r=json.load(sys.stdin) +candidate=pathlib.Path('candidate.txt') +if r.get('phase') == 'checkpoint_direction': + c=r['checkpoint_context'] + json.dump({{'read_context_id':c['read_context_id'], + 'vision_unchanged_reason':'The fresh requirements preserve the remaining route.'}},sys.stdout) +else: + if r.get('result_revalidation'): + assert candidate.read_text() == 'retained candidate' + assert r['result_revalidation']['candidate']['summary'] == 'Candidate verified.' + assert r['delivery_result_context']['read_context_id'] != r['result_revalidation']['original_context']['read_context_id'] + pathlib.Path('revalidated.txt').write_text(r['delivery_result_context']['read_context_id']) + else: + assert not candidate.exists() + candidate.write_text('retained candidate') + if {change_at == 'work'!r}: + state=pathlib.Path({str(state)!r}) + state.write_text(state.read_text()+'\\n## Revised acceptance\\nRecheck the retained candidate.\\n') + json.dump({{'schema_version':'loopx_turn_result_v0','turn_key':r['turn_key'], + 'result_kind':'validated_progress','completed_phases':['host_execute','typed_result'], + 'classification':'candidate_verified','recommended_action':'Review remaining work.', + 'next_action':'Review remaining work.','delivery_batch_scale':'implementation', + 'delivery_outcome':'outcome_progress','summary':'Candidate verified.', + 'vision_unchanged_reason':'Original candidate route.'}},sys.stdout) +''', encoding='utf-8') + validator = project / 'validate.py' + validator.write_text(f'''import pathlib +assert pathlib.Path('candidate.txt').read_text() == 'retained candidate' +marker=pathlib.Path('validated.txt') +if not marker.exists(): + marker.touch() + if {change_at == 'validation'!r}: + state=pathlib.Path({str(state)!r}) + state.write_text(state.read_text()+'\\n## Revised acceptance\\nRecheck the retained candidate.\\n') +''', encoding='utf-8') + args = ('turn', 'run-once', '--host', 'generic-cli', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--project', str(project), '--host-adapter-command-json', + json.dumps([sys.executable, str(host)]), '--validation-command-json', + json.dumps([sys.executable, str(validator)]), '--no-global-sync', '--execute') + rc, first = _run_cli(registry, *args) + assert rc == 1 and first.get('result_kind') == 'validation_failed', first + journal_path = next((runtime / 'goals' / GOAL_ID / 'turns').glob('*.json')) + journal = json.loads(journal_path.read_text()) + assert journal['validation_stage'] == 'result_freshness' + assert journal['result_revalidation']['candidate']['summary'] == 'Candidate verified.' + assert not journal.get('effect_attempts'), 'Definitive rejection must abort its prepared effect' + assert first['effects']['state_written'] is False and first['effects']['quota_spent'] is False + identity = journal['result_work_context']['settlement_identity'] + if provider == 'sqlite': + rc, reread = _run_cli(registry, 'checkpoint-context', '--goal-id', GOAL_ID, + '--agent-id', AGENT_ID, '--todo-id', todo, '--turn-instance-id', identity['turn_instance_id'], + '--purpose', 'delivery_result') + assert rc == 0, reread + rc, resumed = _run_cli(registry, *args, '--retry-failed-turn', '--resume-turn-key', first['resume_turn_key']) + assert rc == 0 and resumed['status'] == 'committed', resumed + settled = json.loads(journal_path.read_text()) + assert settled['result_work_context']['settlement_identity'] == identity + assert (project / 'revalidated.txt').exists() + rows = [json.loads(line) for line in (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_text().splitlines()] + assert sum(row.get('classification') == 'quota_slot_spent' for row in rows) == 1 + rc, replay = _run_cli(registry, *args, '--retry-failed-turn', '--resume-turn-key', first['resume_turn_key']) + assert rc == 0 and replay['replayed'] is True, replay + assert (project / 'candidate.txt').read_text() == 'retained candidate' + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('terminal', [False, True]) +def test_default_direction_scopes_independent_peer_changes(provider, terminal, tmp_path, monkeypatch): + registry, _, todo = _fixture(provider, tmp_path, monkeypatch) + document = json.loads(registry.read_text()) + document['goals'][0]['coordination']['registered_agents'].append('independent-peer') + registry.write_text(json.dumps(document), encoding='utf-8') + peer = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role='agent', + text='Independent peer work.', task_class='advancement_task', claimed_by='independent-peer', + continuation_policy='same_agent_non_delivery') + control = _control(registry) + assert json.loads(control.should_run())['should_run'] is True + options = {'no_follow_up': True} if terminal else {'next_agent_todo': 'Verify remaining scoped work.'} + first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options)) + assert first['stage'] == 'direction_pending', first + context = first['context'] + assert context['basis']['frontier']['scope'] == ('goal' if terminal else 'agent_lane') + update_goal_todo(registry_path=registry, goal_id=GOAL_ID, todo_id=peer['todo_id'], + role='agent', agent_id='independent-peer', note='Independent evidence updated.') + response = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', **options, + read_context_id=context['read_context_id'], vision_unchanged_reason='Own result and route remain valid.')) + if terminal: + assert response['ok'] is False and 'checkpoint_read_context_stale' in json.dumps(response), response + else: + assert response.get('settlement_complete') is True, response + assert response['settlement_identity'] == first['settlement_identity'] diff --git a/tests/control_plane/test_inflight_result_freshness.py b/tests/control_plane/test_inflight_result_freshness.py index 097f48c055..582b7022a0 100644 --- a/tests/control_plane/test_inflight_result_freshness.py +++ b/tests/control_plane/test_inflight_result_freshness.py @@ -29,6 +29,56 @@ def _runs(runtime): return {p.name: p.read_bytes() for p in (runtime / 'goals' / GOAL_ID / 'runs').glob('*') if p.is_file() and p.suffix in {'.json', '.jsonl', '.md'}} +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('change', ['revised', 'missing']) +def test_supplement_direction_keeps_primary_goal_source_fresh(provider, change, tmp_path, monkeypatch): + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + owner = project / 'GOAL.md' + owner.write_text('Initial independent owner requirement.\n', encoding='utf-8') + document = json.loads(registry.read_text()) + document['goals'][0]['authority_sources'] = [{'kind': 'goal_doc', 'path': 'GOAL.md'}] + registry.write_text(json.dumps(document), encoding='utf-8') + identity = ('--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID) + rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', *identity, cwd=project) + assert rc == 0, guard + turn = guard['delivery_result_context']['settlement_identity']['turn_instance_id'] + args = ('refresh-state', *identity, '--turn-instance-id', turn, + '--classification', 'verified_progress', '--delivery-outcome', 'outcome_progress', + '--delivery-batch-scale', 'implementation', '--delivery-boundary', 'semantic_closeout', + '--delivery-workspace-path', str(project), '--no-global-sync', '--suppress-external-sinks') + rc, result = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0 and result['vision_checkpoint']['decision'] == 'missing_required', result + read = ('checkpoint-context', *identity, '--turn-instance-id', turn) + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + assert 'Initial independent owner requirement.' in context['basis']['goal']['prose'] + assert context['basis']['goal']['primary_documents'] + before = _runs(runtime) + if change == 'missing': + owner.unlink() + else: + owner.write_text('Revised independent owner requirement.\n', encoding='utf-8') + decision = ('--vision-summary', 'Continue the freshly checked requirement route.', + '--vision-acceptance', 'The retained candidate and remaining work satisfy the current requirements.') + rc, rejected = _run_cli(registry, runtime, *args, *decision, + '--checkpoint-read-context', context['read_context_id'], cwd=project) + assert rc == 1, rejected + assert _runs(runtime) == before and _spend_run_count(runtime) == 0 + if change == 'missing': + owner.write_text('Revised independent owner requirement.\n', encoding='utf-8') + rc, current = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0 and current['read_context_id'] != context['read_context_id'], current + assert 'Revised independent owner requirement.' in current['basis']['goal']['prose'] + commit = (*args, *decision, '--checkpoint-read-context', current['read_context_id']) + rc, settled = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and settled['vision_checkpoint']['satisfied'], settled + after = _runs(runtime) + owner.unlink() + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay['idempotent_replay'], replay + assert _runs(runtime) == after and _spend_run_count(runtime) == 0 + + @pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('vision', [False, True]) def test_inflight_rejects_changed_basis_then_revalidates_same_turn(provider, vision, tmp_path, monkeypatch): diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index e0b4a515d8..66e86e3d51 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -64,6 +64,27 @@ const check = (receipt: unknown, overrides: JsonObject = {}) => evaluate({ phase: "check", identity, facts, read_context_id: "read-A", receipt, ...overrides, }); +test("current direction isolates independent peers but original terminal intent requires the whole Goal", () => { + for (const terminal of [false, true]) { + const current = structuredClone(facts); + Object.assign(current.todos[0], {status: "done", claimed_by: identity.agent_id, + completion_turn_key: identity.turn_instance_id, no_followup: terminal}); + Object.assign(current.todos[3], {role: "agent", claimed_by: "independent-peer"}); + const request = {purpose: "first_delivery", admitted_turn: true, prior: null, + checkpoint_continuation: true, full_frontier: true, decision_scope: "agent_lane"}; + const result = read({...request, facts: current}); + assert.equal(result.ok, true); + assert.equal((result.receipt as JsonObject).decision_scope, terminal ? "goal" : "agent_lane"); + const changed = structuredClone(current); + Object.assign(changed.todos[3], {note: "Independent evidence changed"}); + const checked = check(result.receipt, {...request, facts: changed}); + assert.equal(checked.ok, !terminal); + if (terminal) assert.equal(checked.error_code, "checkpoint_read_context_stale"); + Object.assign(changed.todos[1], {evidence: "Changed shared dependency"}); + assert.equal(check(result.receipt, {...request, facts: changed}).error_code, "checkpoint_read_context_stale"); + } +}); + test("only matching historical or settled progress permits another read for an unfinished result", () => { const original = read({purpose: "delivery_result", admitted_turn: true, result_only: true, prior: null}); assert.equal(original.ok, true); From ee092a8a425b4c6870f5cf7e8cb50e8872c61aaa Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 14:38:27 +0800 Subject: [PATCH 36/80] fix(chat): reread unavailable checkpoint observations without dispatch Signed-off-by: Tartar --- .../checkpoint-recovery-browser-smoke.mjs | 23 +++++++++++++++++-- .../smoke/checkpoint-recovery-http-fixture.py | 7 ++++++ .../personal-workspace/goal-loopx-mode.tsx | 18 +++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs index 3f4d61d3d1..c97df97533 100644 --- a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs @@ -91,13 +91,32 @@ try { await page.setViewportSize({width:390,height:844});await page.emulateMedia({reducedMotion:"reduce"}); await page.getByRole("button",{name:"恢复推进",exact:true}).scrollIntoViewIfNeeded(); path=resolve(out,"settled-narrow.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + const unavailable=await command("unavailable"); + assert.equal(unavailable.snapshot.coordinator_checkpoint.stage,"observation_unavailable"); + assert.equal(unavailable.snapshot.coordinator_checkpoint.settlement_identity,undefined); + await page.reload(); + await page.getByText("LoopX · 核验信息暂不可用",{exact:true}).waitFor({timeout:60000}); + assert.equal(await page.getByText("原执行尚未完成结算",{exact:false}).count(),0); + const reread=page.getByRole("button",{name:"重新读取",exact:true}); + await reread.click(); + assert.equal(writes().length,1,"Unavailable observation reread is not an execution request"); + const held=await command("inspect"); + assert.deepEqual(held.counts,{result:1,debit:1});assert.equal(held.dispatches.length,1); + path=resolve(out,"unavailable-narrow.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + await page.setViewportSize({width:1512,height:982}); + path=resolve(out,"unavailable-desktop.png");await page.screenshot({path,animations:"disabled"});screenshots.push(await artifact(path)); + await command("restore_observation"); + await page.reload(); + await page.getByRole("button",{name:"恢复推进",exact:true}).waitFor({timeout:60000}); + assert.equal(writes().length,1); const saved=await command("save"); assert.equal(writes().length,1); assert.equal(workspace.errors.length,0,workspace.errors.join("\n")); await writeFile(resolve(out,"http-observations.json"),JSON.stringify(requests,null,2)); const proof={packaged:true,target_http_mocked:false,transport:"deterministic_sdk",paid_calls:0, non_owning_shell_fixture:"existing personal-workspace navigation/status API; target sessions/loopx uses production owner", - browser_version:browser.version(),clicked_continue_review:true,reloads:2, + browser_version:browser.version(),clicked_continue_review:true,reloads:4, + unavailable_observation:{read_only_reread:true,restored:true,counts:held.counts}, original_identity,settled_identity:saved.settled_identity,replayed_identity:replayed.replayed_identity, result_sha256_before:before.result_sha256,result_sha256_after:after.result_sha256, business_counts_before:before.counts,business_counts_after:after.counts,business_counts_replayed:replayed.counts, @@ -105,7 +124,7 @@ try { session_id_before:before.session_id,session_id_after:saved.session_id, upstream_thread_id_before:before.upstream_thread_id,upstream_thread_id_after:saved.upstream_thread_id, errors:workspace.errors,screenshots,native_evidence:[]}; - for(const name of ["before.json","first-result.json","inspect.json","replay.json","save.json"]) + for(const name of ["before.json","first-result.json","inspect.json","replay.json","save.json","unavailable.json","restore_observation.json"]) proof.native_evidence.push(await artifact(resolve(out,"backend",name))); proof.native_evidence.push(await artifact(resolve(out,"http-observations.json"))); await writeFile(resolve(out,"proof.json"),JSON.stringify(proof,null,2)); diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py index a2f8d21b69..1873c29554 100644 --- a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py @@ -146,6 +146,13 @@ def observed_send(handler,body,*,status=200): assert not failures,failures value={**native_snapshot(),'http':http,'original_identity':original, 'settled_identity':decisions[0]['settlement']['settlement_identity']} + elif command in {'unavailable', 'restore_observation'}: + damaged=runtime_root/'goals'/goal_id/'checkpoint-contexts'/('f'*64+'.json') + if command=='unavailable': + damaged.write_text('{',encoding='utf-8') + else: + damaged.unlink() + value={'snapshot':controller.loopx_mode.snapshot(sid),'native':native_snapshot()} else:raise ValueError('Unknown fixture command') (out/(command+'.json')).write_text(json.dumps(value,indent=2),encoding='utf-8') print(json.dumps(value),flush=True) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx index 52232d56ac..795438db4f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx @@ -1,6 +1,6 @@ import {ContextHelp} from "./context-help"; import {useEffect, useRef, useState} from "react"; -import {Pause, Play, Settings2, Users, X} from "lucide-react"; +import {Pause, Play, RefreshCw, Settings2, Users, X} from "lucide-react"; import {fetchLoopXMode, updateLoopXMode, type LoopXModeSnapshot, type LoopXModeSettings} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import {GoalTeamWork} from "./goal-team-work"; @@ -46,6 +46,7 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { return () => {alive = false; window.clearInterval(interval);}; }, [sessionId]); // onChange is the owning component's stable state setter. const active = Boolean(snapshot?.enabled && snapshot.active_turn_id); + const checkpointUnavailable = Boolean(snapshot?.enabled && ["observation_unavailable", "operation_unknown"].includes(snapshot.coordinator_checkpoint?.stage ?? "")); const native = snapshot?.native.status ?? "absent"; const resume = !["absent", "complete"].includes(native); const configured = Boolean(snapshot?.settings.agent_id && snapshot.settings.token_budget @@ -56,6 +57,7 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { }; const status = !snapshot?.enabled ? (zh ? "普通对话" : "Conversation") : snapshot.recovery_required ? (zh ? "LoopX · 需要恢复连接" : "LoopX · Reconnect required") + : snapshot.coordinator_checkpoint?.stage === "observation_unavailable" ? (zh ? "LoopX · 核验信息暂不可用" : "LoopX · Review information unavailable") : snapshot.coordinator_checkpoint?.stage === "operation_unknown" ? (zh ? "LoopX · 原执行状态无法核验" : "LoopX · Original execution unverified") : snapshot.coordinator_checkpoint?.result_committed ? (active ? (zh ? "LoopX · 正在核验已保留结果" : "LoopX · Reviewing retained result") : (zh ? "LoopX · 结果已保留,待协调员核验" : "LoopX · Result retained, coordinator review pending")) : native === "blocked" ? (zh ? "LoopX · 需要处理阻塞" : "LoopX · Blocked") @@ -84,6 +86,13 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { } catch (failure) {setError(failure instanceof Error ? failure.message : String(failure));} finally {setBusy(false);} } + async function rereadCheckpoint() { + if (!sessionId) return; + setBusy(true); setError(""); + try {const result = await fetchLoopXMode(sessionId); setSnapshot(result); onChange(result);} + catch (failure) {setError(failure instanceof Error ? failure.message : String(failure));} + finally {setBusy(false);} + } const needsReadback = snapshot?.deliveries.some(row => ["rejected", "unavailable"].includes(row.status)); const pendingMessages = snapshot?.ingress.filter(row => row.status !== "delivered") ?? []; return
@@ -92,15 +101,16 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: {
{configured && sessionId ? : null} - + }}>{active ? : checkpointUnavailable ? : }{active ? (zh ? "暂停协调员" : "Pause coordinator") : !snapshot?.enabled ? (zh ? "开启 LoopX 模式" : "Enable LoopX") : checkpointUnavailable ? (zh ? "重新读取" : "Reread") : snapshot.coordinator_checkpoint ? (zh ? "继续核验" : "Continue review") : native === "complete" ? (zh ? "开启新一轮" : "Start new run") : (zh ? "恢复推进" : "Continue")}
- {snapshot?.coordinator_checkpoint ?

{snapshot.coordinator_checkpoint.stage === "operation_unknown" ? (zh ? "原执行状态暂不可核验;恢复连接后重读,不能据此重新派发任务。" : "Original execution cannot be verified. Reconnect and reread before dispatching any work.") : (zh ? "原执行尚未完成结算;继续核验后,协调员会读取当前事实并核验下一步。" : "Original execution is awaiting settlement. Continue review lets the coordinator read current facts and review the next step.")}

: null} + {snapshot?.coordinator_checkpoint ?

{snapshot.coordinator_checkpoint.stage === "observation_unavailable" ? (zh ? "核验信息读取失败;请恢复来源后重新读取。" : "Review information could not be read. Restore its source, then reread.") : snapshot.coordinator_checkpoint.stage === "operation_unknown" ? (zh ? "原执行状态暂不可核验;恢复连接后重读,不能据此重新派发任务。" : "Original execution cannot be verified. Reconnect and reread before dispatching any work.") : (zh ? "原执行尚未完成结算;继续核验后,协调员会读取当前事实并核验下一步。" : "Original execution is awaiting settlement. Continue review lets the coordinator read current facts and review the next step.")}

: null} {pendingMessages.length ?

{zh ? "待处理消息:" : "Pending messages: "}{pendingMessages.map(row => `${row.mode === "loopx_queue" ? "queue" : "inbox"} · ${row.status}`).join(" / ")}

: null} {needsReadback ? : null} {(error || readError) && !panel ?

{error || readError}

: null} From 079626d427a8ac97796b681f9e5e13a84e6edab0 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 14:38:28 +0800 Subject: [PATCH 37/80] docs(checkpoint): record scoped recovery and qualification boundaries Signed-off-by: Tartar --- .../rfcs/loopx-overall-roadmap-v0.md | 9 +++++-- .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 4 ++- .../goal-vision-replan-contract-v0.md | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index cd389905a3..af5bdfd484 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -1449,11 +1449,16 @@ recovery. Legacy Markdown is read-only and cannot authorize Todo mutations; File/SQLite and PostgreSQL result transactions retain their typed head/CAS owner. See the [operating protocol](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness). New supported admissions also persist the default current-Agent checkpoint -continuation contract. Required checkpoints use the actual post-result/full -frontier read; MCP returns it to the current tool caller, while managed execution +continuation contract. Required checkpoints use the actual post-result, complete +relevant frontier read; MCP returns it to the current tool caller, while managed execution resumes its original Host/session. Noncompletion outcomes retain their original receipt and use the existing supplement owner. Related direction changes require only a new direction judgment, preserving successful results and single spend. +Definitively rejected progress writes retain their candidate for original-Turn +revalidation. Ordinary direction preserves independent Agent lanes; original +no-followup still requires the whole Goal. Supplementary direction includes +registered primary Goal documents under the same registry fence. Chat treats +unavailable observations as read failures with read-only recovery, not pending settlement. Ordinary terminal completion uses the existing same-Turn upgrade after checkpoint and spend. Historical result-only, explicit first-delivery and supplement Turns retain their persisted contract. Complete changed-frontier recovery, packaged diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index b02bf4f043..85a28d8f13 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -942,7 +942,9 @@ lifecycle。 **R1 首次交付检查点。** 新的普通 CLI/MCP/托管执行在工作前持久化原始结果依据,由现有 Todo owner 在提交时检查新鲜度。默认身份恢复未完成的 Turn;结算后才释放新的工作身份。相关 Todo、依赖、验收和来源变化要求重新验证候选结果,无关修订不会触发。成功回执支持响应丢失和冷恢复。Legacy Markdown 只读,不能授权 Todo 写入;File/SQLite 与 PostgreSQL 结果事务沿用类型化 head/CAS owner。见[操作协议](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness)。 -新的受支持准入还保存当前 Agent 的默认 checkpoint 续跑契约。需要检查点时读取结果提交后的完整任务前沿;MCP 返回给当前工具调用者,托管执行恢复原 Host/session。非完成结果保留原回执并使用现有补充 owner。相关方向变化只重做方向判断,保留成功结果和单次扣费。普通终态完成在 checkpoint 与扣费后使用同一 Turn 的升级路径。历史 result-only、显式 first-delivery 及 supplement Turn 沿用保存的契约。完整的前沿变化恢复、打包前端/Lark 旅程、混合版本回滚与真实模型效用仍是未完成的一阶段验收。Source-session 仍只支持生命周期;普通 Python PostgreSQL 执行仍缺少 service-owned factory。独立 reviewer 需要单独注册的可选契约。这些默认值不关闭 R1、不认证 Goal 完成,也不证明分布式原子性或模型收益。 +新的受支持准入还保存当前 Agent 的默认 checkpoint 续跑契约。需要检查点时读取结果提交后的完整相关任务前沿;MCP 返回给当前工具调用者,托管执行恢复原 Host/session。非完成结果保留原回执并使用现有补充 owner。相关方向变化只重做方向判断,保留成功结果和单次扣费。确定被拒的进度写回保留候选,在原 Turn 重验;普通方向保持独立 Agent lane 隔离,原 no-followup 仍要求全 Goal。补充方向在相同 registry fence 下包含注册的主 Goal 文档;Chat 将观察不可用显示为读取失败并提供只读恢复,不称为待结算。普通终态完成在 checkpoint 与扣费后使用同一 Turn 的升级路径。历史 result-only、显式 first-delivery 及 supplement Turn 沿用保存的契约。完整的前沿变化恢复、打包前端/Lark 旅程、混合版本回滚与真实模型效用仍是未完成的一阶段验收。Source-session 仍只支持生命周期;普通 Python PostgreSQL 执行仍缺少 service-owned factory。独立 reviewer 需要单独注册的可选契约。这些默认值不关闭 R1、不认证 Goal 完成,也不证明分布式原子性或模型收益。 + +恢复资格还覆盖原结果提交后工作读取回执丢失或损坏、已有进度写回证明的历史 result-only 终态重验,以及 Turn 结算后尚未完成的委派 Todo。native index 在方向补充之后仍证明原工作记录。sealed 生命周期恢复按 canonical store 的时间精度比较关闭时刻,保留同秒准入/关闭,不采纳旧终态工作。未知提交和身份不匹配继续拒绝。打包 Chat 恢复烟测复用现有同源 relay 和隔离 Host profile;确定性重放证据不认证 installed Lark、混版本回滚或真实模型效用。 **R1 事务检查点。** 团队计划准入与整批规划现在归 `work_items/team_plan.ts`。确认后,全部已准入 lane 与持久操作回执一次提交;身份由 proposal + lane 决定,不再由 Todo 文本决定。File/SQLite 权威复用现有 CAS 与回执 owner;legacy Markdown 在原有 fence 和锁内同时写入任务和不可变回执。同一操作重试只读历史结果,接收者后来修改、完成或删除任务也不会触发重建。提交前失败不会留下部分 lane;canonical 展示投递仍 pending 时,Chat 必须恢复后才能报告验证成功。卡片列出部分分配及缺口;quota/stop 是参考,显式强制声明会被拒绝。Agent 发起的结算在 journal 首次写入时绑定同一状态基线并在结算时重读;基线缺失或已变动、或全部 lane 均为缺口的计划,记录为类型化的失败回执,不创建 Todo,重放结果不变。 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index e2263a9be2..14f84c4199 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -1180,6 +1180,23 @@ original completion arguments; the facade does not infer that intent from a new frontier or a replacement Agent. This does not certify Goal completion or supply the complete changed-frontier scheduling contract. +A definitive pre-commit freshness rejection of a progress writeback uses the same +prepared-effect abort and retained-candidate recovery as completion. Cold retry +revalidates that candidate against the new basis on its original Turn; an +unknown effect is not treated as a proved rejection. + +The typed owner selects the complete relevant Agent lane and dependency closure +for an ordinary current-Agent direction checkpoint. Independent peer-only updates +do not invalidate it. Original no-followup intent still requires the complete +Goal frontier, regardless of a caller's narrower scope. Supplementary direction +reads and commits include the same registered primary Goal documents and registry +fence; changing or losing a document requires recovery before a new decision can +commit. Exact committed receipt replay retains its historical result. + +An unavailable observation without an original settlement identity is not a +pending settlement. Packaged Chat shows that read failure and offers a read-only +reread; restoring its source does not itself dispatch or spend. + Historical receipts without this mode retain their prior contract, including result-only and already-spent missing-checkpoint supplement recovery. Unknown modes and downgrade attempts fail closed. The explicit `first_delivery` v2 @@ -1351,6 +1368,14 @@ HTTP/controller 和原生 checkpoint,使用确定性 SDK,保留会话与原 Desktop 渲染仍未验证,不能从 status/quota 投影存在推断这些旅程已通过, 也不能据此关闭 Goal。 +进度写回在提交前收到确定的 freshness 拒绝时,与完成写回共用预备效果撤销及 +候选保留路径。冷恢复在原 Turn 上按新依据重验候选;未知效果不能当作已证明 +未提交。普通当前 Agent 方向检查读取完整相关 lane 和依赖闭包,无关 peer 更新 +不使其失效;原 no-followup 意图仍要求全 Goal 前沿,caller 不能缩小该范围。 +补充方向与结果方向共用注册的主 Goal 文档和 registry fence,文档变化或缺失 +先拒绝新提交,已提交的精确回执仍按历史恢复。没有原结算身份的观察不可用不等于 +结算待完成;打包 Chat 如实显示读取失败并提供只读重读,来源恢复不自动派发或扣额。 + ### Recovery checks and support boundary From 88467ff5a514716feced965a723fb84946fbbb7d Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 16:44:33 +0800 Subject: [PATCH 38/80] fix(checkpoint): isolate verified settled reads across delivery and observation Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 16 +++ .../control_plane/effect_runtime_handlers.ts | 1 + .../control_plane/goals/checkpoint_commit.ts | 66 ++++++++++- .../goals/checkpoint_context_io.py | 35 +++++- .../control_plane/host_checkpoint_fixture.py | 9 +- .../test_checkpoint_history_isolation.py | 107 ++++++++++++++++++ .../test_host_checkpoint_fixture.py | 23 ++++ .../checkpoint_provider_head.test.ts | 71 +++++++++++- 8 files changed, 316 insertions(+), 12 deletions(-) create mode 100644 tests/control_plane/test_checkpoint_history_isolation.py create mode 100644 tests/control_plane/test_host_checkpoint_fixture.py diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 14f84c4199..6bb81b878c 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -1327,6 +1327,22 @@ rather than a later direction row. Pending current checkpoint lanes retain exact writeback replay; missing or conflicting commit proof stays fail-closed. An unclaimed successor must be claimed before its work admission. +An unreadable historical read cannot block an independent checkpoint when its +digest-named path is attributable to an exact indexed effect and that effect's +JSON/Markdown artifacts pass the existing receipt-first verification. Inspection +does not rewrite the old read. A work-only proof establishes only the work read, +never a pending direction append. Unknown paths, incomplete indexes, missing or +conflicting artifacts and unindexed direction attempts remain fail-closed. +Status and packaged UI observations use the same native proof to recover the +original binding; a verified direction never erases pending quota accounting. + +损坏的历史读取回执只有在路径能绑定到精确索引效果、且对应 JSON/Markdown +工件通过既有回执优先核验时,才不再阻断独立 checkpoint;检查不会改写旧回执。 +仅有结果依据证明不能证明方向提交。未知路径、不完整索引、缺失或冲突工件, +以及未索引的方向提交仍保持拒绝。 +状态和 packaged UI 读回复用同一原生证明恢复原身份;已核验的方向不会抹掉尚未 +完成的配额结算。 + ### 中文:本阶段边界与恢复 普通新执行默认保存结果依据并在 Todo owner 提交时检查新鲜度。 diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index e94e42a990..edce5bfb73 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -371,6 +371,7 @@ export function createEffectRuntimeHandlers( ["goal.checkpoint_read_context.commit", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({commitCheckpoint}) => params => commitCheckpoint(params, context.authorityProvider))], ["goal.checkpoint_read_context.inspect_replay", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointReplay}) => inspectCheckpointReplay)], ["goal.checkpoint_read_context.inspect_attempt", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointAttempt}) => inspectCheckpointAttempt)], + ["goal.checkpoint_read_context.inspect_history", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointReadHistory}) => inspectCheckpointReadHistory)], ["goal.vision_wait.coverage", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectVisionWaitCoverage}) => projectVisionWaitCoverage)], ["goal.vision_wait.project", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectGoalVisionWaitState}) => projectGoalVisionWaitState)], ["goal.shared_goal_alignment.project", lazyHandler(() => import("./goals/shared_goal_alignment.ts"), ({projectSharedGoalAlignment}) => projectSharedGoalAlignment)], diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index b66f53a61c..6dbc6d8055 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -129,6 +129,53 @@ function writeReceipt(path: string, receipt: JsonObject): void { renameSync(temporary, path); } +function checkpointReceiptName(effect: string, work: boolean): string { + return createHash("sha256").update(effect + (work ? ":delivery_result" : "")).digest("hex") + ".json"; +} + +function indexedReadProof(rows: JsonObject[], goal: string, name: string): {row: JsonObject; purpose: string} | null { + for (const row of rows) { + const binding = jsonObject(row.settlement_identity); + if (binding?.goal_id !== goal) continue; + const effect = binding.effect_id; + if (typeof effect !== "string") continue; + const direction = jsonObject(row.vision_checkpoint)?.satisfied === true && + jsonObject(jsonObject(row.vision_checkpoint)?.read_context) !== null; + if (direction && name === checkpointReceiptName(effect, false)) return {row, purpose: "first_delivery"}; + if ((direction || row.result_work_context != null) && name === checkpointReceiptName(effect, true)) { + return {row, purpose: row.result_work_context != null ? "delivery_result" : "first_delivery"}; + } + } + return null; +} + +/** Observation only: recover bindings from verified indexed effects, never from + * unreadable bytes. Pending accounting remains visible under the recovered identity. */ +export function inspectCheckpointReadHistory(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint read history inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const goal = goalPathSegment(request.goal_id); + const rows = indexBytes(join(root, "goals", goal, "runs", "index.jsonl")) + .toString("utf8").split(/\r?\n/).filter(line => line.trim()).map(line => JSON.parse(line) as JsonObject); + if (!Array.isArray(request.receipt_names)) throw new EffectRuntimeRequestError("receipt_names must be an array"); + const verified: JsonObject[] = []; + for (const name of request.receipt_names) { + if (typeof name !== "string" || !name.endsWith(".json") || !BARE_SHA256_PATTERN.test(name.slice(0, -5))) { + throw new EffectRuntimeRequestError("invalid checkpoint receipt name"); + } + const proof = indexedReadProof(rows, goal, name); + if (!proof) continue; + try { + const result = inspectCheckpointAttempt({runtime_root: root, identity: proof.row.settlement_identity, + purpose: proof.purpose}); + if (result.status === "committed") verified.push({receipt_name: name, identity: proof.row.settlement_identity}); + } catch (error) { + if (!(error instanceof EffectRuntimeRequestError) || error.code !== "checkpoint_commit_unknown") throw error; + } + } + return {ok: true, verified}; +} + /** Caller holds the index lock. Unindexed artifacts never prove a committed * direction. A completely absent attempt may be retried; torn writes stay held. */ export function inspectCheckpointAttempt(value: unknown): JsonObject { @@ -143,7 +190,7 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { (work ? row.result_work_context != null : jsonObject(row.vision_checkpoint)?.satisfied === true && jsonObject(row.vision_checkpoint)?.read_context != null)); const path = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts", - `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id") + (work ? ":delivery_result" : "")).digest("hex")}.json`); + checkpointReceiptName(requireNonEmptyString(identity.effect_id, "effect_id"), work)); let receipt: JsonObject | null = null; let receiptError: unknown; try { receipt = requireJsonObject(JSON.parse(readFileSync(path, "utf8")), "read receipt"); } @@ -168,16 +215,25 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { let names: string[] = []; try { names = readdirSync(contexts); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - const indexed = new Set(indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) - .filter(line => line.trim()).map(line => jsonObject(JSON.parse(line).settlement_identity)?.effect_id)); - const resultReadName = `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id") + ":delivery_result").digest("hex")}.json`; + const indexed = new Set(rows.map(row => jsonObject(row.settlement_identity)?.effect_id)); + const resultReadName = checkpointReceiptName(requireNonEmptyString(identity.effect_id, "effect_id"), true); for (const name of names.filter(name => name.endsWith(".json") && BARE_SHA256_PATTERN.test(name.slice(0, -5)))) { // This disjoint path can hold only the original work read, never a direction append. // Losing it cannot erase the successful result or create direction uncertainty. if (name === resultReadName) continue; let other: JsonObject; try { other = requireJsonObject(JSON.parse(readFileSync(join(contexts, name), "utf8")), "read receipt"); } - catch { unknown("checkpoint read receipt is unavailable; preserve original Turn artifacts before retrying"); } + catch { + const proof = indexedReadProof(rows, goalPathSegment(identity.goal_id), name); + if (proof) { + // Reuse receipt-first artifact verification without scanning other reads. + // A work proof alone cannot establish that a direction append committed. + const verified = inspectCheckpointAttempt({runtime_root: root, identity: proof.row.settlement_identity, + purpose: proof.purpose}); + if (verified.status === "committed") continue; + } + unknown("checkpoint read receipt is unavailable; preserve original Turn artifacts before retrying"); + } const owner = jsonObject(other.identity); if (other.purpose === "first_delivery" && other.commit_attempt != null && owner?.effect_id !== identity.effect_id && owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id && !indexed.has(owner?.effect_id)) { diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 84aaabde15..fd50600b51 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -38,7 +38,8 @@ def _checkpoint_effect(method: str, request: dict[str, Any]) -> Any: # RPC wire. Only this locked local checkpoint path opts into the exact, # digest-bound same-UID snapshot transport; default effects stay bounded. return effect_runtime_result(method, request, - large_local_snapshot=method != "goal.checkpoint_read_context.inspect_attempt") + large_local_snapshot=method not in ("goal.checkpoint_read_context.inspect_attempt", + "goal.checkpoint_read_context.inspect_history")) except EffectRuntimeRejected as error: raise CheckpointReadContextRejected({"ok": False, "error": str(error), "error_code": error.diagnostic_code, "reread_required": False}) from error @@ -434,21 +435,32 @@ def first_delivery_progress( committed = False selected: dict[str, Any] = {} unknown = observation_unknown + read_unavailable = False try: if not first_delivery_context_enrolled(root, identity) and not checkpoint_continuation_required(readback): return None except (ValueError, OSError): - unknown = True + read_unavailable = True try: if canonical_todos is None: canonical_todos = _delivery_observation_todos(root, identity.goal_id, registry_path) selected = next((todo for todo in canonical_todos if todo["todo_id"] == identity.todo_id), {}) committed = selected.get("status") == "done" and selected.get("completion_turn_key") in {identity.effect_id, identity.turn_instance_id} + except (ValueError, OSError): + unknown = True + try: direction_path = _receipt_path(root, identity) direction = _read_context_receipt(direction_path) if direction_path.exists() else {} except (ValueError, OSError): direction = {} - unknown = True + read_unavailable = True + if read_unavailable: + try: + proof = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(root.resolve()), "identity": identity.as_dict(), "purpose": "first_delivery"}) + unknown = unknown or proof.get("status") != "committed" + except (ValueError, OSError): + unknown = True progress = effect_runtime_result("turn.first_delivery.evaluate", { "phase": "project", "result_committed": committed, "checkpoint_continuation": checkpoint_continuation_required(readback), @@ -490,8 +502,9 @@ def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | No *, registry_path: Path | None = None) -> dict[str, Any] | None: """Isolate historical observation faults; admission still uses strict reads. - An unreadable digest cannot be assigned to an Agent. Keep that Goal-scoped - uncertainty visible without replacing another lane's normal next action. + An unreadable digest without verified indexed proof cannot be assigned to + an Agent. Keep that Goal-scoped uncertainty visible without replacing + another lane's normal next action; proved bindings retain pending accounting. """ identities = {} errors = [] @@ -516,6 +529,18 @@ def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | No errors.append({"scope": "goal", "code": "checkpoint_receipt_invalid", "receipt": path.stem}) continue identities[identity.effect_id] = identity.as_dict() + if errors: + try: + recovered = _checkpoint_effect("goal.checkpoint_read_context.inspect_history", { + "runtime_root": str(root.resolve()), "goal_id": goal_id, + "receipt_names": [error["receipt"] + ".json" for error in errors]}) + for item in recovered["verified"]: + identity = SettlementIdentity.from_runtime_payload(item["identity"]) + errors = [error for error in errors if error["receipt"] + ".json" != item["receipt_name"]] + if agent_id is None or identity.agent_id == agent_id: + identities[identity.effect_id] = identity.as_dict() + except (ValueError, OSError): + pass # Unproved history remains explicitly unavailable. # Share the complete current authority snapshot across observed identities. # Do not cap the historical scan or silently lose older pending work. canonical_todos = None diff --git a/tests/control_plane/host_checkpoint_fixture.py b/tests/control_plane/host_checkpoint_fixture.py index 5d2e1c20d4..4a4e72e82e 100644 --- a/tests/control_plane/host_checkpoint_fixture.py +++ b/tests/control_plane/host_checkpoint_fixture.py @@ -9,7 +9,14 @@ def checkpoint_decision(context): basis = context["basis"] assert context["read_context_id"] - assert basis["frontier"]["scope"] == "goal" + todo = basis.get("todo") + identity = context["settlement_identity"] + deferred_terminal = bool(todo and todo.get("status") == "done" + and todo.get("completion_continuation") == "active_goal" + and todo.get("completion_requested_continuation") == "no_followup" + and todo.get("completion_turn_key") in {identity["effect_id"], identity["turn_instance_id"]}) + terminal = todo is None or todo.get("no_followup") is True or deferred_terminal + assert basis["frontier"]["scope"] == ("goal" if terminal else "agent_lane") decision = {"read_context_id": context["read_context_id"], "agent_vision_json": "", "vision_unchanged_reason": "", "stop_reason": ""} if basis["agent_vision"]: diff --git a/tests/control_plane/test_checkpoint_history_isolation.py b/tests/control_plane/test_checkpoint_history_isolation.py new file mode 100644 index 0000000000..49eda3c8a3 --- /dev/null +++ b/tests/control_plane/test_checkpoint_history_isolation.py @@ -0,0 +1,107 @@ +"""Indexed settled history cannot poison another Agent's original delivery.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys + +import pytest + +from loopx.control_plane.goals.checkpoint_context_io import _checkpoint_effect, _receipt_path, pending_first_delivery_progress +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane +from loopx.todos import add_goal_todo +from tests.control_plane.test_default_checkpoint_continuation import _fixture +from tests.test_goal_mode_mcp_settlement import ( + AGENT_ID, GOAL_ID, _complete_default_task, _control, _run_cli, +) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("purpose", ["first_delivery", "delivery_result"]) +def test_settled_peer_read_damage_preserves_independent_delivery(provider, purpose, tmp_path, monkeypatch): + registry, _, first_todo = _fixture(provider, tmp_path, monkeypatch) + peer = "independent-peer" + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["coordination"]["registered_agents"].append(peer) + registry.write_text(json.dumps(registration), encoding="utf-8") + first = _control(registry) + assert json.loads(first.should_run()).get("delivery_result_context") + settled = json.loads(_complete_default_task(first, first_todo, AGENT_ID, + "Independent candidate verified.", next_agent_todo="Verify remaining independent work.")) + assert settled.get("settlement_complete") is True, settled + runtime = tmp_path / "runtime" + old = SettlementIdentity.from_runtime_payload(settled["settlement_identity"]) + proof = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + # Todo work reads are committed by the Todo owner; its later indexed + # direction proves the completed history for either disposable read. + "runtime_root": str(runtime), "identity": old.as_dict(), "purpose": "first_delivery", + }) + assert proof["status"] == "committed", proof + artifacts = {Path(proof[key]): Path(proof[key]).read_bytes() for key in ("json_path", "markdown_path")} + damaged = _receipt_path(runtime, old, purpose) + damaged.write_text("{", encoding="utf-8") + # This is the shared status and packaged UI readback, not only admission. + assert pending_first_delivery_progress(runtime, GOAL_ID, peer, registry_path=registry) is None + assert pending_first_delivery_progress(runtime, GOAL_ID, AGENT_ID, registry_path=registry) is None + + todo = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", claimed_by=peer, + text="Verify the independent peer candidate.", task_class="advancement_task", + continuation_policy="same_agent_non_delivery")["todo_id"] + rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", peer, + "--vision-summary", "Verify the peer route.", "--vision-acceptance", "Verified peer candidate.", + "--no-global-sync", "--suppress-external-sinks") + assert rc == 0, baseline + args = ("quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", peer, + "--todo-id", todo, "--runtime-profile", "claude_code") + rc, guard = _run_cli(registry, *args) + assert rc == 0 and guard.get("delivery_result_context"), guard + identity = guard["delivery_result_context"]["settlement_identity"] + rc, selected = _run_cli(registry, *args, "--turn-instance-id", identity["turn_instance_id"]) + assert rc == 0 and selected["interaction_contract"]["agent_channel"]["delivery_allowed"] is True, selected + control = GoalModeMCPControlPlane(GoalModeMCPConfig(server_name="fixture-peer", + runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": GOAL_ID, "registry": str(registry), "agent_id": peer}) + control.command_prefix = lambda: [sys.executable, "-m", "loopx.cli"] + result = json.loads(_complete_default_task(control, todo, peer, "Peer candidate verified.", + next_agent_todo="Verify remaining peer work.")) + assert result.get("settlement_complete") is True, result + assert result["settlement_identity"] == identity + assert pending_first_delivery_progress(runtime, GOAL_ID, peer, registry_path=registry) is None + assert damaged.read_text(encoding="utf-8") == "{" + assert all(path.read_bytes() == contents for path, contents in artifacts.items()) + rows = [json.loads(line) for line in (runtime / "goals" / GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + spends = [row for row in rows if row.get("classification") == "quota_slot_spent"] + assert len(spends) == 2 + assert sum(row["settlement_identity"]["effect_id"] == identity["effect_id"] for row in spends) == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_recovered_history_retains_original_unspent_accounting(provider, tmp_path, monkeypatch): + registry, _, todo = _fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + guard = json.loads(control.should_run()) + identity = SettlementIdentity.from_runtime_payload(guard["delivery_result_context"]["settlement_identity"]) + run_cli = control.run_cli + + def stop_before_spend(args, **kwargs): + if args[:2] == ["quota", "spend-slot"]: + return json.dumps({"ok": False, "error": "Synthetic stop before accounting."}) + return run_cli(args, **kwargs) + + control.run_cli = stop_before_spend + result = json.loads(_complete_default_task(control, todo, AGENT_ID, "Candidate verified.", + next_agent_todo="Verify remaining independent work.")) + assert result.get("settlement_complete") is not True, result + runtime = tmp_path / "runtime" + proof = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(runtime), "identity": identity.as_dict(), "purpose": "first_delivery"}) + assert proof["status"] == "committed", proof + for purpose in ("first_delivery", "delivery_result"): + _receipt_path(runtime, identity, purpose).write_text("{", encoding="utf-8") + progress = pending_first_delivery_progress(runtime, GOAL_ID, AGENT_ID, registry_path=registry) + assert progress["stage"] == "settlement_pending", progress + assert progress["settlement_identity"] == identity.as_dict() + assert progress["result_committed"] is True and progress["quota_spent"] is False + rows = [json.loads(line) for line in (runtime / "goals" / GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + assert not any(row.get("classification") == "quota_slot_spent" for row in rows) diff --git a/tests/control_plane/test_host_checkpoint_fixture.py b/tests/control_plane/test_host_checkpoint_fixture.py new file mode 100644 index 0000000000..912b3ad08e --- /dev/null +++ b/tests/control_plane/test_host_checkpoint_fixture.py @@ -0,0 +1,23 @@ +"""The fixture oracle keeps ordinary lane and original terminal intent distinct.""" +import pytest + +from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + + +@pytest.mark.parametrize("intent", ["ordinary", "stored", "deferred", "other-turn", "goal-wide"]) +def test_fixture_checks_the_original_direction_scope(intent): + identity = {"effect_id": "original-effect", "turn_instance_id": "original-turn"} + todo = {"status": "done", "completion_continuation": "active_goal"} + if intent == "stored": + todo["no_followup"] = True + elif intent in {"deferred", "other-turn"}: + todo.update(completion_requested_continuation="no_followup", + completion_turn_key="other-turn" if intent == "other-turn" else identity["effect_id"]) + terminal = intent in {"stored", "deferred", "goal-wide"} + context = {"read_context_id": "fresh-read", "settlement_identity": identity, + "basis": {"todo": None if intent == "goal-wide" else todo, "agent_vision": {}, + "frontier": {"scope": "goal" if terminal else "agent_lane"}}} + assert checkpoint_decision(context)["read_context_id"] == "fresh-read" + context["basis"]["frontier"]["scope"] = "agent_lane" if terminal else "goal" + with pytest.raises(AssertionError): + checkpoint_decision(context) diff --git a/tests/control_plane_ts/checkpoint_provider_head.test.ts b/tests/control_plane_ts/checkpoint_provider_head.test.ts index 44b2416339..efaff6f973 100644 --- a/tests/control_plane_ts/checkpoint_provider_head.test.ts +++ b/tests/control_plane_ts/checkpoint_provider_head.test.ts @@ -1,5 +1,6 @@ -import {inspectCheckpointAttempt} from "../../loopx/control_plane/goals/checkpoint_commit.ts"; +import {inspectCheckpointAttempt, inspectCheckpointReadHistory} from "../../loopx/control_plane/goals/checkpoint_commit.ts"; import assert from "node:assert/strict"; +import {createHash} from "node:crypto"; import {mkdir, mkdtemp, rm} from "node:fs/promises"; import {writeFileSync, readFileSync} from "node:fs"; import {tmpdir} from "node:os"; @@ -87,3 +88,71 @@ for (const work of [true, false]) { purpose: work ? "delivery_result" : "first_delivery"}), (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); }); } + +for (const readWork of [false, true]) { + for (const damage of ["none", "missing-json", "mismatched-json", "missing-markdown", "torn-index", "no-proof", "work-only-proof"] as const) { + test(`obsolete ${readWork ? "work" : "direction"} receipt needs a verified matching indexed effect: ${damage}`, async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-old-read-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + const contexts = join(root, "goals", "goal", "checkpoint-contexts"); + await mkdir(runs, {recursive: true}); + await mkdir(contexts, {recursive: true}); + const old = {goal_id: "goal", agent_id: "peer", todo_id: "old-task", turn_instance_id: "old-turn", effect_id: "old-effect"}; + const current = {goal_id: "goal", agent_id: "agent", todo_id: "new-task", turn_instance_id: "new-turn", effect_id: "new-effect"}; + const jsonPath = join(runs, "old.json"); + const markdownPath = join(runs, "old.md"); + const row = {settlement_identity: old, refresh_recovery: {}, json_path: jsonPath, markdown_path: markdownPath, + ...(damage === "work-only-proof" ? {result_work_context: {read_context_id: "old-work"}} : + damage === "no-proof" ? {classification: "quota_slot_spent"} : + {vision_checkpoint: {satisfied: true, read_context: {read_context_id: "old-direction"}}})}; + if (damage !== "missing-json") writeFileSync(jsonPath, JSON.stringify(damage === "mismatched-json" ? {...row, settlement_identity: current} : row)); + if (damage !== "missing-markdown") writeFileSync(markdownPath, "Retained checkpoint\n"); + writeFileSync(join(runs, "index.jsonl"), JSON.stringify(row) + (damage === "torn-index" ? "" : "\n")); + const name = createHash("sha256").update(old.effect_id + (readWork ? ":delivery_result" : "")).digest("hex") + ".json"; + writeFileSync(join(contexts, name), "{"); + const inspect = () => inspectCheckpointAttempt({runtime_root: root, identity: current, + purpose: "first_delivery", check_other_attempts: true}); + const observe = () => inspectCheckpointReadHistory({runtime_root: root, goal_id: "goal", receipt_names: [name]}); + if (damage === "none" || damage === "work-only-proof" && readWork) { + assert.deepEqual(observe().verified, [{receipt_name: name, identity: old}]); + assert.equal(inspect().status, "absent"); + assert.equal(readFileSync(join(contexts, name), "utf8"), "{", "Inspection must not repair another identity's read"); + // An unattributable or current receipt remains unknown, even beside a verified old effect. + for (const effect of [current.effect_id, "unattributed-effect"]) { + const unknownPath = join(contexts, createHash("sha256").update(effect).digest("hex") + ".json"); + writeFileSync(unknownPath, "{"); + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + await rm(unknownPath); + } + } else { + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + if (damage === "torn-index") assert.throws(observe); + else assert.deepEqual(observe().verified, []); + } + }); + } +} + +test("history inspection cannot borrow a valid checkpoint from another Goal", async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-history-goal-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "other-goal", "runs"); + const currentRuns = join(root, "goals", "goal", "runs"); + const contexts = join(root, "goals", "goal", "checkpoint-contexts"); + for (const path of [runs, currentRuns, contexts]) await mkdir(path, {recursive: true}); + const identity = {goal_id: "other-goal", agent_id: "peer", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; + const row = {settlement_identity: identity, refresh_recovery: {}, + vision_checkpoint: {satisfied: true, read_context: {read_context_id: "read"}}, + json_path: join(runs, "saved.json"), markdown_path: join(runs, "saved.md")}; + writeFileSync(row.json_path, JSON.stringify(row)); + writeFileSync(row.markdown_path, "Saved checkpoint\n"); + for (const path of [runs, currentRuns]) writeFileSync(join(path, "index.jsonl"), JSON.stringify(row) + "\n"); + const name = createHash("sha256").update(identity.effect_id).digest("hex") + ".json"; + writeFileSync(join(contexts, name), "{"); + assert.equal(inspectCheckpointAttempt({runtime_root: root, identity}).status, "committed"); + assert.deepEqual(inspectCheckpointReadHistory({runtime_root: root, goal_id: "goal", receipt_names: [name]}).verified, []); + assert.throws(() => inspectCheckpointAttempt({runtime_root: root, + identity: {...identity, goal_id: "goal", effect_id: "new-effect"}, check_other_attempts: true}), + (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); +}); From 1d5e37c16a8b8e4e09ae34b5c750523feae7f2af Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 17:04:10 +0800 Subject: [PATCH 39/80] fix(checkpoint): preserve missing-read recovery and canonical chat routing Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 4 ++ loopx/chat_loopx_mode.py | 4 +- .../control_plane/goals/checkpoint_commit.ts | 37 +++++++++++++++---- .../goals/checkpoint_context_io.py | 32 ++++++++-------- .../control_plane/host_checkpoint_fixture.py | 7 +++- .../test_checkpoint_history_isolation.py | 9 ++++- .../test_host_checkpoint_fixture.py | 2 +- .../checkpoint_provider_head.test.ts | 36 ++++++++++++++++-- tests/test_chat_coordinator_checkpoint.py | 29 +++++++++++++++ 9 files changed, 128 insertions(+), 32 deletions(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 6bb81b878c..3062a328ec 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -1335,6 +1335,8 @@ never a pending direction append. Unknown paths, incomplete indexes, missing or conflicting artifacts and unindexed direction attempts remain fail-closed. Status and packaged UI observations use the same native proof to recover the original binding; a verified direction never erases pending quota accounting. +This includes deletion of both disposable reads. Coordinator checkpoint reads +and recovery follow the registered Goal runtime even when Chat storage is separate. 损坏的历史读取回执只有在路径能绑定到精确索引效果、且对应 JSON/Markdown 工件通过既有回执优先核验时,才不再阻断独立 checkpoint;检查不会改写旧回执。 @@ -1342,6 +1344,8 @@ original binding; a verified direction never erases pending quota accounting. 以及未索引的方向提交仍保持拒绝。 状态和 packaged UI 读回复用同一原生证明恢复原身份;已核验的方向不会抹掉尚未 完成的配额结算。 +两份可丢弃读取回执都被删除时也适用。Chat 存储分离时,协调员 checkpoint 的 +读取与恢复仍使用注册 Goal 的运行时。 ### 中文:本阶段边界与恢复 diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py index 98d89fa50f..bd7e73b15d 100644 --- a/loopx/chat_loopx_mode.py +++ b/loopx/chat_loopx_mode.py @@ -274,7 +274,7 @@ def snapshot(self, session_id): checkpoint = None if settings.get("agent_id"): from .control_plane.goals.checkpoint_context_io import pending_first_delivery_progress - checkpoint = pending_first_delivery_progress(self.store.root.parent, session["goal_id"], + checkpoint = pending_first_delivery_progress(self.controller.coordination_runtime_root, session["goal_id"], settings["agent_id"], registry_path=self.controller.registry_path) # Refresh unfinished observations even while the coordinator is paused: # delegated members have their own lifecycles. Accepted labels remain @@ -936,7 +936,7 @@ def dispatch(name, arguments): if service.path(operation_id).exists(): raise ValueError("a member operation cannot be reinterpreted as a coordinator checkpoint") from .chat_coordinator_checkpoint import CoordinatorCheckpoints - checkpoint = CoordinatorCheckpoints(self.controller.registry_path, self.store.root.parent, + checkpoint = CoordinatorCheckpoints(self.controller.registry_path, self.controller.coordination_runtime_root, session["goal_id"], settings["agent_id"]) return checkpoint.read(operation_id) if action == "read" else checkpoint.resume(operation_id, {k: v for k, v in brief.items() if k != "purpose"}) if action == "start" and not service.path(operation_id).exists(): diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 6dbc6d8055..27848efdde 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -1,7 +1,7 @@ /** Receipt-bound work and direction commits. Index/source claims survive the requesting CLI; * the real provider fence lasts through the synchronous durable append. */ import {createHash} from "node:crypto"; -import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, writeFileSync} from "node:fs"; +import {closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, writeFileSync} from "node:fs"; import {dirname, join, resolve} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; @@ -142,8 +142,9 @@ function indexedReadProof(rows: JsonObject[], goal: string, name: string): {row: const direction = jsonObject(row.vision_checkpoint)?.satisfied === true && jsonObject(jsonObject(row.vision_checkpoint)?.read_context) !== null; if (direction && name === checkpointReceiptName(effect, false)) return {row, purpose: "first_delivery"}; - if ((direction || row.result_work_context != null) && name === checkpointReceiptName(effect, true)) { - return {row, purpose: row.result_work_context != null ? "delivery_result" : "first_delivery"}; + const work = jsonObject(row.result_work_context) !== null; + if ((direction || work) && name === checkpointReceiptName(effect, true)) { + return {row, purpose: work ? "delivery_result" : "first_delivery"}; } } return null; @@ -158,8 +159,24 @@ export function inspectCheckpointReadHistory(value: unknown): JsonObject { const rows = indexBytes(join(root, "goals", goal, "runs", "index.jsonl")) .toString("utf8").split(/\r?\n/).filter(line => line.trim()).map(line => JSON.parse(line) as JsonObject); if (!Array.isArray(request.receipt_names)) throw new EffectRuntimeRequestError("receipt_names must be an array"); + const names = new Set(request.receipt_names); + const contexts = join(root, "goals", goal, "checkpoint-contexts"); + // Deleted reads cannot appear in a directory scan. Recover their indexed + // binding too; accounting/terminal owners still decide whether it is pending. + for (const row of rows) { + const binding = jsonObject(row.settlement_identity); + if (binding?.goal_id !== goal || typeof binding.effect_id !== "string" || + (request.agent_id != null && binding.agent_id !== request.agent_id)) continue; + const effect = binding.effect_id; + if (!existsSync(join(contexts, checkpointReceiptName(effect, false))) && + !existsSync(join(contexts, checkpointReceiptName(effect, true)))) { + if (row.result_work_context != null) names.add(checkpointReceiptName(effect, true)); + if (jsonObject(row.vision_checkpoint)?.satisfied === true && + jsonObject(jsonObject(row.vision_checkpoint)?.read_context)) names.add(checkpointReceiptName(effect, false)); + } + } const verified: JsonObject[] = []; - for (const name of request.receipt_names) { + for (const name of names) { if (typeof name !== "string" || !name.endsWith(".json") || !BARE_SHA256_PATTERN.test(name.slice(0, -5))) { throw new EffectRuntimeRequestError("invalid checkpoint receipt name"); } @@ -168,7 +185,8 @@ export function inspectCheckpointReadHistory(value: unknown): JsonObject { try { const result = inspectCheckpointAttempt({runtime_root: root, identity: proof.row.settlement_identity, purpose: proof.purpose}); - if (result.status === "committed") verified.push({receipt_name: name, identity: proof.row.settlement_identity}); + if (result.status === "committed") verified.push({receipt_name: name, identity: proof.row.settlement_identity, + purpose: proof.purpose}); } catch (error) { if (!(error instanceof EffectRuntimeRequestError) || error.code !== "checkpoint_commit_unknown") throw error; } @@ -215,7 +233,6 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { let names: string[] = []; try { names = readdirSync(contexts); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - const indexed = new Set(rows.map(row => jsonObject(row.settlement_identity)?.effect_id)); const resultReadName = checkpointReceiptName(requireNonEmptyString(identity.effect_id, "effect_id"), true); for (const name of names.filter(name => name.endsWith(".json") && BARE_SHA256_PATTERN.test(name.slice(0, -5)))) { // This disjoint path can hold only the original work read, never a direction append. @@ -236,8 +253,12 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { } const owner = jsonObject(other.identity); if (other.purpose === "first_delivery" && other.commit_attempt != null && owner?.effect_id !== identity.effect_id && - owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id && !indexed.has(owner?.effect_id)) { - unknown("another original Turn has an unresolved direction append for this Todo"); + owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id) { + const proof = indexedReadProof(rows, goalPathSegment(identity.goal_id), name); + if (!proof || proof.purpose !== "first_delivery" || + inspectCheckpointAttempt({runtime_root: root, identity: owner, purpose: "first_delivery"}).status !== "committed") { + unknown("another original Turn has an unresolved direction append for this Todo"); + } } } } diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index fd50600b51..b877326a9e 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -422,7 +422,7 @@ def _inspect_attempt(root: Path, identity: SettlementIdentity, purpose: str = "f def first_delivery_progress( root: Path, readback: Any, *, canonical_todos: list[dict[str, Any]] | None = None, - observation_unknown: bool = False, registry_path: Path | None = None, + observation_unknown: bool = False, registry_path: Path | None = None, verified_direction: bool = False, ) -> dict[str, Any] | None: """Observe protocol receipts for quota consumers; this grants no write authority. @@ -437,7 +437,7 @@ def first_delivery_progress( unknown = observation_unknown read_unavailable = False try: - if not first_delivery_context_enrolled(root, identity) and not checkpoint_continuation_required(readback): + if not verified_direction and not first_delivery_context_enrolled(root, identity) and not checkpoint_continuation_required(readback): return None except (ValueError, OSError): read_unavailable = True @@ -508,6 +508,7 @@ def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | No """ identities = {} errors = [] + verified_directions = set() for path in (root / "goals" / goal_id / "checkpoint-contexts").glob("*.json"): try: receipt = _read_context_receipt(path) @@ -529,18 +530,19 @@ def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | No errors.append({"scope": "goal", "code": "checkpoint_receipt_invalid", "receipt": path.stem}) continue identities[identity.effect_id] = identity.as_dict() - if errors: - try: - recovered = _checkpoint_effect("goal.checkpoint_read_context.inspect_history", { - "runtime_root": str(root.resolve()), "goal_id": goal_id, - "receipt_names": [error["receipt"] + ".json" for error in errors]}) - for item in recovered["verified"]: - identity = SettlementIdentity.from_runtime_payload(item["identity"]) - errors = [error for error in errors if error["receipt"] + ".json" != item["receipt_name"]] - if agent_id is None or identity.agent_id == agent_id: - identities[identity.effect_id] = identity.as_dict() - except (ValueError, OSError): - pass # Unproved history remains explicitly unavailable. + try: + recovered = _checkpoint_effect("goal.checkpoint_read_context.inspect_history", { + "runtime_root": str(root.resolve()), "goal_id": goal_id, "agent_id": agent_id, + "receipt_names": [error["receipt"] + ".json" for error in errors]}) + for item in recovered["verified"]: + identity = SettlementIdentity.from_runtime_payload(item["identity"]) + errors = [error for error in errors if error["receipt"] + ".json" != item["receipt_name"]] + if agent_id is None or identity.agent_id == agent_id: + identities[identity.effect_id] = identity.as_dict() + if item["purpose"] == "first_delivery": + verified_directions.add(identity.effect_id) + except (ValueError, OSError): + errors.append({"scope": "goal", "code": "checkpoint_history_unavailable"}) # Share the complete current authority snapshot across observed identities. # Do not cap the historical scan or silently lose older pending work. canonical_todos = None @@ -561,7 +563,7 @@ def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | No if readback is None or canonical_todos is None: raise ValueError("enrolled checkpoint readback unavailable") progress = first_delivery_progress(root, readback, canonical_todos=canonical_todos, - observation_unknown=observation_unknown) + observation_unknown=observation_unknown, verified_direction=identity.effect_id in verified_directions) except (ValueError, OSError): progress = effect_runtime_result("turn.first_delivery.evaluate", {"phase": "project", "unknown": True}) if progress is not None and progress["stage"] != "settled": diff --git a/tests/control_plane/host_checkpoint_fixture.py b/tests/control_plane/host_checkpoint_fixture.py index 4a4e72e82e..41fe5786d6 100644 --- a/tests/control_plane/host_checkpoint_fixture.py +++ b/tests/control_plane/host_checkpoint_fixture.py @@ -11,11 +11,16 @@ def checkpoint_decision(context): assert context["read_context_id"] todo = basis.get("todo") identity = context["settlement_identity"] + assert isinstance(todo, (dict, list)) + goal_wide = isinstance(todo, list) + if goal_wide: + assert identity.get("todo_id") is None + todo = {} deferred_terminal = bool(todo and todo.get("status") == "done" and todo.get("completion_continuation") == "active_goal" and todo.get("completion_requested_continuation") == "no_followup" and todo.get("completion_turn_key") in {identity["effect_id"], identity["turn_instance_id"]}) - terminal = todo is None or todo.get("no_followup") is True or deferred_terminal + terminal = goal_wide or todo.get("no_followup") is True or deferred_terminal assert basis["frontier"]["scope"] == ("goal" if terminal else "agent_lane") decision = {"read_context_id": context["read_context_id"], "agent_vision_json": "", "vision_unchanged_reason": "", "stop_reason": ""} diff --git a/tests/control_plane/test_checkpoint_history_isolation.py b/tests/control_plane/test_checkpoint_history_isolation.py index 49eda3c8a3..eed39e66cc 100644 --- a/tests/control_plane/test_checkpoint_history_isolation.py +++ b/tests/control_plane/test_checkpoint_history_isolation.py @@ -77,7 +77,8 @@ def test_settled_peer_read_damage_preserves_independent_delivery(provider, purpo @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_recovered_history_retains_original_unspent_accounting(provider, tmp_path, monkeypatch): +@pytest.mark.parametrize("loss", ["damaged", "deleted"]) +def test_recovered_history_retains_original_unspent_accounting(provider, loss, tmp_path, monkeypatch): registry, _, todo = _fixture(provider, tmp_path, monkeypatch) control = _control(registry) guard = json.loads(control.should_run()) @@ -98,7 +99,11 @@ def stop_before_spend(args, **kwargs): "runtime_root": str(runtime), "identity": identity.as_dict(), "purpose": "first_delivery"}) assert proof["status"] == "committed", proof for purpose in ("first_delivery", "delivery_result"): - _receipt_path(runtime, identity, purpose).write_text("{", encoding="utf-8") + path = _receipt_path(runtime, identity, purpose) + if loss == "deleted": + path.unlink() + else: + path.write_text("{", encoding="utf-8") progress = pending_first_delivery_progress(runtime, GOAL_ID, AGENT_ID, registry_path=registry) assert progress["stage"] == "settlement_pending", progress assert progress["settlement_identity"] == identity.as_dict() diff --git a/tests/control_plane/test_host_checkpoint_fixture.py b/tests/control_plane/test_host_checkpoint_fixture.py index 912b3ad08e..b4e22bc13f 100644 --- a/tests/control_plane/test_host_checkpoint_fixture.py +++ b/tests/control_plane/test_host_checkpoint_fixture.py @@ -15,7 +15,7 @@ def test_fixture_checks_the_original_direction_scope(intent): completion_turn_key="other-turn" if intent == "other-turn" else identity["effect_id"]) terminal = intent in {"stored", "deferred", "goal-wide"} context = {"read_context_id": "fresh-read", "settlement_identity": identity, - "basis": {"todo": None if intent == "goal-wide" else todo, "agent_vision": {}, + "basis": {"todo": [] if intent == "goal-wide" else todo, "agent_vision": {}, "frontier": {"scope": "goal" if terminal else "agent_lane"}}} assert checkpoint_decision(context)["read_context_id"] == "fresh-read" context["basis"]["frontier"]["scope"] = "agent_lane" if terminal else "goal" diff --git a/tests/control_plane_ts/checkpoint_provider_head.test.ts b/tests/control_plane_ts/checkpoint_provider_head.test.ts index efaff6f973..b9495cc6a2 100644 --- a/tests/control_plane_ts/checkpoint_provider_head.test.ts +++ b/tests/control_plane_ts/checkpoint_provider_head.test.ts @@ -90,7 +90,7 @@ for (const work of [true, false]) { } for (const readWork of [false, true]) { - for (const damage of ["none", "missing-json", "mismatched-json", "missing-markdown", "torn-index", "no-proof", "work-only-proof"] as const) { + for (const damage of ["none", "missing-json", "mismatched-json", "missing-markdown", "torn-index", "no-proof", "work-only-proof", "invalid-work-proof"] as const) { test(`obsolete ${readWork ? "work" : "direction"} receipt needs a verified matching indexed effect: ${damage}`, async t => { const root = await mkdtemp(join(tmpdir(), "checkpoint-old-read-")); t.after(() => rm(root, {recursive: true, force: true})); @@ -103,7 +103,8 @@ for (const readWork of [false, true]) { const jsonPath = join(runs, "old.json"); const markdownPath = join(runs, "old.md"); const row = {settlement_identity: old, refresh_recovery: {}, json_path: jsonPath, markdown_path: markdownPath, - ...(damage === "work-only-proof" ? {result_work_context: {read_context_id: "old-work"}} : + ...(damage === "invalid-work-proof" ? {result_work_context: false} : + damage === "work-only-proof" ? {result_work_context: {read_context_id: "old-work"}} : damage === "no-proof" ? {classification: "quota_slot_spent"} : {vision_checkpoint: {satisfied: true, read_context: {read_context_id: "old-direction"}}})}; if (damage !== "missing-json") writeFileSync(jsonPath, JSON.stringify(damage === "mismatched-json" ? {...row, settlement_identity: current} : row)); @@ -115,7 +116,8 @@ for (const readWork of [false, true]) { purpose: "first_delivery", check_other_attempts: true}); const observe = () => inspectCheckpointReadHistory({runtime_root: root, goal_id: "goal", receipt_names: [name]}); if (damage === "none" || damage === "work-only-proof" && readWork) { - assert.deepEqual(observe().verified, [{receipt_name: name, identity: old}]); + assert.deepEqual(observe().verified, [{receipt_name: name, identity: old, + purpose: damage === "work-only-proof" ? "delivery_result" : "first_delivery"}]); assert.equal(inspect().status, "absent"); assert.equal(readFileSync(join(contexts, name), "utf8"), "{", "Inspection must not repair another identity's read"); // An unattributable or current receipt remains unknown, even beside a verified old effect. @@ -156,3 +158,31 @@ test("history inspection cannot borrow a valid checkpoint from another Goal", as identity: {...identity, goal_id: "goal", effect_id: "new-effect"}, check_other_attempts: true}), (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); }); + +test("an indexed work result cannot discharge another Turn's unindexed direction", async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-direction-proof-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + const contexts = join(root, "goals", "goal", "checkpoint-contexts"); + await mkdir(runs, {recursive: true}); + await mkdir(contexts, {recursive: true}); + const old = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "old", effect_id: "old-effect"}; + const current = {...old, turn_instance_id: "new", effect_id: "new-effect"}; + const work = {settlement_identity: old, refresh_recovery: {}, result_work_context: {read_context_id: "work"}, + json_path: join(runs, "work.json"), markdown_path: join(runs, "work.md")}; + const direction = {settlement_identity: old, refresh_recovery: {}, vision_checkpoint: {satisfied: true, + read_context: {read_context_id: "direction"}}, json_path: join(runs, "direction.json"), markdown_path: join(runs, "direction.md")}; + writeFileSync(work.json_path, JSON.stringify(work)); + writeFileSync(work.markdown_path, "Committed work\n"); + writeFileSync(direction.json_path, JSON.stringify(direction)); + writeFileSync(direction.markdown_path, "Retained direction bytes\n"); + writeFileSync(join(contexts, createHash("sha256").update(old.effect_id).digest("hex") + ".json"), JSON.stringify({ + identity: old, purpose: "first_delivery", commit_attempt: {...direction, index_record: direction}})); + const inspect = () => inspectCheckpointAttempt({runtime_root: root, identity: current, check_other_attempts: true}); + writeFileSync(join(runs, "index.jsonl"), JSON.stringify(work) + "\n"); + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + writeFileSync(join(runs, "index.jsonl"), [work, direction].map(row => JSON.stringify(row)).join("\n") + "\n"); + assert.equal(inspect().status, "absent"); + writeFileSync(direction.json_path, JSON.stringify({...direction, settlement_identity: current})); + assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); +}); diff --git a/tests/test_chat_coordinator_checkpoint.py b/tests/test_chat_coordinator_checkpoint.py index a4cd04038d..1495d189ee 100644 --- a/tests/test_chat_coordinator_checkpoint.py +++ b/tests/test_chat_coordinator_checkpoint.py @@ -6,6 +6,8 @@ from test_chat_project_coordination import project as project import pytest from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_store import ChatSessionStore from loopx.collaboration_mcp import _pinned_release_environment, _python_module_command from loopx.todos import add_goal_todo from loopx.control_plane.coordination.local_authority import ( @@ -112,11 +114,13 @@ def test_chat_checkpoint_dispatch_keeps_pause_binding_and_member_namespace( ): service, sid, repo, settings, _ = mode started = apply(mode, "start", settings=settings) + service.controller.coordination_runtime_root = repo / "canonical-runtime" calls = [] class Checkpoints: def __init__(self, registry, root, goal_id, agent_id): assert registry == service.controller.registry_path + assert root == service.controller.coordination_runtime_root assert goal_id == "research" and agent_id == "coordinator" def read(self, todo): @@ -175,3 +179,28 @@ def resume(self, todo, decision): TOOL_NAME = "loopx_collaboration" + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_checkpoint_snapshot_follows_registered_runtime_with_separate_chat_storage(provider, tmp_path, monkeypatch): + registry, _, todo = native._fixture(provider, tmp_path, monkeypatch) + control = _control(registry) + assert json.loads(control.should_run()).get("delivery_result_context") + result = json.loads(control.complete_task(todo, AGENT_ID, "Verified retained result.", no_follow_up=True)) + assert result["stage"] == "direction_pending", result + store = ChatSessionStore(tmp_path / "chat-only") + controller = ChatRuntimeController(store=store, codex_bin="unused-fixture-host", registry_path=registry) + try: + session = store.create_session(goal_id=GOAL_ID, agent_id="codex", channel_id=f"goal.{GOAL_ID}", + upstream_thread_id="fixture-session", upstream_mode="chat", adapter_kind="codex_app_server") + store.update_session(session["session_id"], native_goal={"status": "paused"}, + loopx_mode={"enabled": True, "paused": True, "settings": {"agent_id": AGENT_ID, "token_budget": 10000}}) + assert controller.coordination_runtime_root == tmp_path / "runtime" + assert store.root.parent != controller.coordination_runtime_root + observed = controller.loopx_mode.snapshot(session["session_id"])["coordinator_checkpoint"] + assert observed["stage"] == "direction_pending" and observed["result_committed"] is True + assert observed["quota_spent"] is False and observed["settlement_identity"] == result["settlement_identity"] + context = CoordinatorCheckpoints(registry, controller.coordination_runtime_root, GOAL_ID, AGENT_ID).read(todo) + assert context["settlement_identity"] == result["settlement_identity"] + finally: + controller.close() From 226bb19ffefa3c958653efc8f51d7d995736c883 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 22:06:20 +0800 Subject: [PATCH 40/80] fix(checkpoint): allow bounded explicit direction-stop reentry Retain classified direction stops and require explicit original-Turn resume and retry under current Goal, permission and quota gates. Clear a superseded transport failure only after a valid Host response; preserve total attempts, true Stop reasons, result/session/validator identity and once-only settlement. Defer selected semantic-replan qualification until fresh direction, and keep settled planning Turns distinct from ordinary progress recovery. Move scoped recovery and retained-input IO into existing owners without adding authority. Validation: 174 affected native-owner tests and three stop-kind retry-budget regressions pass. Historical boolean-only stop receipts remain held. This does not close the retained terminal/frontier and Windows exit qualification gaps. Signed-off-by: Tartar --- loopx/cli_commands/quota.py | 48 +++-- loopx/cli_commands/quota_monitor_poll.py | 3 +- loopx/cli_commands/turn_decision.py | 9 + loopx/cli_commands/turn_run_once.py | 57 +++--- .../goals/checkpoint_context_io.py | 27 ++- .../goals/checkpoint_read_context.ts | 33 +++- loopx/control_plane/heartbeat/builder.py | 2 +- loopx/control_plane/quota/live_decision.py | 24 ++- .../quota/settlement_readback.ts | 17 +- .../control_plane/quota/should_run_prepare.py | 18 +- .../control_plane/todos/completion_result.py | 21 ++ .../todos/provider_terminal_lifecycle.py | 20 +- .../turn_driver/checkpoint_continuation.py | 12 +- loopx/control_plane/turn_driver/executor.py | 146 ++------------ .../turn_driver/host_process_transport.py | 44 +++-- loopx/control_plane/turn_driver/recovery.py | 180 +++++++++++++++++- .../work_items/semantic_replan_writeback.py | 5 +- loopx/project_prompt.py | 4 +- loopx/state_refresh.py | 7 +- .../test_direction_stop_reentry.py | 156 +++++++++++++++ .../checkpoint_read_context.test.ts | 35 ++++ .../quota_settlement_readback.test.ts | 25 +++ 22 files changed, 649 insertions(+), 244 deletions(-) create mode 100644 tests/control_plane/test_direction_stop_reentry.py diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 862b67e2e8..74c49426fd 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -966,6 +966,34 @@ def _handle_quota_command( spend_turn_instance_id=spend_turn_instance_id, rollout_details=rollout_details, append_cli_rollout_event=append_cli_rollout_event, ) + if args.quota_command == "should-run" and heartbeat_turn_id and payload.get("ok") is True: + payload = _restore_quota_work_input(args, payload, heartbeat_turn_id=heartbeat_turn_id, + heartbeat_receipt_existing=heartbeat_receipt_existing, registry_path=registry_path, + runtime_root=runtime_root, runtime_root_arg=runtime_root_arg, goal_ref=goal_ref) + attach_reward_memory_recall_after_should_run( + payload, + quota_command=args.quota_command, + heartbeat_turn_id=heartbeat_turn_id, + registry_path=registry_path, + goal_id=args.goal_id, + agent_id=args.agent_id, + ) + return _emit_quota_result( + payload, + args, + context=context, + registry_path=registry_path, + heartbeat_turn_id=heartbeat_turn_id, + usage_quota_started=usage_quota_started, + capture_directory=capture_directory, + detail_sections=detail_sections, + print_payload=print_payload, + ) + + +def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_receipt_existing, + registry_path, runtime_root, runtime_root_arg, goal_ref): + """Restore the original captured input after a successful quota guard.""" if args.quota_command == "should-run" and heartbeat_turn_id and payload.get("ok") is True: from ..control_plane.goals.checkpoint_context_io import TurnResultWork, result_work_required, result_work_context, result_work_context_exists from ..control_plane.quota.settlement import read_heartbeat_settlement @@ -998,22 +1026,4 @@ def _handle_quota_command( payload.update(error_code=getattr(error, "code", "delivery_work_context_unknown"), error=str(error), status="delivery_work_context_unavailable", heartbeat_receipt=admitted_receipt) - attach_reward_memory_recall_after_should_run( - payload, - quota_command=args.quota_command, - heartbeat_turn_id=heartbeat_turn_id, - registry_path=registry_path, - goal_id=args.goal_id, - agent_id=args.agent_id, - ) - return _emit_quota_result( - payload, - args, - context=context, - registry_path=registry_path, - heartbeat_turn_id=heartbeat_turn_id, - usage_quota_started=usage_quota_started, - capture_directory=capture_directory, - detail_sections=detail_sections, - print_payload=print_payload, - ) + return payload diff --git a/loopx/cli_commands/quota_monitor_poll.py b/loopx/cli_commands/quota_monitor_poll.py index d3efa7890f..d43b0915b3 100644 --- a/loopx/cli_commands/quota_monitor_poll.py +++ b/loopx/cli_commands/quota_monitor_poll.py @@ -186,7 +186,8 @@ def record_automatic_heartbeat_stall( """Commit and reproject the automatic no-spend heartbeat observation.""" turn_id = context.heartbeat_turn_id - if turn_id is None: + # A default result-read identity is not an automatic Monitor observation. + if turn_id is None or not (args.turn_instance_id or getattr(args, "begin_turn", False)): return payload, status_payload, cache_metadata, "not_applicable" existing_stall = find_monitor_poll_turn( context.runtime_root, diff --git a/loopx/cli_commands/turn_decision.py b/loopx/cli_commands/turn_decision.py index 80f19f851e..2b7c9878a1 100644 --- a/loopx/cli_commands/turn_decision.py +++ b/loopx/cli_commands/turn_decision.py @@ -355,6 +355,7 @@ def current_turn_host_launch_decision( operator_inbox_urgency_projector: Callable[..., dict[str, Any]], turn_start_hook_dispatch: Mapping[str, Any] | None = None, goal_ref: Mapping[str, object] | None = None, + checkpoint_direction: bool = False, ) -> dict[str, Any]: """Re-read the original Turn's eligibility inside its launch lifetime. @@ -393,6 +394,14 @@ def current_turn_host_launch_decision( turn_instance_id=identity.turn_instance_id, goal_ref=goal_ref, receipt_bound_todo_id=identity.todo_id or None) launch = {"current": current, "planned_effective_action": planned_effective_action} + if checkpoint_direction: + admitted = effect_runtime_result("goal.checkpoint_read_context.work_binding", { + "phase": "direction_admission", "current": current, "identity": identity.as_dict(), + "planned_effective_action": planned_effective_action, + }) + if admitted.get("ok") is not True: + raise FirstPartyHostRuntimeRejected(str(admitted.get("error_code") or "goal_launch_not_allowed")) + return {"current": current, "planned_effective_action": current["effective_action"]} # The shared typed launch owner retains refusal precedence (including # Stop/witness failures) and compares the original effective action. if (current.get("should_run") is not True diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 60163844f6..e613f7b0d6 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -2,6 +2,7 @@ import argparse from collections.abc import Callable, Mapping +from contextlib import nullcontext from pathlib import Path from typing import Any @@ -833,42 +834,32 @@ def scheduler(_spend_payload: dict[str, object]) -> dict[str, object]: host_runner: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None session_binding_resolver = None - if args.host == "codex-app-server": - def native_launch_decision(current_registry: Path) -> Mapping[str, Any]: - return current_turn_host_launch_decision(args, registry_path=current_registry, - runtime_root=runtime_root, identity=settlement_identity, - planned_effective_action=payload["turn_envelope"].get("effective_action"), - scheduler_execution_context=payload.get("scheduler_execution_context") or {}, - operator_inbox_urgency_projector=operator_inbox_urgency_projector, - turn_start_hook_dispatch=turn_start_hook_dispatch, goal_ref=goal_ref) + def current_launch_decision(current_registry: Path, *, checkpoint_direction: bool = False) -> Mapping[str, Any]: + # Revalidate the already admitted identity through the same owner; + # global routes check the source before its quota/health mirror. + return current_turn_host_launch_decision(args, registry_path=current_registry, + runtime_root=runtime_root, identity=settlement_identity, + planned_effective_action=payload["turn_envelope"].get("effective_action"), + scheduler_execution_context=payload.get("scheduler_execution_context") or {}, + operator_inbox_urgency_projector=operator_inbox_urgency_projector, + turn_start_hook_dispatch=turn_start_hook_dispatch, goal_ref=goal_ref, + checkpoint_direction=checkpoint_direction) + if args.host == "codex-app-server": native_admission = (goal_admission or FirstPartyHostGoalAdmission.for_plan( registry_path=registry_path, goal_id=args.goal_id, planned_goal_ref=goal_ref, - )).with_launch_decision(native_launch_decision) + )) def run_native_host(request: Mapping[str, Any]) -> dict[str, Any]: - return native_host_runner(request, lambda: native_admission.launch_lifetime( + admitted = native_admission.with_launch_decision(lambda registry: current_launch_decision( + registry, checkpoint_direction=request.get("phase") == "checkpoint_direction")) + return native_host_runner(request, lambda: admitted.launch_lifetime( operation="native_conversation_turn_launch")) host_runner = run_native_host session_binding_resolver = native_session_binding_resolver elif args.host == "codex-cli": - def current_launch_decision(current_registry: Path) -> Mapping[str, Any]: - # Revalidate the already admitted identity through the same - # owner. In a global route the source is checked first, even - # when its mirror still advertises old quota or health. - return current_turn_host_launch_decision( - args, registry_path=current_registry, runtime_root=runtime_root, - identity=settlement_identity, - planned_effective_action=payload["turn_envelope"].get("effective_action"), - scheduler_execution_context=payload.get("scheduler_execution_context") or {}, - operator_inbox_urgency_projector=operator_inbox_urgency_projector, - # Keep this Turn's already dispatched required reads at launch. - turn_start_hook_dispatch=turn_start_hook_dispatch, - goal_ref=goal_ref, - ) - def run_built_in_host( request: Mapping[str, Any], ) -> dict[str, Any]: @@ -887,7 +878,8 @@ def run_built_in_host( registry_path=registry_path, goal_id=args.goal_id, planned_goal_ref=goal_ref, ) - ).with_launch_decision(current_launch_decision) + ).with_launch_decision(lambda registry: current_launch_decision( + registry, checkpoint_direction=request.get("phase") == "checkpoint_direction")) if getattr(args, "codex_operation_tools", False): from ..control_plane.turn_driver.codex_operation_host import ( run_codex_operation_host, @@ -988,7 +980,13 @@ def run_direction(context: dict[str, Any]) -> dict[str, Any]: session["action"] = "resume" session["context_policy"] = {**session.get("context_policy", {}), "mode": "resume"} request["session"] = session - with observe_goal_execution(runtime_root, args.goal_id, host=args.host): + admission = (goal_admission or FirstPartyHostGoalAdmission.for_plan( + registry_path=registry_path, goal_id=args.goal_id, planned_goal_ref=goal_ref, + )).with_launch_decision(lambda registry: current_launch_decision(registry, checkpoint_direction=True)) + # Built-in hosts hold their own launch lifetime at actual spawn. + lifetime = nullcontext() if args.host in {"codex-cli", "codex-app-server"} else admission.launch_lifetime( + operation="checkpoint_direction_launch") + with lifetime, observe_goal_execution(runtime_root, args.goal_id, host=args.host): observation = (_run_host_runner(request, runner=host_runner) if host_runner is not None else _run_host(request, argv=raw_argv, project=project, timeout_seconds=args.timeout_seconds)) if observation.get("ok") is not True: @@ -997,7 +995,7 @@ def run_direction(context: dict[str, Any]) -> dict[str, Any]: failure_kind=str(observation.get("failure_kind") or "unknown")) decision = dict(observation["value"]) if decision.get("stop_reason"): - return {"result_kind": "user_action_required"} + return {"result_kind": "user_action_required", "stop_reason": decision["stop_reason"]} if decision.get("result_kind") in {"wait", "user_action_required", "iteration_failed"}: return decision if decision.get("agent_vision_json"): @@ -1010,7 +1008,8 @@ def run_direction(context: dict[str, Any]) -> dict[str, Any]: _state_project, checkpoint_state = resolve_todo_state_path(registry_path=registry_path, goal_id=args.goal_id, project=None, state_file=None) current_checkpoint = CurrentAgentCheckpoint(registry_path, runtime_root, checkpoint_state, - settlement_identity, run_direction) + settlement_identity, run_direction, + explicit_resume=bool(args.resume_turn_key and args.retry_failed_turn)) def on_managed_start_admitted() -> None: if settlement_identity.request_subject is not None: diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index b877326a9e..480483c0fc 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -1,7 +1,7 @@ """Source/receipt I/O only; checkpoint_read_context.ts owns decision semantics.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import ExitStack, contextmanager import hashlib import json @@ -276,6 +276,31 @@ def result_committed(self) -> bool: completion_identity_source="turn_settlement") return fence["outcome"] == "replay" and fence["status"] == "done" + def prepare_request(self, journal: dict[str, Any], request: dict[str, Any], *, + post_result_checkpoint: Callable[[], bool] | None, + persist_journal: Callable[[Mapping[str, Any]], None]) -> bool: + """Persist the original work basis before dispatch or exact cold recovery.""" + original_result_committed = (isinstance(journal.get("result_work_context"), Mapping) and ( + ("durable_writeback" in journal.get("completed_phases", []) and + post_result_checkpoint is not None and post_result_checkpoint()) or self.result_committed())) + if not original_result_committed: + revalidation = journal.get("result_revalidation") + context = self.read() + if revalidation is not None and (context == revalidation.get("original_context") or revalidation.get("reread_required") is True): + context = self.read(reread=True) + if context is not None: + previous = journal.get("result_work_context") + if previous is not None and revalidation is None and previous != context: + raise ValueError("Original Turn work input was replaced; preserve its candidate and reconcile") + journal["result_work_context"] = context + request["delivery_result_context"] = context + if revalidation is not None: + request["result_revalidation"] = revalidation + revalidation["reread_required"] = False + # Durable work input precedes dispatch, including cold retries. + persist_journal(journal) + return original_result_committed + def read(self, *, reread: bool = False) -> dict[str, Any] | None: readback = read_heartbeat_settlement(self.runtime_root, goal_id=self.identity.goal_id, agent_id=self.identity.agent_id, todo_id=self.identity.todo_id, diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index d7602025a8..b6f39629ca 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -10,6 +10,7 @@ import {claimAllowsAgent} from "../todos/agent_scope.ts"; import {resultFreshnessRequired, checkpointContinuationRequired} from "../quota/heartbeat_receipt_identity.ts"; import {visionAuthoringContract} from "./vision_checkpoint.ts"; import {hostRetryPolicyCheck} from "../turn_driver/turn_journal.ts"; +import {EffectiveAction} from "../quota/effective_action.generated.ts"; import {originalTurnRequestsNoFollowup} from "../todos/completion_state.ts"; import {terminalValidationRecovery} from "../todos/completion_validation_plan.ts"; import {decodeTodoCompletionFenceRequest, evaluateTodoCompletionFence, @@ -266,7 +267,7 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { ...(request.checkpoint_continuation === true && use !== "delivery_result" && originalOutcome ? {original_outcome: originalOutcome} : {}), ...(request.checkpoint_continuation === true && use !== "delivery_result" && canonicalAuthoritySha256(jsonObject(request.receipt)?.identity ?? null) === canonicalAuthoritySha256(identity) ? - Object.fromEntries(["direction_host_attempt_count", "direction_host_failure", "direction_stopped"] + Object.fromEntries(["direction_host_attempt_count", "direction_host_failure", "direction_stopped", "direction_stops"] .filter(key => jsonObject(request.receipt)?.[key] !== undefined) .map(key => [key, jsonObject(request.receipt)?.[key]])) : {}), ...(use === "delivery_result" && request.result_only === true ? {result_only: true} : {}), @@ -377,7 +378,19 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { if (request.phase === "direction_dispatch") { const receipt = requireJsonObject(request.receipt, "direction receipt"); const failure = receipt.direction_host_failure; - if (receipt.direction_stopped === true) return rejected("checkpoint_direction_stopped"); + if (receipt.direction_stopped === true) { + const stops = receipt.direction_stops; + // Explicit recovery is not a fresh grant. Unknown historical stops and + // actual Host Stop reasons stay closed; a read alone never rearms them. + if (request.explicit_resume !== true || !Array.isArray(stops) || !stops.length || + stops.some(value => { + const stop = jsonObject(value); + return !stop || !Number.isInteger(stop.attempt) || Number(stop.attempt) < 1 || + Number(stop.attempt) > Number(receipt.direction_host_attempt_count) || + !["wait", "user_action_required", "iteration_failed"].includes(String(stop.result_kind)) || + Object.hasOwn(stop, "stop_reason"); + })) return rejected("checkpoint_direction_stopped"); + } if (failure !== undefined) { const policy = hostRetryPolicyCheck({host_failure: failure, host_attempt_count: receipt.direction_host_attempt_count}); @@ -387,6 +400,22 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { if (!Number.isInteger(count) || Number(count) < 0) return rejected("checkpoint_direction_attempt_invalid"); return {ok: true, attempt: Number(count) + 1}; } + if (request.phase === "direction_admission") { + const current = requireJsonObject(request.current, "current direction admission"); + const identity = requireJsonObject(request.identity, "original direction identity"); + const recovery = jsonObject(current.unsettled_host_turn_recovery); + const eligible = current.ok === true && current.should_run === true && current.status_health_ok === true && + current.goal_id === identity.goal_id && current.requires_user_action === false && + current.blocked_action_scope == null && jsonObject(current.quota)?.state === "eligible"; + const sameRecovery = current.effective_action === EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY && + recovery?.scope === "current_turn" && recovery.repair === "durable_writeback" && + recovery.turn_instance_id === identity.turn_instance_id && recovery.binding_kind === "todo" && + recovery.binding_id === identity.todo_id; + const sameWork = current.effective_action === request.planned_effective_action && + jsonObject(current.selected_todo)?.todo_id === identity.todo_id && + jsonObject(jsonObject(current.interaction_contract)?.agent_channel)?.delivery_allowed === true; + return eligible && (sameRecovery || sameWork) ? {ok: true} : rejected("checkpoint_direction_admission_rejected"); + } if (request.phase === "direction_response") { const receipt = requireJsonObject(request.receipt, "direction receipt"); const decision = requireJsonObject(request.decision, "direction decision"); diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index 9fb17c8d5b..b41abb1bab 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -519,7 +519,7 @@ def build_heartbeat_prompt( active_state=active_state_text, # Thin remains an installed-CLI worker, as before. Bootstrap/full # generator surfaces retain preflight; brief carries it in its body. - cli_preflight="" if thin else cli_preflight, + cli_preflight=render_cli_preflight(cli_bin=cli_bin, thin=thin), pr_review_pre_quota_command=( "" if traex_visible_goal else commands["pr_review_pre_quota_command"] or "" ), diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 579de517ff..4f2747663b 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -757,15 +757,8 @@ def build_live_quota_should_run_decision( scheduler_execution_context=resolved_context, goal_ref=goal_ref, ) - if ( - receipt_bound_todo_id and agent_id and turn_instance_id - and receipt_bound_replay_phase is not ReceiptBoundReplayPhase.SETTLED - and payload.get("effective_action") != EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value - and ( - receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLEMENT_PENDING - or _receipt_bound_recovery_required(payload, receipt_bound_todo_id) - ) - ): + if _needs_receipt_settlement_recovery(payload, receipt_bound_todo_id, agent_id, + turn_instance_id, receipt_bound_replay_phase): # A verified writeback seals this Turn even while its Todo stays open # and selected. Let the existing typed owner project only the remaining # settlement; another delivery cannot replace the immutable writeback. @@ -833,3 +826,16 @@ def build_live_quota_should_run_decision( hook_dispatch=turn_start_hook_dispatch, context_navigation=context_navigation, inline_goal_source=inline_goal_source) return payload + + +def _needs_receipt_settlement_recovery(payload, receipt_bound_todo_id, agent_id, turn_instance_id, receipt_bound_replay_phase): + """Recognize the existing owner-proved pending settlement route.""" + return bool( + receipt_bound_todo_id and agent_id and turn_instance_id + and receipt_bound_replay_phase is not ReceiptBoundReplayPhase.SETTLED + and payload.get("effective_action") != EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value + and ( + receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLEMENT_PENDING + or _receipt_bound_recovery_required(payload, receipt_bound_todo_id) + ) + ) diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index 2d56b15694..f2f38d2500 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -1466,10 +1466,21 @@ export async function selectDefaultResultWorkTurn(value: unknown): Promise done.event_kind === "todo_complete" && + // A settled planning obligation must not compete with its later work + // admission. Ordinary progress still retains its original-Turn result + // recovery path; settlement alone is not proof of planning-only work. + const guard = jsonObject(readback.semantic_replan_guard); + const planned = guard?.scope === "turn_guard" && + typeof guard.selected_obligation_id === "string" && + isAcceptedReplanWriteback(readback.writeback_run, settlementIdentity({ + goal_id: request.goal_id, agent_id: request.agent_id!, + todo_id: fields.todo_id, turn_instance_id: turn, + })); + if (readback.replay_phase === "settled" && (raw.pending_only === true || planned)) continue; + } + if (raw.pending_only !== true && todo?.status !== "done" && snapshot.events.some(done => done.event_kind === "todo_complete" && done.agent_id === request.agent_id && done.run_id === event.run_id && done.todo_id === fields.todo_id && quotaOwnerOwnsProjection(request.owner, done.goal_ref))) continue; turns.set(turn, event); diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index 1cb92da105..95a9987fdf 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -275,12 +275,7 @@ def _prepare_quota_replan_context( receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, ) - retained_semantic_source = status_payload.get("_receipt_bound_semantic_source") - if isinstance(retained_semantic_source, dict): - from ..work_items.progress_observation import build_replan_context - retained_semantic_source = {**retained_semantic_source, "replan_context": build_replan_context( - retained_semantic_source, goal_id=safe_goal_id, agent_id=agent_frontier_id, - newest_first_runs=_goal_latest_runs(status_payload, goal_id=safe_goal_id), source_status=status_payload)} + retained_semantic_source = _retained_replan_source(status_payload, safe_goal_id, agent_frontier_id) replan_obligation = ( None if receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLED @@ -1063,3 +1058,14 @@ def _prepare_quota_should_run_item( delivery_continuity_anchor=delivery_continuity_anchor, delivery_continuity_todo=delivery_continuity_todo, ) + + +def _retained_replan_source(status_payload, safe_goal_id, agent_frontier_id): + """Rebuild current context without replacing the original obligation source.""" + retained_semantic_source = status_payload.get("_receipt_bound_semantic_source") + if isinstance(retained_semantic_source, dict): + from ..work_items.progress_observation import build_replan_context + retained_semantic_source = {**retained_semantic_source, "replan_context": build_replan_context( + retained_semantic_source,goal_id=safe_goal_id,agent_id=agent_frontier_id, + newest_first_runs=_goal_latest_runs(status_payload,goal_id=safe_goal_id),source_status=status_payload)} + return retained_semantic_source diff --git a/loopx/control_plane/todos/completion_result.py b/loopx/control_plane/todos/completion_result.py index 5a6b9650b4..898f35310d 100644 --- a/loopx/control_plane/todos/completion_result.py +++ b/loopx/control_plane/todos/completion_result.py @@ -123,3 +123,24 @@ def read_completion_result(*, registry_path: Path, runtime_root: Path, raise ValueError("completion result bytes no longer match canonical binding") return {"ok": True, "goal_id": goal_id, "todo_id": todo_id, "result": binding, "text": data.decode("utf-8"), "audience": "local_operator"} + + +def resolve_terminal_result_descriptor(completion_result_file, *, target, runtime_root, registry_path, goal_id, todo_id): + """Recover missing input bytes only from the original completed Todo binding.""" + result_descriptor = None + if completion_result_file is not None: + try: + result_descriptor = store_completion_result( + source=completion_result_file, runtime_root=runtime_root, + goal_id=goal_id, persist=False, + ) + except FileNotFoundError: + if target.get("status") != "done": + raise + bound = read_completion_result( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + )["result"] + result_descriptor = {key: bound[key] for key in + ("provider", "sha256", "size_bytes", "content_type")} + return result_descriptor diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 892ac93e26..557115a9b7 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -39,7 +39,7 @@ from .path_resolution import resolve_todo_state_path from .provider_projection import projection_delivery_requires_ack, settle_canonical_todo_projection from .successor_derivation import build_successor_intents -from .completion_result import read_completion_result, store_completion_result +from .completion_result import read_completion_result, store_completion_result, resolve_terminal_result_descriptor _TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v3" _ARCHIVE_REQUEST_SCHEMA = "loopx_local_coordination_todo_archive_request_v0" @@ -316,22 +316,8 @@ def terminal_canonical_todo_if_promoted( # The canonical transaction owns missing/role/archive lifecycle decisions. # Keep only the optional local validation facts needed by the host adapter. target = _todo_by_id(canonical["todos"], todo_id) or {} - result_descriptor = None - if completion_result_file is not None: - try: - result_descriptor = store_completion_result( - source=completion_result_file, runtime_root=runtime_root, - goal_id=goal_id, persist=False, - ) - except FileNotFoundError: - if target.get("status") != "done": - raise - bound = read_completion_result( - registry_path=registry_path, runtime_root=runtime_root, - goal_id=goal_id, todo_id=todo_id, - )["result"] - result_descriptor = {key: bound[key] for key in - ("provider", "sha256", "size_bytes", "content_type")} + result_descriptor = resolve_terminal_result_descriptor(completion_result_file, + target=target, runtime_root=runtime_root, registry_path=registry_path, goal_id=goal_id, todo_id=todo_id) with authority_registry_source(registry_path) as registry_source: registered, grants = todo_lifecycle_facts(registry_path, goal_id) successor_intents = build_successor_intents( diff --git a/loopx/control_plane/turn_driver/checkpoint_continuation.py b/loopx/control_plane/turn_driver/checkpoint_continuation.py index 6a3e1daaa9..bb32811ff8 100644 --- a/loopx/control_plane/turn_driver/checkpoint_continuation.py +++ b/loopx/control_plane/turn_driver/checkpoint_continuation.py @@ -18,8 +18,9 @@ class CurrentAgentCheckpoint: def __init__(self, registry: Path, root: Path, state: Path, identity: SettlementIdentity, - runner: Callable[[dict[str, Any]], dict[str, Any]]): + runner: Callable[[dict[str, Any]], dict[str, Any]], *, explicit_resume: bool = False): self.registry, self.root, self.state, self.identity, self.runner = registry, root, state, identity, runner + self.explicit_resume = explicit_resume self._enabled: bool | None = None def enabled(self) -> bool: @@ -81,7 +82,7 @@ def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first decision = receipt.get('current_agent_decision') if decision is None: dispatch = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { - 'phase': 'direction_dispatch', 'receipt': receipt}) + 'phase': 'direction_dispatch', 'receipt': receipt, 'explicit_resume': self.explicit_resume}) if dispatch.get('ok') is not True: raise CheckpointReadContextRejected(dispatch) receipt['direction_host_attempt_count'] = dispatch['attempt'] @@ -94,7 +95,13 @@ def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first self._save(path, receipt) raise ValueError(error.reason) from error if decision.get('result_kind') in {'wait', 'user_action_required', 'iteration_failed'}: + # A valid Host response supersedes the prior transport + # failure; retain the total attempt count for future failures. + receipt.pop('direction_host_failure', None) receipt['direction_stopped'] = True + receipt['direction_stops'] = [*receipt.get('direction_stops', []), { + 'attempt': dispatch['attempt'], 'result_kind': decision['result_kind'], + **({'stop_reason': decision['stop_reason']} if decision.get('stop_reason') else {})}] self._save(path, receipt) raise ValueError('Current Agent stopped direction continuation; result retained') bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { @@ -103,6 +110,7 @@ def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first raise ValueError(bound.get('error', 'Direction response binding rejected')) receipt = {**receipt, 'current_agent_decision': decision, 'current_agent_binding': bound['binding']} receipt.pop('direction_host_failure', None) + receipt.pop('direction_stopped', None) self._save(path, receipt) else: bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 5c6da7b8ad..70cec1184c 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -56,6 +56,8 @@ ) from .lane_fence import single_executor_per_turn_lane from .recovery import ( + preview_turn_recovery, prepare_terminal_host_recovery, reset_failed_turn_for_retry, + recover_existing_turn, admit_unfinished_host, assess_existing_turn_recovery, build_turn_recovery_audit, require_turn_recovery_continuation, @@ -1619,45 +1621,11 @@ def run_loopx_turn_once( plan, fail_closed, execute=True, replayed=False, effects=empty_effects ) if not execute: - preview = { - "schema_version": LOOPX_TURN_JOURNAL_SCHEMA_VERSION, - "status": "preview", - "host": host_projection, - "result_kind": None, - "receipt": None, - "scheduler": {"disposition": "not_evaluated"}, - } - # A retained Turn may only need settlement. Project the same typed - # recovery owner that execution consumes, without changing its journal - # or calling any execution callback. A new Turn keeps the original plan. - journal = _load_journal(turn_journal_path( - runtime_root, goal_id=goal_id, turn_key=str(transaction_plan["turn_key"]) - )) - preview_plan = plan - if journal is not None: - envelope = plan.get("turn_envelope") - assessment = assess_existing_turn_recovery( - journal, request, goal_id=goal_id, - agent_id=str(envelope.get("agent_id") or "") - if isinstance(envelope, Mapping) else "", - turn_key=str(transaction_plan["turn_key"]), - retry_failed=retry_failed, - session_binding_resolver=session_binding_resolver, - ) - preview_plan = {**plan, "route": { - **plan["route"], - "would_invoke_host": assessment.decision["reinvoke_host"], - }} - preview["recovery_audit"] = build_turn_recovery_audit( - assessment.decision, journal, status="finished", host_invoked=False - ) - return execution_payload( - preview_plan, - preview, - execute=False, - replayed=False, - effects=empty_effects, - ) + return preview_turn_recovery(plan, host_projection, + journal=_load_journal(turn_journal_path(runtime_root, goal_id=goal_id, + turn_key=str(transaction_plan["turn_key"]))), request=request, + goal_id=goal_id, turn_key=str(transaction_plan["turn_key"]), retry_failed=retry_failed, + session_binding_resolver=session_binding_resolver, empty_effects=empty_effects) if writeback is None or spend is None or scheduler is None: raise ValueError( "executing run-once requires writeback, spend, and scheduler callbacks" @@ -1725,58 +1693,17 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: session_binding_resolver=session_binding_resolver, ) recovery_decision = assessment.decision - action = recovery_decision.get("action") - if action == "return_existing": - payload = execution_payload( - plan, - journal, - execute=True, - replayed=True, - effects=empty_effects, - ) - payload.update(recover_post_settlement( - plan=plan, post_settlement=post_settlement, journal=journal, - )) - payload["recovery"] = build_turn_recovery_audit( - recovery_decision, - journal, - status="finished", - host_invoked=False, - ) - return payload + if recovery_decision.get("action") == "return_existing": + return recover_existing_turn(plan, journal, recovery_decision, + empty_effects=empty_effects, post_settlement=post_settlement) request = require_turn_recovery_continuation(assessment) # Settlement recovery uses its cached host result and must not reserve # another interval. A new host attempt, including failed-result retry, # goes through admission before any host or journal attempt mutation. - receipt = journal.get("receipt") if isinstance(journal, Mapping) else None - validation_reinvokes_host = ( - isinstance(journal, Mapping) - and journal.get("status") == "failed" - and isinstance(receipt, Mapping) - and receipt.get("failed_phase") == "validation" - and journal.get("validation_stage") != "task_postcondition" - ) - terminal_retry = (journal or {}).get("result_revalidation") or {} - terminal_view = terminal_retry.get("validation", {}) - if terminal_retry.get("phase") == "terminal_closeout" and terminal_view.get("status") == "failed": - assessment = assess_existing_turn_recovery({"schema_version": journal["schema_version"], - "goal_id": goal_id, "turn_key": turn_key, "plan": journal["plan"], **terminal_view}, request, - goal_id=goal_id, agent_id=str(plan["turn_envelope"]["agent_id"]), turn_key=turn_key, - retry_failed=True, session_binding_resolver=session_binding_resolver) - request = require_turn_recovery_continuation(assessment) - terminal_work_changed = (result_work is not None and terminal_retry.get("phase") == "terminal_closeout" - and terminal_retry.get("validation", {}).get("work_context") != result_work.read()) - needs_host = ( - validation_reinvokes_host - or journal is None - or "typed_result" not in list(journal.get("completed_phases") or []) - or terminal_work_changed - or isinstance(journal.get("result_revalidation"), dict) and - journal["result_revalidation"].get("phase") == "terminal_closeout" and - ("typed_result" not in journal["result_revalidation"].get("validation", {}).get("completed_phases", []) or - journal["result_revalidation"].get("validation", {}).get("status") == "failed") - ) + request, needs_host = prepare_terminal_host_recovery(journal, request, + plan=plan, goal_id=goal_id, turn_key=turn_key, result_work=result_work, + session_binding_resolver=session_binding_resolver) if needs_host and goal_admission is not None: goal_admission.require_current() capture = stage_turn_decision_capture( @@ -1818,29 +1745,7 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: ) persist_journal(journal) - if journal and journal.get("status") == "failed": - receipt = ( - journal.get("receipt") - if isinstance(journal.get("receipt"), dict) - else {} - ) - if receipt.get("failed_phase") == "validation": - if validation_reinvokes_host: - journal.pop("host_result", None) - journal.pop("result_kind", None) - journal["completed_phases"] = ( - list(TRANSACTION_PHASES[:2]) - if isinstance(journal.get("host_result"), dict) - else [] - ) - journal.pop("task_validation", None) - journal.pop("validation_stage", None) - journal.pop("reason", None) - journal.pop("receipt", None) - journal.pop("host_recovery", None) - journal.pop("host_failure", None) - journal["status"] = "in_progress" - persist_journal(journal) + reset_failed_turn_for_retry(journal, persist_journal) if journal is None: journal = { "schema_version": LOOPX_TURN_JOURNAL_SCHEMA_VERSION, @@ -1857,26 +1762,9 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: journal["admission"] = admission persist_journal(journal) - original_result_committed = (result_work is not None and - isinstance(journal.get("result_work_context"), Mapping) and ( - ("durable_writeback" in journal.get("completed_phases", []) and - post_result_checkpoint is not None and post_result_checkpoint()) or result_work.result_committed())) - if result_work is not None and not original_result_committed: - revalidation = journal.get("result_revalidation") - context = result_work.read() - if revalidation is not None and (context == revalidation.get("original_context") or revalidation.get("reread_required") is True): - context = result_work.read(reread=True) - if context is not None: - previous = journal.get("result_work_context") - if previous is not None and revalidation is None and previous != context: - raise ValueError("Original Turn work input was replaced; preserve its candidate and reconcile") - journal["result_work_context"] = context - request["delivery_result_context"] = context - if revalidation is not None: - request["result_revalidation"] = revalidation - revalidation["reread_required"] = False - # Durable work input precedes dispatch, including cold retries. - persist_journal(journal) + original_result_committed = (result_work.prepare_request(journal, request, + post_result_checkpoint=post_result_checkpoint, persist_journal=persist_journal) + if result_work is not None else False) effects = dict(empty_effects) diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index 568880eb30..91374306c5 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -296,25 +296,7 @@ def run_host_process( if delegated_lease is not None: request["delegated_lease"] = delegated_lease bridge = Path(__file__).with_name("host_process_bridge.ts") - # The bridge runs on the caller's environment, which may pin the release the - # Host must run and name the record its supervisor owns. Copy it instead of - # replacing it with the ambient one, and consume the record env from the - # copy only: the Host below must not inherit a marker for its supervisor's - # record, and the caller's mapping is never mutated. - bridge_environment = os.environ.copy() if environment is None else dict(environment) - record_value = bridge_environment.pop(HOST_PROCESS_RECORD_ENV, "") - supervision = bridge_environment.pop(HOST_PROCESS_PARENT_ENV, "direct") - if supervision not in {"direct", "leased"}: - raise ValueError("invalid managed Host supervision scope") - record_path = Path(record_value) if record_value else None - supervises = HOST_PROCESS_SUPERVISES_HOST - if record_path is not None and delegated_lease is not None: - # A leased run starts the private leased CLI, which records the actual - # Host it starts under the owner's record. This supervisor records its - # own group beside that record, so readback can prove both exited. - record_path = host_process_supervisor_record(record_path) - supervises = HOST_PROCESS_SUPERVISES_NESTED_HOST - supervision = "leased" + bridge_environment, record_value, record_path, supervision, supervises = _bridge_record_scope(environment, delegated_lease) launch_budget = max(1.0, timeout_seconds) if timeout_seconds is not None else 30.0 launch_deadline = time.monotonic() + launch_budget if launch_guard is not None else None launch_arguments = (["--guarded-launch", str((time.time() + launch_budget) * 1000)] @@ -435,3 +417,27 @@ def run_host_process( if proc.returncode != 0 or result is None: raise RuntimeError("Managed Host process supervision returned no result") return result + + +def _bridge_record_scope(environment, delegated_lease): + """Separate the supervisor's private record namespace from its child Host.""" + # The bridge runs on the caller's environment, which may pin the release the + # Host must run and name the record its supervisor owns. Copy it instead of + # replacing it with the ambient one, and consume the record env from the + # copy only: the Host below must not inherit a marker for its supervisor's + # record, and the caller's mapping is never mutated. + bridge_environment = os.environ.copy() if environment is None else dict(environment) + record_value = bridge_environment.pop(HOST_PROCESS_RECORD_ENV, "") + supervision = bridge_environment.pop(HOST_PROCESS_PARENT_ENV, "direct") + if supervision not in {"direct", "leased"}: + raise ValueError("invalid managed Host supervision scope") + record_path = Path(record_value) if record_value else None + supervises = HOST_PROCESS_SUPERVISES_HOST + if record_path is not None and delegated_lease is not None: + # A leased run starts the private leased CLI, which records the actual + # Host it starts under the owner's record. This supervisor records its + # own group beside that record, so readback can prove both exited. + record_path = host_process_supervisor_record(record_path) + supervises = HOST_PROCESS_SUPERVISES_NESTED_HOST + supervision = "leased" + return bridge_environment, record_value, record_path, supervision, supervises diff --git a/loopx/control_plane/turn_driver/recovery.py b/loopx/control_plane/turn_driver/recovery.py index a934d350f5..b9380adf32 100644 --- a/loopx/control_plane/turn_driver/recovery.py +++ b/loopx/control_plane/turn_driver/recovery.py @@ -2,9 +2,12 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from ..goals.checkpoint_context_io import TurnResultWork from .session_recovery import ( SessionBindingResolver, @@ -124,3 +127,176 @@ def build_turn_recovery_audit( "host_invoked": host_invoked, }, } + + +def preview_turn_recovery(plan: Mapping[str, Any], host_projection: Mapping[str, Any], *, + journal: Mapping[str, Any] | None, request: Mapping[str, Any], + goal_id: str, turn_key: str, retry_failed: bool, + session_binding_resolver: SessionBindingResolver | None, + empty_effects: Mapping[str, bool]) -> dict[str, Any]: + """Read-only preview of the same retained-Turn recovery used by execution.""" + from .execution_readback import execution_payload + from .journal_store import LOOPX_TURN_JOURNAL_SCHEMA_VERSION + preview = { + "schema_version": LOOPX_TURN_JOURNAL_SCHEMA_VERSION, + "status": "preview", + "host": host_projection, + "result_kind": None, + "receipt": None, + "scheduler": {"disposition": "not_evaluated"}, + } + # A retained Turn may only need settlement. Project the same typed + # recovery owner that execution consumes, without changing its journal + # or calling any execution callback. A new Turn keeps the original plan. + preview_plan = plan + if journal is not None: + envelope = plan.get("turn_envelope") + assessment = assess_existing_turn_recovery( + journal, request, goal_id=goal_id, + agent_id=str(envelope.get("agent_id") or "") + if isinstance(envelope, Mapping) else "", + turn_key=turn_key, + retry_failed=retry_failed, + session_binding_resolver=session_binding_resolver, + ) + preview_plan = {**plan, "route": { + **plan["route"], + "would_invoke_host": assessment.decision["reinvoke_host"], + }} + preview["recovery_audit"] = build_turn_recovery_audit( + assessment.decision, journal, status="finished", host_invoked=False + ) + return execution_payload( + preview_plan, + preview, + execute=False, + replayed=False, + effects=empty_effects, + ) + + +def prepare_terminal_host_recovery(journal: Mapping[str, Any] | None, request: dict[str, Any], *, + plan: Mapping[str, Any], goal_id: str, turn_key: str, + result_work: TurnResultWork | None, + session_binding_resolver: SessionBindingResolver | None) -> tuple[dict[str, Any], bool]: + """Restore only unfinished terminal validation and its original request.""" + validation_reinvokes_host = _validation_reinvokes_host(journal) + terminal_retry = (journal or {}).get("result_revalidation") or {} + terminal_view = terminal_retry.get("validation", {}) + if terminal_retry.get("phase") == "terminal_closeout" and terminal_view.get("status") == "failed": + assessment = assess_existing_turn_recovery({"schema_version": journal["schema_version"], + "goal_id": goal_id, "turn_key": turn_key, "plan": journal["plan"], **terminal_view}, request, + goal_id=goal_id, agent_id=str(plan["turn_envelope"]["agent_id"]), turn_key=turn_key, + retry_failed=True, session_binding_resolver=session_binding_resolver) + request = require_turn_recovery_continuation(assessment) + terminal_work_changed = (result_work is not None and terminal_retry.get("phase") == "terminal_closeout" + and terminal_retry.get("validation", {}).get("work_context") != result_work.read()) + needs_host = ( + validation_reinvokes_host + or journal is None + or "typed_result" not in list(journal.get("completed_phases") or []) + or terminal_work_changed + or isinstance(journal.get("result_revalidation"), dict) and + journal["result_revalidation"].get("phase") == "terminal_closeout" and + ("typed_result" not in journal["result_revalidation"].get("validation", {}).get("completed_phases", []) or + journal["result_revalidation"].get("validation", {}).get("status") == "failed") + ) + return request, needs_host + + +def _validation_reinvokes_host(journal: Mapping[str, Any] | None) -> bool: + receipt = journal.get("receipt") if isinstance(journal, Mapping) else None + return ( + isinstance(journal, Mapping) + and journal.get("status") == "failed" + and isinstance(receipt, Mapping) + and receipt.get("failed_phase") == "validation" + and journal.get("validation_stage") != "task_postcondition" + ) + + +def reset_failed_turn_for_retry(journal: dict[str, Any] | None, + persist_journal: Callable[[Mapping[str, Any]], None]) -> None: + """Reset failed transport phases while retaining committed result facts.""" + from .transaction import TRANSACTION_PHASES + if journal and journal.get("status") == "failed": + receipt = ( + journal.get("receipt") + if isinstance(journal.get("receipt"), dict) + else {} + ) + if receipt.get("failed_phase") == "validation": + if _validation_reinvokes_host(journal): + journal.pop("host_result", None) + journal.pop("result_kind", None) + journal["completed_phases"] = ( + list(TRANSACTION_PHASES[:2]) + if isinstance(journal.get("host_result"), dict) + else [] + ) + journal.pop("task_validation", None) + journal.pop("validation_stage", None) + journal.pop("reason", None) + journal.pop("receipt", None) + journal.pop("host_recovery", None) + journal.pop("host_failure", None) + journal["status"] = "in_progress" + persist_journal(journal) + + +def recover_existing_turn(plan: Mapping[str, Any], journal: Mapping[str, Any], + recovery_decision: Mapping[str, Any], *, empty_effects: Mapping[str, bool], + post_settlement: Callable[..., dict[str, Any]] | None) -> dict[str, Any]: + """Read and recover only the already settled Turn's post-settlement work.""" + from .execution_readback import execution_payload + from .post_settlement import recover_post_settlement + payload = execution_payload( + plan, + journal, + execute=True, + replayed=True, + effects=empty_effects, + ) + payload.update(recover_post_settlement( + plan=plan, post_settlement=post_settlement, journal=journal, + )) + payload["recovery"] = build_turn_recovery_audit( + recovery_decision, + journal, + status="finished", + host_invoked=False, + ) + return payload + + +def admit_unfinished_host(plan: Mapping[str, Any], *, needs_host: bool, + admit_start: Callable[[Mapping[str, Any]], dict[str, Any]] | None, + journal: Mapping[str, Any] | None, turn_key: str, + host_projection: Mapping[str, Any], empty_effects: Mapping[str, bool] + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Reserve cadence only for a real unfinished Host attempt, before writes.""" + from .execution_readback import execution_payload + admission = None + if needs_host and admit_start is not None: + admission = admit_start( + { + "turn_key": turn_key, + "attempt": int(journal.get("host_attempt_count") or 0) + 1 + if journal is not None + else 1, + } + ) + if admission.get("admitted") is not True: + waiting = { + "status": "interval_wait", + "host": host_projection, + "completed_phases": [], + "reason": str( + admission.get("reason") or "automatic start not admitted" + ), + "admission": admission, + } + return admission, execution_payload( + plan, waiting, execute=True, replayed=False, effects=empty_effects + ) + return admission, None diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index c12a04eb86..bf2afd51cd 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -570,6 +570,7 @@ def qualify_refresh_replan_writeback( replan_explore_reader: Callable[[str, str | None], Mapping[str, Any] | None] | None = None, capability_evidence: CapabilityReplanEvidence | None = None, guard_capability: Mapping[str, Any] | None = None, + defer_until_direction: bool = False, ) -> RefreshReplanQualification: """Qualify one refresh's replan delta and accountable settlement outcome.""" @@ -620,7 +621,9 @@ def qualify_refresh_replan_writeback( ), ) - semantic_delta = enforce_open_replan_writeback( + # Pending checkpoint is a retained result, not a semantic ACK. The same + # bound obligation is qualified after the current Agent reads that result. + semantic_delta = None if defer_until_direction else enforce_open_replan_writeback( capability_evidence=capability_evidence, guard_capability=guard_capability, newest_first_runs=newest_first_runs, state_text=state_text, diff --git a/loopx/project_prompt.py b/loopx/project_prompt.py index a96edde69b..f71443e6fc 100644 --- a/loopx/project_prompt.py +++ b/loopx/project_prompt.py @@ -156,7 +156,9 @@ def render_available_capability_args(values: Any) -> str: ) -def render_cli_preflight(*, cli_bin: str = "loopx") -> str: +def render_cli_preflight(*, cli_bin: str = "loopx", thin: bool = False) -> str: + if thin: + return "" return install_contract.doctor_preflight_command(cli_bin=cli_bin) diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index e0b0d87af6..0432d4e481 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1164,14 +1164,16 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): # Retained identity cannot override a later explicit policy revision. usable_source = retained_source if retained_source and retained_source.get("cadence") == guard_cadence(registry_path,runtime_root,safe_goal_id) else None from .control_plane.goals.checkpoint_context_io import _checkpoint_effect + requested_replan = autonomous_replan_recorded or bool(settlement_replan_guard.get('selected_obligation_id')) replan_stage = _checkpoint_effect("goal.checkpoint_read_context.work_binding", { "phase": "replan_stage", "heartbeat_receipt": settlement_readback.heartbeat_receipt if settlement_readback else None, - "prior": prior_writeback_run, "requested": autonomous_replan_recorded, + "prior": prior_writeback_run, "requested": requested_replan, "has_read_context": bool(checkpoint_read_context_id), - }) if default_checkpoint and (autonomous_replan_recorded or + }) if default_checkpoint and (requested_replan or (prior_writeback_run or {}).get("vision_checkpoint", {}).get("replan_stage")) else { "deferred": False, "qualify": autonomous_replan_recorded} replan_qualification = qualify_refresh_replan_writeback( + defer_until_direction=replan_stage['deferred'], replan_explore_reader=read_explore_context, replan_source_route={"registry": str(registry_path), "runtime_root": str(runtime_root)}, capability_evidence=capability_evidence, @@ -1263,6 +1265,7 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): if ( normalized_delivery_outcome in ACCOUNTABLE_DELIVERY_OUTCOMES and not checkpoint_supplement + and not replan_stage['deferred'] ): require_accountable_completion_validation( state_text, diff --git a/tests/control_plane/test_direction_stop_reentry.py b/tests/control_plane/test_direction_stop_reentry.py new file mode 100644 index 0000000000..74f774985b --- /dev/null +++ b/tests/control_plane/test_direction_stop_reentry.py @@ -0,0 +1,156 @@ +"""Explicit recovery of a retained result must finish only its direction.""" +from __future__ import annotations + +import json +import copy +from contextlib import nullcontext +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context, _receipt_path +from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.configure_goal import configure_goal +from tests.control_plane.test_default_turn_recovery import ( + _managed_recovery_call, _work_counts, _business_rows, +) +from tests.test_goal_mode_mcp_settlement import GOAL_ID + + +def _stopped(provider, kind, tmp_path, monkeypatch, *, stop_reason=None): + state, project, args, _ = _managed_recovery_call(provider, 'none', tmp_path, monkeypatch) + host = project / 'host.py' + stop = {'result_kind': kind, **({'stop_reason': stop_reason} if stop_reason else {})} + host.write_text(host.read_text().replace(" count('direction.txt')", " count('direction.txt')\n" + " if not pathlib.Path('resume-ready').exists():\n" + f" json.dump({stop!r},sys.stdout); raise SystemExit(0)"), encoding='utf-8') + command = [sys.executable, '-m', 'loopx.cli', *args] + stopped = subprocess.run(command, capture_output=True, text=True, timeout=90) + assert stopped.returncode == 1, stopped.stdout + stopped.stderr + runtime = tmp_path / 'runtime' + journal_path = next(p for p in (runtime / 'goals' / GOAL_ID / 'turns').glob('*.json') + if not p.name.endswith('.holder.json')) + journal = json.loads(journal_path.read_text()) + return state, project, args, command, runtime, journal_path, journal + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('kind,reread', [ + ('wait', False), ('user_action_required', False), ('iteration_failed', False), ('wait', True), +]) +def test_explicit_direction_reentry_retains_original_result(provider, kind, reread, tmp_path, monkeypatch): + _, project, args, command, runtime, journal_path, journal = _stopped(provider, kind, tmp_path, monkeypatch) + original_result = journal['host_result'] + resume = [*command, '--resume-turn-key', journal['turn_key']] + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + assert _business_rows(runtime)['quota_slot_spent'] == 0 + (project / 'resume-ready').touch() + implicit = subprocess.run(resume, capture_output=True, text=True, timeout=90) + assert implicit.returncode == 1, implicit.stdout + implicit.stderr + assert _work_counts(project)['direction.txt'] == 1 + raw_identity = journal['plan']['transaction']['settlement_plan']['identity'] + identity = SettlementIdentity(**{key: raw_identity.get(key) for key in ( + 'goal_id', 'agent_id', 'todo_id', 'turn_instance_id', 'replan_obligation_id')}) + if reread: + context = read_checkpoint_context(registry_path=Path(args[args.index('--registry') + 1]), + runtime_root_override=str(runtime), goal_id=identity.goal_id, agent_id=identity.agent_id, + todo_id=identity.todo_id, replan_obligation_id=identity.replan_obligation_id, + turn_instance_id=identity.turn_instance_id, purpose='first_delivery') + assert context['read_context_id'] + assert json.loads(_receipt_path(runtime, identity).read_text())['direction_stopped'] is True + assert _work_counts(project)['direction.txt'] == 1 + recovered = subprocess.run([*resume, '--retry-failed-turn'], capture_output=True, text=True, timeout=90) + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + assert json.loads(recovered.stdout)['status'] == 'committed' + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 2} + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + assert json.loads(journal_path.read_text())['host_result'] == original_result + replay = subprocess.run(resume, capture_output=True, text=True, timeout=90) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert _work_counts(project)['direction.txt'] == 2 + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +@pytest.mark.parametrize('gate', ['pause', 'stop', 'permission']) +def test_stopped_direction_rechecks_current_gate_before_reentry(provider, gate, tmp_path, monkeypatch): + _, project, args, command, runtime, journal_path, journal = _stopped(provider, 'wait', tmp_path, monkeypatch, + stop_reason='permission_required' if gate == 'permission' else None) + registry = Path(args[args.index('--registry') + 1]) + before = registry.read_bytes() + if gate == 'pause': + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=0, execute=True)['written'] + elif gate == 'stop': + doc = json.loads(before) + doc['goals'][0]['activation'] = {'schema_version': 'loopx_goal_activation_v1', 'state': 'stopped', + 'updated_at': '2026-10-11T00:00:00Z', 'reason': 'Fixture owner stopped execution.'} + registry.write_text(json.dumps(doc), encoding='utf-8') + (project / 'resume-ready').touch() + retry = [*command, '--resume-turn-key', journal['turn_key'], '--retry-failed-turn'] + denied = subprocess.run(retry, capture_output=True, text=True, timeout=90) + assert denied.returncode == 1, denied.stdout + denied.stderr + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 1} + assert _business_rows(runtime)['quota_slot_spent'] == 0 + assert json.loads(journal_path.read_text())['host_result'] == journal['host_result'] + if gate != 'permission': + if gate == 'pause': + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, quota_compute=1, execute=True)['written'] + else: + registry.write_bytes(before) + recovered = subprocess.run(retry, capture_output=True, text=True, timeout=90) + assert recovered.returncode == 0, recovered.stdout + recovered.stderr + assert _work_counts(project) == {'implementation.txt': 1, 'validation.txt': 1, 'direction.txt': 2} + assert _business_rows(runtime) == {'candidate_verified': 1, 'quota_slot_spent': 1} + + +@pytest.mark.parametrize('kind', ['wait', 'user_action_required', 'iteration_failed']) +def test_valid_stop_supersedes_transport_failure_without_resetting_attempt_budget(kind, tmp_path, monkeypatch): + from loopx.control_plane.turn_driver import checkpoint_continuation as cp + from loopx.control_plane.turn_driver.host_failure import BuiltInHostError + + identity = SimpleNamespace(goal_id='reentry-goal', as_dict=lambda: {}) + receipt = {'purpose': 'first_delivery', 'read_context_id': 'original-read', + 'decision_basis': {}, 'vision_authoring': {}} + for name, value in { + '_receipt_path': lambda *_: SimpleNamespace(exists=lambda: True), + '_read_context_receipt': lambda *_: copy.deepcopy(receipt), + '_inspect_attempt': lambda *_: {'status': 'not_started'}, + '_resolve': lambda *a, **kw: {'ok': True}, + '_local_source_facts': lambda *_: {}, + '_source_guard': lambda *_: nullcontext(), + 'exclusive_file_lock': lambda *_: nullcontext(), + 'exclusive_cross_runtime_file_lock': lambda *a, **kw: nullcontext(), + }.items(): + monkeypatch.setattr(cp, name, value) + monkeypatch.setattr(cp.CurrentAgentCheckpoint, 'enabled', lambda _: True) + + def save(self, path, value): + receipt.clear() + receipt.update(copy.deepcopy(value)) + monkeypatch.setattr(cp.CurrentAgentCheckpoint, '_save', save) + calls = [] + def host(context): + calls.append(context['read_context_id']) + if len(calls) == 2: + return {'result_kind': kind} + raise BuiltInHostError('Synthetic capacity refusal', failure_kind='provider_capacity') + + driver = cp.CurrentAgentCheckpoint(tmp_path/'registry', tmp_path, tmp_path/'state', identity, host) + with pytest.raises(ValueError, match='capacity refusal'): + driver.continue_direction({}) + with pytest.raises(ValueError, match='stopped direction'): + driver.continue_direction({}) + assert receipt['direction_host_attempt_count'] == 2 + assert 'direction_host_failure' not in receipt + assert receipt['direction_stops'] == [{'attempt': 2, 'result_kind': kind}] + driver.explicit_resume = True + with pytest.raises(ValueError, match='capacity refusal'): + driver.continue_direction({}) + assert receipt['direction_host_attempt_count'] == receipt['direction_host_failure']['attempt'] == 3 + with pytest.raises(cp.CheckpointReadContextRejected) as rejected: + driver.continue_direction({}) + assert rejected.value.code == 'host_retry_budget_exhausted' + assert calls == ['original-read'] * 3 diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index 66e86e3d51..ab5780a9a3 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -222,6 +222,41 @@ test("default direction binds the post-result input and response without relabel assert.equal(check(direction.receipt, {purpose: "first_delivery", facts: display}).error_code, "checkpoint_read_context_stale"); }); +test("only explicit recovery rearms a known temporary direction stop without resetting attempts", () => { + const receipt = {direction_stopped: true, direction_host_attempt_count: 3, + direction_stops: [{attempt: 1, result_kind: "wait"}, {attempt: 3, result_kind: "user_action_required"}]}; + const request = {phase: "direction_dispatch", receipt, explicit_resume: true}; + assert.equal(work(request).attempt, 4); + assert.equal(work({...request, explicit_resume: false}).ok, false); + for (const stops of [[], null, [{attempt: 4, result_kind: "wait"}], + [{attempt: 1, result_kind: "future"}], [{attempt: 1, result_kind: "wait", stop_reason: "user_stop"}]]) { + assert.equal(work({...request, receipt: {...receipt, direction_stops: stops}}).ok, false); + } + assert.equal(work({...request, receipt: {direction_stopped: true}}).ok, false); + const exhausted = {schema_version: "loopx_turn_host_failure_v0", kind: "executor_timeout", attempt: 3, + retryable: true, retry: {strategy: "same_configuration", max_attempts: 2, backoff_seconds: 20}}; + assert.equal(work({...request, receipt: {...receipt, direction_host_failure: exhausted}}).ok, false); +}); + +test("direction launch reuses current quota gates and exact original recovery identity", () => { + const current = {ok: true, should_run: true, status_health_ok: true, goal_id: identity.goal_id, + requires_user_action: false, blocked_action_scope: null, quota: {state: "eligible"}, + effective_action: "unsettled_host_turn_recovery", unsettled_host_turn_recovery: { + scope: "current_turn", repair: "durable_writeback", turn_instance_id: identity.turn_instance_id, + binding_kind: "todo", binding_id: identity.todo_id}}; + const request = {phase: "direction_admission", current, identity, planned_effective_action: "normal_run"}; + assert.equal(work(request).ok, true); + for (const patch of [{ok: false}, {should_run: false}, {status_health_ok: false}, + {requires_user_action: true}, {requires_user_action: null}, {quota: {state: "exhausted"}}, + {quota: {state: "paused"}}, {quota: null}, {blocked_action_scope: "write"}, + {goal_id: "other"}, {effective_action: "normal_run"}, + {unsettled_host_turn_recovery: {...current.unsettled_host_turn_recovery, repair: "quota_spend"}}, + {unsettled_host_turn_recovery: {...current.unsettled_host_turn_recovery, turn_instance_id: "other"}}, + {unsettled_host_turn_recovery: {...current.unsettled_host_turn_recovery, binding_id: "other"}}]) { + assert.equal(work({...request, current: {...current, ...patch}}).ok, false); + } +}); + test("default work input survives cold restore and validation cannot be relabelled", () => { const result = read({purpose: "delivery_result", admitted_turn: true, prior: null, result_only: true}); const receipt = result.receipt as JsonObject; diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index b11c0fcdee..76a7d87ef6 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -29,6 +29,7 @@ import { QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlement, readQuotaSettlementSnapshot, + selectDefaultResultWorkTurn, } from "../../loopx/control_plane/quota/settlement_readback.ts"; import { PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, @@ -483,6 +484,30 @@ function request(runtimeRoot: string, overrides: Record = {}) { }; } +test("work inference excludes only settled selected planning, preserving ordinary progress recovery", async () => { + for (const planned of [false, true]) { + const root = await fixture({writeback: true, spend: true, + visionCheckpoint: {schema_version: "vision_checkpoint_v0", agent_id: agentId, + satisfied: true, delivery_boundary: "semantic_closeout", + triggers: [{kind: "material_delivery_outcome", delivery_outcome: "outcome_progress"}]}, + ...(planned ? {autonomousReplanAck: {schema_version: "autonomous_replan_ack_v0", + recorded: true, semantic_delta: {schema_version: "replan_semantic_delta_v0", accepted: true}}} : {}), + }); + try { + const path = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const events = (await readFile(path, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const guard = events.find(event => event.event_kind === "quota_should_run"); + guard.details.result_freshness = "turn_result_freshness_required_v1"; + guard.details.semantic_replan_obligation_id = planned ? "replan-0000000000000001" : null; + await writeFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n"); + assert.equal((await readQuotaSettlement(request(root))).replay_phase, "settled"); + const selection = request(root, {todo: {status: "open"}}); + assert.equal((await selectDefaultResultWorkTurn(selection)).turn_instance_id, planned ? null : turnId); + assert.equal((await selectDefaultResultWorkTurn({...selection, pending_only: true})).turn_instance_id, null); + } finally { await rm(root, {recursive: true, force: true}); } + } +}); + function sourceGuardPath(registryPath: string): string { const digest = createHash("sha256").update(goalId, "utf8").digest("hex"); return join( From 98404d701cd8b06f54f24f85a9b62cc51c3231b6 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 22:06:58 +0800 Subject: [PATCH 41/80] test(checkpoint): reconcile recovery fixtures and validation budgets Freeze the historical canonical/domain field manifests, repair Windows file URL/JSON/extension transports and use the existing long-path adapter for owned canary directories and saved import-plan readback. Complete the peer fixture's actual direction and once-only settlement before its next admission. Use legal selected replanning before admitting changed-frontier work; preserve the original negative assertion and all original result/debit counter checks. That assertion's post-replan premise remains unresolved, so the four affected terminal/frontier cases are not claimed passing. Record measured module ceiling changes and an existing cadence-reader exception without raising the default executor or function budgets. First full core: 100 passed, 7 failed, 1 teardown error; failed-only cases pass after the helper import repair. Original recovery: 70 passed, 2 failed, 1 teardown error; failed-only rerun: 2 passed, 1 teardown error. Windows OS-exit qualification remains held. Historical failures are not converted into full-suite passes. Signed-off-by: Tartar --- .../heartbeat-quota-flow-smoke.py | 19 ++- .../hot-path-interface-budget-smoke.py | 3 +- ...-agent-continuation-state-machine-smoke.py | 31 ++++ loopx/canary/maintainability_ratchet.py | 6 + loopx/canary/module_metric_baseline.json | 6 +- loopx/control_plane/testing/canary_harness.py | 26 +++- .../fixtures/todo_read_fields_a3266402.json | 145 ++++++++++++++++++ .../test_checkpoint_read_context.py | 2 +- .../test_default_terminal_admission_gates.py | 4 +- .../test_default_terminal_frontier.py | 79 +++++++++- .../host_stdio_bridge.test.ts | 3 +- .../local_authority_shadow_outbox.test.ts | 16 +- .../retained_lease_receipts.test.ts | 2 +- 13 files changed, 315 insertions(+), 27 deletions(-) create mode 100644 tests/control_plane/fixtures/todo_read_fields_a3266402.json diff --git a/examples/control_plane/heartbeat-quota-flow-smoke.py b/examples/control_plane/heartbeat-quota-flow-smoke.py index 06a852430f..4f74c83c55 100644 --- a/examples/control_plane/heartbeat-quota-flow-smoke.py +++ b/examples/control_plane/heartbeat-quota-flow-smoke.py @@ -7,14 +7,13 @@ import shlex import subprocess import sys -import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) -from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.testing.canary_harness import import_fixture_authority, temporary_canary_directory # noqa: E402 GOAL_ID = "heartbeat-flow-main-control" LONG_NEXT_ACTION_TAIL = "without truncating the final private-safe boundary sentence." @@ -642,7 +641,7 @@ def fake_codex_executor_decision(guard: dict) -> dict: def main() -> int: - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-quota-flow-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-quota-flow-") as tmp: root = Path(tmp) project, runtime, registry_path = write_fixture(root) registry_before = registry_path.read_text(encoding="utf-8") @@ -749,7 +748,7 @@ def main() -> int: assert "protocol_action_packet" not in follow_up, follow_up assert registry_path.read_text(encoding="utf-8") == registry_before - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-operator-gate-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-operator-gate-") as tmp: root = Path(tmp) project, runtime, registry_path = write_operator_gate_fixture(root) gate_guard = run_cli( @@ -774,7 +773,7 @@ def main() -> int: assert interaction["cli_channel"]["spend_after_validation"] is False, interaction assert interaction["fallback_policy"]["do_not_cancel_on_block"] is True, interaction - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-monitor-poll-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-monitor-poll-") as tmp: root = Path(tmp) project, runtime, registry_path = write_monitor_fixture(root) first_guard = run_cli( @@ -974,7 +973,7 @@ def main() -> int: heartbeat_turn_two ) - with tempfile.TemporaryDirectory(prefix="loopx-heartbeat-monitor-replan-surface-only-") as tmp: + with temporary_canary_directory(prefix="loopx-heartbeat-monitor-replan-surface-only-") as tmp: root = Path(tmp) project, runtime, registry_path = write_monitor_fixture(root) first_guard = run_cli( @@ -1031,7 +1030,7 @@ def main() -> int: assert spend["ok"] is False, spend assert count_spend_events(runtime) == 0, spend - with tempfile.TemporaryDirectory(prefix="loopx-external-evidence-observation-") as tmp: + with temporary_canary_directory(prefix="loopx-external-evidence-observation-") as tmp: root = Path(tmp) project, runtime, registry_path = write_external_evidence_fixture(root) guard = run_cli( @@ -1109,7 +1108,7 @@ def main() -> int: assert count_spend_events(runtime) == 0, poll assert count_events(runtime, "quota_monitor_poll") == 1, poll - with tempfile.TemporaryDirectory(prefix="loopx-external-monitor-advancement-") as tmp: + with temporary_canary_directory(prefix="loopx-external-monitor-advancement-") as tmp: root = Path(tmp) project, runtime, registry_path = write_external_monitor_advancement_fixture(root) guard = run_cli( @@ -1156,7 +1155,7 @@ def main() -> int: assert count_spend_events(runtime) == 0, poll assert count_events(runtime, "quota_monitor_poll") == 1, poll - with tempfile.TemporaryDirectory(prefix="loopx-scoped-future-external-monitor-") as tmp: + with temporary_canary_directory(prefix="loopx-scoped-future-external-monitor-") as tmp: root = Path(tmp) project, runtime, registry_path = write_scoped_future_external_monitor_fixture(root) guard = run_cli( @@ -1187,7 +1186,7 @@ def main() -> int: assert guard.get("replan_action_packet") is None, guard assert guard.get("autonomous_replan_obligation") is None, guard - with tempfile.TemporaryDirectory(prefix="loopx-external-evidence-projection-") as tmp: + with temporary_canary_directory(prefix="loopx-external-evidence-projection-") as tmp: root = Path(tmp) project, runtime, registry_path = write_external_evidence_fixture( root, diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 94043b3285..5c459b83ed 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -246,7 +246,8 @@ def install_ready_presentation_surface(root: Path) -> None: [runtime] protocol = "budget_presentation_extension_v0" -entrypoint = {json.dumps(str(provider))} +entrypoint = {json.dumps(sys.executable)} +args = [{json.dumps(str(provider))}] doctor_args = ["--doctor"] required_permissions = [] timeout_seconds = 5 diff --git a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py index c347d273c3..5e35983a42 100644 --- a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py +++ b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py @@ -4,6 +4,7 @@ from __future__ import annotations import sys +import json import tempfile from pathlib import Path @@ -141,6 +142,36 @@ def assert_same_peer_continuation() -> None: assert completed["completion_policy"]["self_merged"] is True, completed successor = completed["next_todos"][0] assert successor["claimed_by"] == PEER_ALPHA, successor + # Native completion preserves the result but does not settle the + # admitted Turn. Finish its real writeback before selecting a successor. + turn = first['heartbeat_receipt']['turn_instance_id'] + from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context + context = read_checkpoint_context(registry_path=registry_path, + runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=PEER_ALPHA, + todo_id=START_TODO_ID, turn_instance_id=turn, purpose='first_delivery') + assert context['basis']['todo']['status'] == 'done' + vision = project / 'peer-direction.json' + vision.write_text(json.dumps({ + 'schema_version': 'goal_vision_replan_contract_v0', 'state': 'vision_closed', + 'vision_patch': {'vision_summary': 'Continue the peer lane after the verified first slice.', + 'acceptance_summary': 'The first slice is verified; the same peer owns the explicit successor.'}, + 'path_delta': {'outcome': 'continue', + 'prior_assumption': 'The first slice must precede its successor.', + 'observed_reality': 'Native completion retained the verified slice and created its same-peer successor.', + 'retained': ['The original peer ownership and the independently verified first slice.'], + 'evidence_refs': [f'todo:{START_TODO_ID}', f'todo:{successor["todo_id"]}']}}), encoding='utf-8') + binding = ('--goal-id', GOAL_ID, '--agent-id', PEER_ALPHA, + '--todo-id', START_TODO_ID, '--turn-instance-id', turn) + refreshed = run_cli('refresh-state', *binding, '--classification', 'peer_slice_verified', + '--delivery-batch-scale', 'implementation', '--delivery-outcome', 'outcome_progress', + '--agent-vision-json', str(vision), '--no-global-sync', '--suppress-external-sinks', + '--first-delivery', '--checkpoint-read-context', context['read_context_id'], + registry_path=registry_path, runtime=runtime, cwd=project) + assert refreshed['ok'] is True, refreshed + for replay in (False, True): + spent = run_cli('quota', 'spend-slot', *binding, '--slots', '1', '--source', 'heartbeat', '--execute', + registry_path=registry_path, runtime=runtime, cwd=project) + assert spent['appended'] is (not replay), spent second = should_run(project, runtime, registry_path, PEER_ALPHA) assert second["agent_lane_next_action"]["todo_id"] == successor["todo_id"], second diff --git a/loopx/canary/maintainability_ratchet.py b/loopx/canary/maintainability_ratchet.py index ebad2d40c4..a6c350c5e1 100644 --- a/loopx/canary/maintainability_ratchet.py +++ b/loopx/canary/maintainability_ratchet.py @@ -49,6 +49,12 @@ def _exception( REVIEWED_MAINTAINABILITY_EXCEPTIONS: dict[str, dict[str, Any]] = { + "dependency_debt:loopx.control_plane.quota.heartbeat_receipt->loopx.capabilities.todo_replan_cadence.machine_defaults": _exception( + "The receipt's cadence witness reads the existing machine-configuration owner; " + "it neither creates cadence policy nor grants execution or settles a Turn.", + "Inject the resolved cadence at the CLI/source composition boundary when the " + "receipt API migrates; retain exact current-policy equality during that move.", + ), "compatibility_facade:loopx.quota": _exception( "The public loopx.quota import surface remains a supported compatibility contract, " "including presentation-owned quota event renderers.", diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index c31fcc6bc9..4d6d759f1f 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -31,12 +31,12 @@ "dict_any_count": 0 }, "loopx/chat_agent.py": { - "lines": 2007 + "lines": 2034 }, "loopx/chat_runtime.py": { "any_count": 37, "dict_any_count": 0, - "lines": 2333 + "lines": 2336 }, "loopx/chat_server.py": { "any_count": 25, @@ -44,7 +44,7 @@ "lines": 2026 }, "loopx/chat_store.py": { - "lines": 2066 + "lines": 2069 }, "loopx/codex_cli_probe.py": { "any_count": 37, diff --git a/loopx/control_plane/testing/canary_harness.py b/loopx/control_plane/testing/canary_harness.py index c1599112f1..4c8927c8aa 100644 --- a/loopx/control_plane/testing/canary_harness.py +++ b/loopx/control_plane/testing/canary_harness.py @@ -1,9 +1,13 @@ from __future__ import annotations import json +import os +import shutil import subprocess import sys from datetime import datetime +import tempfile +from contextlib import contextmanager from pathlib import Path from typing import Any, Iterable @@ -11,6 +15,25 @@ REPO_ROOT = Path(__file__).resolve().parents[3] +@contextmanager +def temporary_canary_directory(*, prefix: str): + """Keep fixture cleanup valid for canonical Windows authority paths.""" + with tempfile.TemporaryDirectory(prefix=prefix) as name: + owned = Path(name).resolve() + try: + yield name + finally: + if os.name == 'nt': + from ..runtime.file_paths import windows_extended_path + + # Cold-import receipts can exceed MAX_PATH. Delete only the + # exact directory we created, using the existing path adapter. + target = Path(name) + if target.is_symlink() or target.resolve() != owned: + raise RuntimeError('Canary directory identity changed before cleanup') + shutil.rmtree(windows_extended_path(owned)) + + def default_state_file(goal_id: str) -> str: # Legacy route is intentional: existing characterization fixtures create # state there and declare it explicitly in their synthetic registries. @@ -197,6 +220,7 @@ def import_fixture_authority(registry_path: Path, goal_id: str, *, provider: str Existing unpromoted/refusal fixtures opt in explicitly, never by default. """ from ...state_backup import build_state_backup_plan, execute_state_backup_plan + from ..runtime.file_paths import windows_extended_path registry_path = registry_path.resolve() registry = json.loads(registry_path.read_text(encoding="utf-8")) @@ -228,7 +252,7 @@ def import_fixture_authority(registry_path: Path, goal_id: str, *, provider: str )["cold_import"] assert prepared["status"] == "prepared", prepared # Read the saved carrier rather than manufacturing a digest/target. - plan = json.loads(Path(prepared["plan_path"]).read_text(encoding="utf-8")) + plan = json.loads(windows_extended_path(Path(prepared["plan_path"])).read_text(encoding="utf-8")) assert plan["plan_sha256"] == prepared["plan_sha256"], plan applied = run_json_cli( *prefix, "apply-import", *identity, diff --git a/tests/control_plane/fixtures/todo_read_fields_a3266402.json b/tests/control_plane/fixtures/todo_read_fields_a3266402.json new file mode 100644 index 0000000000..deae84401b --- /dev/null +++ b/tests/control_plane/fixtures/todo_read_fields_a3266402.json @@ -0,0 +1,145 @@ +{ + "source_commit": "a326640280507e7e6dcd5adba8ce706bd03b219f", + "canonical_fields": [ + "index", + "done", + "text", + "schema_version", + "todo_id", + "role", + "status", + "priority", + "title", + "archive_state", + "source_section", + "task_class", + "action_kind", + "task_domain", + "capability_binding_ref", + "task_repository", + "continuation_policy", + "removed_continuation_policy", + "required_write_scopes", + "required_capabilities", + "target_capabilities", + "explore_result_node_refs", + "decision_scope", + "required_decision_scopes", + "decision_outcome", + "decision_scope_outcomes", + "claimed_by", + "created_by", + "last_actor_agent_id", + "bound_agent", + "goal_bound", + "blocks_agent", + "excluded_agents", + "global_gate", + "unblocks_todo_id", + "resume_when", + "resume_monitor_generation", + "resume_condition", + "resume_ready", + "no_followup", + "successor_todo_ids", + "completion_continuation", + "completion_recovery", + "completion_receipt_id", + "replan_obligation_id", + "target_key", + "cadence", + "next_due_at", + "expires_at", + "watch_only", + "last_checked_at", + "result_hash", + "consecutive_no_change", + "material_change", + "material_change_generation", + "max_no_change_before_replan", + "monitor_effect_id", + "note", + "evidence", + "reason", + "completed_at", + "completion_turn_key", + "completion_result", + "updated_at", + "superseded_by", + "completion_validation_required", + "completion_validation_sha256", + "completion_validation_revision", + "completion_validation_revision_history", + "handoff_note" + ], + "domain_fields": [ + "done", + "text", + "schema_version", + "todo_id", + "role", + "status", + "priority", + "title", + "archive_state", + "task_class", + "action_kind", + "task_domain", + "capability_binding_ref", + "task_repository", + "continuation_policy", + "removed_continuation_policy", + "required_write_scopes", + "required_capabilities", + "target_capabilities", + "explore_result_node_refs", + "decision_scope", + "required_decision_scopes", + "decision_outcome", + "decision_scope_outcomes", + "claimed_by", + "created_by", + "last_actor_agent_id", + "bound_agent", + "goal_bound", + "blocks_agent", + "excluded_agents", + "global_gate", + "unblocks_todo_id", + "resume_when", + "resume_monitor_generation", + "resume_condition", + "resume_ready", + "no_followup", + "successor_todo_ids", + "completion_continuation", + "completion_recovery", + "completion_receipt_id", + "replan_obligation_id", + "target_key", + "cadence", + "next_due_at", + "expires_at", + "watch_only", + "last_checked_at", + "result_hash", + "consecutive_no_change", + "material_change", + "material_change_generation", + "max_no_change_before_replan", + "monitor_effect_id", + "note", + "evidence", + "reason", + "completed_at", + "completion_turn_key", + "completion_result", + "updated_at", + "superseded_by", + "completion_validation_required", + "completion_validation_sha256", + "completion_validation_revision", + "completion_validation_revision_history", + "handoff_note" + ] +} diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py index e7c84f9a8b..d76d8c5b17 100644 --- a/tests/control_plane/test_checkpoint_read_context.py +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -266,7 +266,7 @@ def test_large_goal_context_keeps_complete_basis_and_checkpoint_only_recovery( @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkeypatch, provider): - from canonical_authority_fixture import advance_canonical_fixture_authority, isolate_sqlite_runtime + 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.coordination.local_authority_shadow_projection import canonical_bytes from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context diff --git a/tests/control_plane/test_default_terminal_admission_gates.py b/tests/control_plane/test_default_terminal_admission_gates.py index 1b1e323fb4..1a0cabe4ba 100644 --- a/tests/control_plane/test_default_terminal_admission_gates.py +++ b/tests/control_plane/test_default_terminal_admission_gates.py @@ -114,7 +114,9 @@ def test_real_direction_permission_signal_retains_result_and_blocks_retry(provid assert 'current_agent_decision' not in receipt assert receipt['decision_basis']['todo']['status'] == 'done' assert (project/'permission_requests.txt').read_text() == '1\n' - second = _resume(resume) + # Ordinary replay must not reprompt. Explicit retry is a separate owner + # decision, exercised after resolution in test_direction_stop_reentry. + second = _resume([arg for arg in resume if arg != '--retry-failed-turn']) assert second.returncode != 0, second.stdout+second.stderr _assert_preserved(before, _snapshot(project, runtime)) assert (project/'permission_requests.txt').read_text() == '1\n' diff --git a/tests/control_plane/test_default_terminal_frontier.py b/tests/control_plane/test_default_terminal_frontier.py index 02e51358fd..fa04b72a62 100644 --- a/tests/control_plane/test_default_terminal_frontier.py +++ b/tests/control_plane/test_default_terminal_frontier.py @@ -59,6 +59,64 @@ def business_snapshot(project, runtime): return {'counts': _work_counts(project), 'effects': dict(_business_rows(runtime)), 'candidate': (project / 'candidate.txt').read_bytes()} + +def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): + """A new dependency after terminal intent owes an actual route decision.""" + turn = guard['heartbeat_receipt']['turn_instance_id'] + identity = guard['heartbeat_receipt']['settlement_identity'] + assert identity['todo_id'] == new_todo + assert (project / 'new-dependency.txt').read_text() == 'delivered-v2' + assert not (project / 'new-proof.txt').exists(), 'Planning cannot claim the future validator result' + from tests.control_plane.test_quota_settlement_cli import _projected_cli_args + actions = guard['interaction_contract']['cli_channel']['next_cli_actions'] + packet = project / 'frontier-replan.json' + refresh = next(c for c in actions if 'refresh-state' in c).replace( + '', str(packet)) + for key, value in {'': 'advanced', + '': 'delivered-dependency', '': 'dependency-acceptance', + '': 'source-read', '': 'file:new-dependency.txt'}.items(): + refresh = refresh.replace(key, value) + refresh_args = list(_projected_cli_args(refresh, turn_instance_id=turn)) + initial = refresh_args.copy() + index = initial.index('--agent-vision-json') + del initial[index:index + 2] + pending = json.loads(caller.run_cli([*initial, '--no-global-sync', '--suppress-external-sinks'])) + assert pending['ok'] is True, {key: pending.get(key) for key in ('error', 'reason')} + retained = json.loads(Path(pending['json_path']).read_text()) + assert retained['vision_checkpoint']['satisfied'] is False + assert not retained.get('autonomous_replan_ack', {}).get('recorded') + spend = _projected_cli_args(next(c for c in actions if 'spend-slot' in c), turn_instance_id=turn) + denied_spend = json.loads(caller.run_cli(list(spend))) + assert denied_spend['ok'] is False, denied_spend + assert _business_rows(runtime)['quota_slot_spent'] == 1 + from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context + context = read_checkpoint_context(registry_path=registry, + runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=identity['todo_id'], turn_instance_id=turn, purpose='supplement_checkpoint') + assert context['basis']['todo']['status'] == 'open' + prior = context['basis']['agent_vision'] + assert prior + vision = {key: prior[key] for key in ('schema_version', 'state', 'vision_patch', 'todo_delta') if key in prior} + vision['vision_patch'] = {**vision['vision_patch'], + 'acceptance_summary': 'Retain the accepted candidate; independently verify the newly delivered dependency.'} + vision['path_delta'] = {'outcome': 'replan', + 'prior_assumption': 'The original terminal route had no further dependency.', + 'observed_reality': 'A delivered dependency and an open validator-backed Todo now require verification.', + 'retained': ['The original accepted result and its one settlement.'], + 'changed': ['Verify the delivered dependency before another terminal decision.'], + 'evidence_refs': [f'todo:{new_todo}', 'file:new-dependency.txt']} + packet.write_text(json.dumps(vision), encoding='utf-8') + refreshed = json.loads(caller.run_cli([*refresh_args, + '--checkpoint-read-context', context['read_context_id'], + '--no-global-sync', '--suppress-external-sinks'])) + assert refreshed['ok'] is True, {key: refreshed.get(key) for key in ( + 'error', 'error_code', 'reason', 'vision_checkpoint', 'autonomous_replan_ack')} + spent = json.loads(caller.run_cli(list(spend))) + assert spent['appended'] is True, spent + assert json.loads(caller.run_cli(list(spend)))['appended'] is False + assert _business_rows(runtime)['quota_slot_spent'] == 2 + return json.loads(caller.should_run()) + def test_negative_oracle_sentinels(tmp_path): # The actual counter oracle must reject duplicated persisted work, not just a missing file. candidate = tmp_path / 'candidate.txt' @@ -160,6 +218,11 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): if new_todo: assert next(r for r in rows(runtime, state) if r['todo_id'] == new_todo)['status'] == 'open' if new_todo: + from loopx.configure_goal import configure_goal + # Freeze the required-review premise: the old and new completion each + # owe their own current outcome judgment, even after a lawful replan. + assert configure_goal(registry_path=registry, goal_id=GOAL_ID, + execution_replan_after_todos=1, execute=True)['written'] caller = _control(registry) guard = json.loads(caller.should_run()) assert guard['should_run'] is True and guard['selected_todo']['todo_id'] == new_todo, guard @@ -172,6 +235,16 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): suffix = ['--todo-id', new_todo, '--turn-instance-id', selection_turn] guard = json.loads(caller.run_cli([*current_args, *suffix], legacy_args=[*legacy_args, *suffix])) assert guard['heartbeat_receipt']['turn_instance_id'] == selection_turn + if guard['effective_action'] == 'autonomous_replan_required': + guard = settle_frontier_replan(caller, guard, registry, project, runtime, new_todo) + # A paid replan is distinct from both the old result and new work. + after = business_snapshot(project, runtime) + counts = _work_counts(project) + assert counts['implementation.txt'] == counts['validation.txt'] == 1 + if guard['interaction_contract']['agent_channel'].get('selection_required') is True: + current_args, legacy_args = caller.should_run_args(GOAL_ID, AGENT_ID) + suffix = ['--todo-id', new_todo, '--turn-instance-id', guard['heartbeat_receipt']['turn_instance_id']] + guard = json.loads(caller.run_cli([*current_args, *suffix], legacy_args=[*legacy_args, *suffix])) assert guard['interaction_contract']['agent_channel']['delivery_allowed'] is True, guard assert guard['selected_todo']['todo_id'] == new_todo assert business_snapshot(project, runtime) == after, 'New work admission must not resettle old work' @@ -195,7 +268,7 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): assert denied['ok'] is False and denied['completed'] is True, denied assert denied['settlement']['failed_stage'] == 'durable_writeback' assert 'typed semantic delta' in denied['settlement']['reason'] - assert _business_rows(runtime)['quota_slot_spent'] == 1, 'Rejected direction cannot spend the new Turn' + assert _business_rows(runtime)['quota_slot_spent'] == after['effects']['quota_slot_spent'], 'Rejected direction cannot spend the new Turn' import hashlib proof_refs = [f'todo:{new_todo}'] + [ f'file:{name}#sha256:{hashlib.sha256((project/name).read_bytes()).hexdigest()}' @@ -204,7 +277,7 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): 'state': 'vision_patch_proposed', 'vision_patch': { 'vision_summary': 'Verify the delivered dependency while preserving prior accepted results.', 'acceptance_summary': 'The new dependency was read and its independent validator passed; retain the original result and settlement identities.'}, - 'path_delta': {'outcome': 'continue', + 'path_delta': {'outcome': 'replan', 'prior_assumption': 'The original terminal route had no delivered follow-up dependency.', 'observed_reality': 'A new dependency and proof were read, and its native completion validator passed.', 'retained': ['Prior accepted results and all original settlement identities.'], @@ -220,7 +293,7 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): assert (project / 'new-validation.txt').read_text().splitlines() == ['1'] assert _work_counts(project) == after['counts'] and (project / 'candidate.txt').read_bytes() == after['candidate'] effects = _business_rows(runtime) - assert effects['candidate_verified'] == 1 and effects['quota_slot_spent'] == 2 + assert effects['candidate_verified'] == 1 and effects['quota_slot_spent'] == after['effects']['quota_slot_spent'] + 1 final_rows = rows(runtime, state) assert result_facts(next(r for r in final_rows if r['todo_id'] == original_todo)) == original_result_facts assert next(r for r in final_rows if r['todo_id'] == new_todo)['status'] == 'done' diff --git a/tests/control_plane_ts/host_stdio_bridge.test.ts b/tests/control_plane_ts/host_stdio_bridge.test.ts index 568289b57f..67e5c14e9d 100644 --- a/tests/control_plane_ts/host_stdio_bridge.test.ts +++ b/tests/control_plane_ts/host_stdio_bridge.test.ts @@ -5,10 +5,11 @@ import {existsSync} from "node:fs"; import {mkdtemp, rm, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; +import {fileURLToPath} from "node:url"; import {setTimeout as delay} from "node:timers/promises"; import test from "node:test"; -const bridge = new URL("../../loopx/control_plane/turn_driver/host_stdio_bridge.ts", import.meta.url).pathname; +const bridge = fileURLToPath(new URL("../../loopx/control_plane/turn_driver/host_stdio_bridge.ts", import.meta.url)); const launch = (script: string) => spawn(process.execPath, ["--no-warnings", "--experimental-strip-types", bridge, process.execPath, "-e", script], {stdio: ["pipe", "pipe", "pipe"]}); diff --git a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts index af81edfeaf..e7fc7db4d9 100644 --- a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts +++ b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts @@ -17,6 +17,7 @@ import { SHADOW_EXACT_DRAIN_SCHEMA, } from "../../loopx/control_plane/coordination/shadow_drain.ts"; import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import {TODO_DOMAIN_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import { fixture, pendingEntry, settleFiles, todo, sha } from "./shadow_file_fixture.ts"; import { resolveTestPython } from "../../scripts/test-python.mjs"; @@ -33,17 +34,22 @@ const GOAL_B = { test("released Todo manifests replay without rewriting history and allow the next capture", async (t) => { for (const absent of [ - ["completion_receipt_id"], - ["completion_receipt_id", "completion_result"], + [], ["completion_receipt_id"], ["completion_receipt_id", "completion_result"], ["completion_receipt_id", "completion_result", "completion_validation_revision", "completion_validation_revision_history"], ]) { const f = await fixture(t); const commit = f.store.commitAuthority.bind(f.store); - // Write a real File transaction using a released writer's field manifest. + // Freeze the actual released manifest, then replay its earlier revisions. + // Subtracting fields from today's contract constructs a mixed release. + const released = JSON.parse(await readFile(new URL( + "../control_plane/fixtures/todo_read_fields_a3266402.json", import.meta.url), "utf8")); + assert.equal(released.source_commit, "a326640280507e7e6dcd5adba8ce706bd03b219f"); f.store.commitAuthority = async (input) => { const next = structuredClone(input.next_projection); const model = next.todo_read_model as JsonObject; - model.contract_fields = (model.contract_fields as string[]).filter((field) => !absent.includes(field)); + const fields = model.schema_version === TODO_DOMAIN_READ_RECORD_SCHEMA + ? released.domain_fields : released.canonical_fields; + model.contract_fields = fields.filter((field: string) => !absent.includes(field)); return commit({...input, next_projection: next}); }; const request = await pendingEntry(f, 1, {handoff_mode: "hard_lease", todos: [todo()]}); @@ -64,7 +70,7 @@ test("released Todo manifests replay without rewriting history and allow the nex if (history.status !== "page") continue; assert.equal(history.transactions.length, 3); assert.equal(((history.transactions[1]!.projection.todo_read_model as JsonObject).contract_fields as string[]) - .includes("completion_receipt_id"), false); + .includes("completion_receipt_id"), !absent.includes("completion_receipt_id")); assert.equal(((history.transactions[2]!.projection.todo_read_model as JsonObject).contract_fields as string[]) .includes("completion_receipt_id"), true); } diff --git a/tests/control_plane_ts/retained_lease_receipts.test.ts b/tests/control_plane_ts/retained_lease_receipts.test.ts index 94c70dbbbb..cfd52eda3a 100644 --- a/tests/control_plane_ts/retained_lease_receipts.test.ts +++ b/tests/control_plane_ts/retained_lease_receipts.test.ts @@ -14,7 +14,7 @@ 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)); + const original = JSON.parse(JSON.stringify(history.scenarios[name]).replaceAll("$ROOT", JSON.stringify(root).slice(1, -1))); for (const [relative, value] of Object.entries(original.files)) { const path = join(root, "runtime", relative); await mkdir(dirname(path), {recursive: true}); From bdad81a4d64a94f2bd66b773945567305759c7b1 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 27 Oct 2026 22:08:15 +0800 Subject: [PATCH 42/80] docs(checkpoint): describe reentry and retain qualification limits Document classified direction-stop recovery, current launch gates, retained attempt budgets, unknown historical stop holds and post-result semantic-replan qualification in both languages. Keep the roadmap checkpoint partial and record the measured scope of the existing transport budget changes. Source validation record: 174 affected native-owner tests and 3 retry-budget regressions pass; real isolated PostgreSQL suites have 445 passes with no skips. Packaged browser owning HTTP/native recovery passes with deterministic SDK transport, not live/installed App, SDK or Lark qualification. Standard premerge has 6 direct and 19 selected checks without failures, but remains manual_review_required. The full TS run retains 5654 passes, 2 failures also reproduced on target main, and 110 skips. Terminal/frontier assertion alignment and Windows OS-exit qualification remain open. Preserve the combined regular/goal-change diagnostic evaluation report, its measured versions, runner-invalid history and countability limits. No new benchmark, model or judge run; these source checks do not prove model benefit. Signed-off-by: Tartar --- .../rfcs/loopx-overall-roadmap-v0.md | 5 +++ .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 2 ++ docs/development/testing-and-quality.md | 32 +++++++++++++++++ .../goal-vision-replan-contract-v0.md | 36 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index af5bdfd484..34ef4f4ce7 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -1459,6 +1459,11 @@ revalidation. Ordinary direction preserves independent Agent lanes; original no-followup still requires the whole Goal. Supplementary direction includes registered primary Goal documents under the same registry fence. Chat treats unavailable observations as read failures with read-only recovery, not pending settlement. +The source candidate retains classified direction stops and permits explicit +original-Turn reentry under current launch gates; unknown historical stop +records stay held. Receipt-bound replanning keeps its semantic ACK behind the +fresh direction, and settled selected planning obligations are excluded from later work +inference while ordinary progress retains original-Turn recovery. These are recovery mechanics, not evidence of new model utility. Ordinary terminal completion uses the existing same-Turn upgrade after checkpoint and spend. Historical result-only, explicit first-delivery and supplement Turns retain their persisted contract. Complete changed-frontier recovery, packaged diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 85a28d8f13..7fc06f43be 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -944,6 +944,8 @@ lifecycle。 新的受支持准入还保存当前 Agent 的默认 checkpoint 续跑契约。需要检查点时读取结果提交后的完整相关任务前沿;MCP 返回给当前工具调用者,托管执行恢复原 Host/session。非完成结果保留原回执并使用现有补充 owner。相关方向变化只重做方向判断,保留成功结果和单次扣费。确定被拒的进度写回保留候选,在原 Turn 重验;普通方向保持独立 Agent lane 隔离,原 no-followup 仍要求全 Goal。补充方向在相同 registry fence 下包含注册的主 Goal 文档;Chat 将观察不可用显示为读取失败并提供只读恢复,不称为待结算。普通终态完成在 checkpoint 与扣费后使用同一 Turn 的升级路径。历史 result-only、显式 first-delivery 及 supplement Turn 沿用保存的契约。完整的前沿变化恢复、打包前端/Lark 旅程、混合版本回滚与真实模型效用仍是未完成的一阶段验收。Source-session 仍只支持生命周期;普通 Python PostgreSQL 执行仍缺少 service-owned factory。独立 reviewer 需要单独注册的可选契约。这些默认值不关闭 R1、不认证 Goal 完成,也不证明分布式原子性或模型收益。 +源码候选保留已分类的方向停止,并在当前派发门约束下支持原 Turn 显式重入;未知历史停止仍被阻断。回执绑定的重规划在新方向返回后才验收语义 ACK,已结算且验收所选规划义务的 Turn 不再与后续工作竞争,普通进度仍保留原 Turn 恢复。这些是恢复机制,不是新增模型效用证据。 + 恢复资格还覆盖原结果提交后工作读取回执丢失或损坏、已有进度写回证明的历史 result-only 终态重验,以及 Turn 结算后尚未完成的委派 Todo。native index 在方向补充之后仍证明原工作记录。sealed 生命周期恢复按 canonical store 的时间精度比较关闭时刻,保留同秒准入/关闭,不采纳旧终态工作。未知提交和身份不匹配继续拒绝。打包 Chat 恢复烟测复用现有同源 relay 和隔离 Host profile;确定性重放证据不认证 installed Lark、混版本回滚或真实模型效用。 **R1 事务检查点。** 团队计划准入与整批规划现在归 `work_items/team_plan.ts`。确认后,全部已准入 lane 与持久操作回执一次提交;身份由 proposal + lane 决定,不再由 Todo 文本决定。File/SQLite 权威复用现有 CAS 与回执 owner;legacy Markdown 在原有 fence 和锁内同时写入任务和不可变回执。同一操作重试只读历史结果,接收者后来修改、完成或删除任务也不会触发重建。提交前失败不会留下部分 lane;canonical 展示投递仍 pending 时,Chat 必须恢复后才能报告验证成功。卡片列出部分分配及缺口;quota/stop 是参考,显式强制声明会被拒绝。Agent 发起的结算在 journal 首次写入时绑定同一状态基线并在结算时重读;基线缺失或已变动、或全部 lane 均为缺口的计划,记录为类型化的失败回执,不创建 Todo,重放结果不变。 diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 8ce3645802..cb5cb32227 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -2409,3 +2409,35 @@ ids, reason codes, digests, and public-safe semantic projections. 禁止提交凭证、私有状态、原始 benchmark 材料、模型响应、本地绝对路径或生成日志。 优先保留紧凑 fixture id、reason code、digest 和公开安全语义投影。 + +### Recovery transport budget decision / 恢复运输层预算决策 + +The recovery revision compares the same source-line and AST workload against +target main `d52f2e153a0a5abb2f9c469ad78f0beccfe3aae8`: + +| Existing module | Target lines | Recovery candidate ceiling | +| --- | ---: | ---: | +| `chat_agent.py` | 2005 | 2032 | +| `chat_runtime.py` | 2319 | 2322 | +| `chat_store.py` | 2003 | 2006 | +| `collaboration_mcp.py` | 2058 | 2083 | + +These exact ceilings retain 58 lines of active caller/receipt transport across +four existing surfaces; none is unused scaffolding or duplicated policy. +Splitting a 3-line binding into a new module would increase navigation without +localizing a separate owner. The larger Turn executor instead moves preview, +terminal recovery and work-input IO to their existing owners: its candidate is +1882 lines, below the unchanged 2000-line ceiling. Function statement/decision +limits remain unchanged. The cadence dependency exception covers only the +existing configuration owner's read, not a second policy or authority owner; +its retirement is explicit injection from the CLI/source composition boundary. +Source ratios and a passing ratchet do not establish behavior parity: cold +recovery, validation, Stop, quota, real providers and packaged entrypoints still +need their independent checks. + +这次预算比较使用相同源码行数/AST 口径。四个既有调用界面共增加 58 行有效绑定运输, +精确提高其已有 ceiling;没有删除有用指令,也没有因 3 行绑定另建无独立职责的模块。 +较大的执行器则实际拆回现有 owner,候选 1882 行,仍低于未放宽的 2000 行上限; +函数语句数与决策数限制保持原值。cadence 例外只覆盖读取既有配置 owner,退休方向 +是从 CLI/source 组合根注入已解析配置,不允许新增政策或执行权。预算通过与代码量 +变化不证明行为保持,仍须独立执行冷恢复、验证、停止、额度、真实 provider 与打包入口。 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 3062a328ec..c2dd72d88a 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -2353,3 +2353,39 @@ need no new setting or editor. Read back with `loopx status --goal-id ` and 机器执行的 replan 条件。尚未到期且在到期时仍有效的关联监控继续抑制提前重规划。 这不替代其他 frontier 的语义验收、版本检查或权限。前端与 Lark 继续使用原有 返回结构;可用上面的 status/quota 命令核对。 + +### Direction stops and explicit original-Turn recovery + +A direction Host's `wait`, `user_action_required` or `iteration_failed` retains +the result and records its existing result kind and attempt in `direction_stops`. +Read-only `checkpoint-context` preserves this history and the stopped marker. +Explicit `--resume-turn-key` together with `--retry-failed-turn` may retry the +unfinished direction; it cannot replace the original result, validator, Host +session or settlement identity. Current activation, permission and quota are +checked again at Host launch. Retry counters and Host failure budgets are never +reset. A recorded Host `stop_reason`, malformed/unknown stop history, and an +uncertain commit remain held. An older boolean-only stopped receipt supplies no +stop-classification evidence and is not silently rearmed. + +For a receipt-bound semantic replan, the default post-result checkpoint may +retain the result before the current Agent has authored its direction. Its +pending checkpoint is neither an accepted semantic delta nor permission to +spend. The original obligation is qualified on the fresh supplement; completion +validation still belongs to the later Todo completion. A settled Turn for a selected +planning obligation cannot compete with its later work admission. Ordinary +progress and completed Todos retain their original-Turn recovery paths. + +### 中文:方向停止与原 Turn 显式恢复 + +方向 Host 返回 `wait`、`user_action_required` 或 `iteration_failed` 后,保留成果, +在 `direction_stops` 中记录既有结果种类与尝试次数。只读重读会保留停止标记和历史。 +`--resume-turn-key` 与 `--retry-failed-turn` 同时使用时,只能重试尚未完成的方向; +原成果、validator、Host session 和结算身份保持原绑定。实际派发时重新检查当前 +激活状态、权限和额度,不能重置尝试次数或 Host 失败预算。真实 `stop_reason`、 +格式未知的停止历史与提交结果不确定继续阻断。旧版仅含停止布尔值的回执没有 +停止分类证据,不能被静默重新激活。 + +回执已绑定的语义重规划可以先暂存结果,再由当前 Agent 读取结果后判断方向。 +待补的 checkpoint 不等于语义 delta 已接受,也不允许提前扣额;新补充判断仍须 +验收原义务。新 Todo 的完成验证仍在其原有完成门执行。已结算且已验收所选规划义务的 Turn +不再与后续实际工作竞争;普通进度和已完成 Todo 仍保留原 Turn 恢复路径。 From 5484611b0ab2472ffe1830f3797cf9e0de86a002 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 15:23:31 +0800 Subject: [PATCH 43/80] fix(checkpoint): preserve physical aliases and original-Turn recovery Resolve artifact parent aliases to the admitted physical directory, retain leaf-file rejection and recheck directory identity before append. Canonicalize new producer paths without rewriting historical artifact fingerprints. Restore the admitted original Turn before explicit MCP revalidation; missing admission remains rejected. Align positive recovery fixtures with legal work admission and distinguish pending semantic duties from already settled duties. Validation: complete affected Windows cohort: 206 passed/2 POSIX-only skips; terminal/frontier module: 13 passed; seven native owner modules: 262 passed with one Windows file-symlink privilege skip. Type checks pass. Native macOS TMPDIR and POSIX directory-replacement qualification remain unverified. Signed-off-by: Tartar --- .../control_plane/goals/checkpoint_commit.ts | 23 ++++-- loopx/goal_mode_mcp.py | 7 +- loopx/state_refresh.py | 5 +- .../test_checkpoint_goal_documents.py | 9 ++- .../test_checkpoint_history_isolation.py | 4 +- .../test_checkpoint_provider_fence.py | 72 +++++++++++++++++ .../test_default_result_freshness.py | 59 ++++++++++++-- .../test_default_terminal_frontier.py | 58 +++++++------- .../test_inflight_result_freshness.py | 8 +- .../test_quota_settlement_cli.py | 1 + .../checkpoint_provider_head.test.ts | 77 ++++++++++++++++++- 11 files changed, 271 insertions(+), 52 deletions(-) diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 27848efdde..6c4195b6e9 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -1,8 +1,8 @@ /** Receipt-bound work and direction commits. Index/source claims survive the requesting CLI; * the real provider fence lasts through the synchronous durable append. */ import {createHash} from "node:crypto"; -import {closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, writeFileSync} from "node:fs"; -import {dirname, join, resolve} from "node:path"; +import {closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync} from "node:fs"; +import {basename, dirname, join, resolve} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; @@ -109,12 +109,18 @@ export function inspectCheckpointReplay(value: unknown): JsonObject { function runPath(value: unknown, runsDir: string, extension: string): string { const path = resolve(requireNonEmptyString(value, "run artifact path")); - if (dirname(path) !== resolve(runsDir) || !path.endsWith(extension)) { + let parent: string; + try { parent = realpathSync(dirname(path)); } + catch { unknown("checkpoint artifact directory is unavailable"); } + if (parent !== realpathSync(runsDir) || !path.endsWith(extension)) { unknown("checkpoint artifact path is invalid"); } - try { if (!lstatSync(path).isFile()) unknown("checkpoint artifact is not a regular file"); } + // Resolve only the directory alias. Following the leaf would admit a file + // symlink, and writing through the original alias could follow a later retarget. + const physical = join(parent, basename(path)); + try { if (!lstatSync(physical).isFile()) unknown("checkpoint artifact is not a regular file"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - return path; + return physical; } function writeSynced(path: string, text: string): void { @@ -299,7 +305,9 @@ export async function commitCheckpoint(value: unknown, throw new EffectRuntimeRequestError("checkpoint settlement identity is invalid"); } const runsDir = join(root, "goals", identity.goal_id, "runs"); - const indexPath = join(runsDir, "index.jsonl"); + const physicalRuns = realpathSync(runsDir); + const directoryIdentity = statSync(physicalRuns, {bigint: true}); + const indexPath = join(physicalRuns, "index.jsonl"); const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); const firstDelivery = request.purpose === "first_delivery"; const resultWork = request.purpose === "delivery_result"; @@ -484,6 +492,9 @@ export async function commitCheckpoint(value: unknown, mutation_digest: recovery.mutation_digest, json_path: jsonPath, markdown_path: markdownPath, index_record: row}}); } + const currentDirectory = statSync(physicalRuns, {bigint: true}); + if (realpathSync(runsDir) !== physicalRuns || currentDirectory.dev !== directoryIdentity.dev || + currentDirectory.ino !== directoryIdentity.ino) unknown("checkpoint artifact directory changed"); writeSynced(jsonPath, JSON.stringify(record, null, 2) + "\n"); writeSynced(markdownPath, requireNonEmptyString(request.markdown, "checkpoint Markdown")); appendJsonLineSync(indexPath, row); diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 14063b4090..0b02ff8afe 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -194,7 +194,12 @@ def should_run(self, revalidate_todo_id: str = "") -> str: return self.no_goal_message() args, legacy_args = self.should_run_args(goal_id, self.bound_agent_id()) suffix = ["--todo-id", revalidate_todo_id] if revalidate_todo_id else [] - output = self.run_cli([*args, *suffix], legacy_args=[*legacy_args, *suffix]) + guard_suffix = list(suffix) + if revalidate_todo_id: + turn = self._original_work_turn(revalidate_todo_id) + if turn is not None: + guard_suffix += ["--turn-instance-id", turn] + output = self.run_cli([*args, *guard_suffix], legacy_args=[*legacy_args, *guard_suffix]) if revalidate_todo_id: guard = json.loads(output) if guard.get("ok") is True and guard.get("delivery_result_context"): diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 0432d4e481..8ce69b6bcd 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1488,7 +1488,10 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): compact_route["projection_marker_field"] = "shared_runtime_projection" record["runtime_projection_route"] = compact_route - runs_dir = runtime_root / "goals" / safe_goal_id / "runs" + # Produce physical artifact paths before record/index fingerprints are + # constructed. Historical aliases remain immutable and are verified by + # the checkpoint owner against this same directory identity. + runs_dir = runtime_root.resolve() / "goals" / safe_goal_id / "runs" json_path, markdown_path = unique_run_paths(runs_dir, generated_at) index_path = runs_dir / "index.jsonl" index_record, payload = _build_state_refresh_output_projections( diff --git a/tests/control_plane/test_checkpoint_goal_documents.py b/tests/control_plane/test_checkpoint_goal_documents.py index 9dde4892f4..7300cca2e4 100644 --- a/tests/control_plane/test_checkpoint_goal_documents.py +++ b/tests/control_plane/test_checkpoint_goal_documents.py @@ -28,7 +28,8 @@ def test_owner_change_survives_agent_state_rollback(provider, tmp_path, monkeypa "--ttl-seconds", "600", cwd=project) assert rc == 0, lease identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) - rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, cwd=project) + rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--turn-instance-id", "owner-document-turn", cwd=project) assert rc == 0, admitted original = admitted["delivery_result_context"] assert original["basis"]["goal"]["primary_documents"] == [ @@ -73,7 +74,8 @@ def test_missing_primary_document_cannot_be_rebuilt_from_agent_state(provider, t registry.write_text(json.dumps(registration), encoding="utf-8") before = state.read_bytes() rc, rejected = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "missing-owner-turn", cwd=project) assert rc != 0 and rejected["error_code"] == "checkpoint_goal_source_unavailable", rejected assert state.read_bytes() == before assert _spend_run_count(runtime) == 0 @@ -89,7 +91,8 @@ def test_owner_change_during_native_capture_is_rechecked_at_provider_fence(provi {"kind": "goal_doc", "path": "GOAL.md", "role": "primary_goal_document"}] registry.write_text(json.dumps(registration), encoding="utf-8") rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "capture-race-turn", cwd=project) assert rc == 0, admitted wrapper = project / "capture_race.py" wrapper.write_text(f''' diff --git a/tests/control_plane/test_checkpoint_history_isolation.py b/tests/control_plane/test_checkpoint_history_isolation.py index eed39e66cc..6571aac1cd 100644 --- a/tests/control_plane/test_checkpoint_history_isolation.py +++ b/tests/control_plane/test_checkpoint_history_isolation.py @@ -53,11 +53,11 @@ def test_settled_peer_read_damage_preserves_independent_delivery(provider, purpo "--no-global-sync", "--suppress-external-sinks") assert rc == 0, baseline args = ("quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", peer, - "--todo-id", todo, "--runtime-profile", "claude_code") + "--todo-id", todo, "--runtime-profile", "claude_code", "--turn-instance-id", "peer-original-turn") rc, guard = _run_cli(registry, *args) assert rc == 0 and guard.get("delivery_result_context"), guard identity = guard["delivery_result_context"]["settlement_identity"] - rc, selected = _run_cli(registry, *args, "--turn-instance-id", identity["turn_instance_id"]) + rc, selected = _run_cli(registry, *args) assert rc == 0 and selected["interaction_contract"]["agent_channel"]["delivery_allowed"] is True, selected control = GoalModeMCPControlPlane(GoalModeMCPConfig(server_name="fixture-peer", runtime_profile="claude_code", legacy_host_surface="claude_code"), diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index 4733ad8990..d42d0d826d 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -163,6 +163,78 @@ def test_public_update_before_final_read_rejects_then_reread_succeeds(tmp_path, assert _spend_run_count(runtime) == 0 +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_runtime_directory_alias_commits_and_cold_replays_the_original_turn(tmp_path, monkeypatch, provider): + _, runtime, registry, _, read, original = fixture(tmp_path, monkeypatch, provider) + token = read()["read_context_id"] + alias = tmp_path / "runtime-alias" + if os.name == "nt": + import _winapi + _winapi.CreateJunction(str(runtime), str(alias)) + else: + alias.symlink_to(runtime, target_is_directory=True) + try: + saved = refresh(registry, alias, token) + assert saved["appended"] and saved["vision_checkpoint"]["satisfied"], saved + assert Path(saved["json_path"]).parent == runtime.resolve() / "goals" / GOAL_ID / "runs" + assert saved["settlement_identity"] == original["settlement_identity"] + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + artifacts = {Path(saved[key]): Path(saved[key]).read_bytes() for key in ("json_path", "markdown_path")} + before = index.read_bytes() + for root in (runtime, alias): + replay = refresh(registry, root, token) + assert replay["idempotent_replay"] and replay["settlement_identity"] == saved["settlement_identity"], replay + assert index.read_bytes() == before + assert all(path.read_bytes() == contents for path, contents in artifacts.items()) + assert _spend_run_count(runtime) == 0 + finally: + alias.rmdir() if os.name == "nt" else alias.unlink() + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.skipif(os.name == "nt", reason="Windows held index handles reject directory replacement before the native identity guard; exercise this injection on POSIX") +def test_replaced_artifact_directory_cannot_receive_the_checkpoint_append(tmp_path, monkeypatch, provider): + import shutil + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + token = read()["read_context_id"] + runs = runtime / "goals" / GOAL_ID / "runs" + before = (runs / "index.jsonl").read_bytes() + barrier = tmp_path / "directory-race" + barrier.mkdir() + effect = context_io.effect_runtime_result + + def native(method, params, **kwargs): + if method != "goal.checkpoint_read_context.commit": + return effect(method, params, **kwargs) + child = start_probe({"mode": "checkpoint", "provider": provider, "barrier": str(barrier), "params": params}) + try: + wait_for(barrier / "head-read", child) + retained = runs.with_name("runs-retained") + runs.rename(retained) + # Identical bytes do not make a replacement the directory whose + # identity was admitted before the provider head was acquired. + shutil.copytree(retained, runs) + (barrier / "release").touch() + stdout, stderr = child.communicate(timeout=20) + assert child.returncode == 1, stdout + stderr + rejected = json.loads(stdout) + assert rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert "directory changed" in rejected["error"], rejected + raise RuntimeError("replaced directory refused at the checkpoint owner") + finally: + (barrier / "release").touch() + if child.poll() is None: + child.kill() + child.communicate() + + monkeypatch.setattr(context_io, "effect_runtime_result", native) + with pytest.raises(RuntimeError, match="replaced directory refused"): + refresh(registry, runtime, token) + assert (runs / "index.jsonl").read_bytes() == before + assert (runs.with_name("runs-retained") / "index.jsonl").read_bytes() == before + assert _spend_run_count(runtime) == 0 + + def test_exact_source_checkpoint_commits_only_for_the_current_goal_instance( tmp_path, monkeypatch, diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index 1c825fd0a5..7d85ecaf5b 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -31,7 +31,8 @@ def _default_fixture(provider, tmp_path, monkeypatch, *, handoff_mode="soft_clai def test_default_guard_restores_actual_basis_and_result_commit(provider, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) - guard_args = ("quota", "should-run", "--codex-app", *identity) + # Automatic default admission has no caller-selected Todo or invented Turn. + guard_args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID) rc, first = _run_cli(registry, runtime, *guard_args, cwd=project) assert rc == 0 and "delivery_result_context" in first, json.dumps(first) context = first["delivery_result_context"] @@ -68,20 +69,38 @@ def test_concurrent_default_reads_share_original_turn_and_basis(provider, tmp_pa def read(): barrier.wait() return _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + "--agent-id", AGENT_ID, cwd=project) with ThreadPoolExecutor(max_workers=2) as pool: results = list(pool.map(lambda _: read(), range(2))) assert all(rc == 0 for rc, _ in results), json.dumps(results, indent=2) assert results[0][1]["delivery_result_context"] == results[1][1]["delivery_result_context"] +def test_explicit_todo_selection_still_requires_a_turn(tmp_path): + project, runtime, registry = _write_fixture(tmp_path) + events = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + before = events.read_text().splitlines() if events.exists() else [] + rc, rejected = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + assert rc == 1 and rejected["error_code"] == "QUOTA_VALIDATION_FAILED", rejected + assert "requires --turn-instance-id or --begin-turn" in rejected["reason"], rejected + assert "heartbeat_receipt" not in rejected + # A diagnostic quota_should_run event is allowed; it cannot mint the + # settlement binding that distinguishes a durable work admission. + after = events.read_text().splitlines() if events.exists() else [] + assert after[:len(before)] == before + assert all(not json.loads(line).get("details", {}).get("settlement_identity") + for line in after[len(before):]) + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_todo_only_source_after_work_read_rejects_result_commit(provider, tmp_path, monkeypatch): from loopx.control_plane.todos.projection_document import recovered_todo_projection_skeleton from loopx.todos import list_goal_todos project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) - rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, cwd=project) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--turn-instance-id", TURN_ID, cwd=project) assert rc == 0 and "delivery_result_context" in guard, guard original_contexts = {path: path.read_bytes() for path in (runtime / "goals" / GOAL_ID / "checkpoint-contexts").glob("*.json")} @@ -102,7 +121,8 @@ def test_successful_result_replay_precedes_later_goal_source_loss(provider, sour from loopx.todos import list_goal_todos project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) - rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, cwd=project) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, + "--turn-instance-id", TURN_ID, cwd=project) assert rc == 0, guard complete = ("todo", "complete", *identity, "--note", "Verified result committed before source loss.") rc, committed = _run_cli(registry, runtime, *complete, cwd=project) @@ -145,7 +165,7 @@ def test_default_commit_keeps_basis_after_unrelated_todo_revision(provider, tmp_ project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) identity = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID) rc, work = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *identity, - cwd=project) + "--turn-instance-id", TURN_ID, cwd=project) assert rc == 0, work rc, peer = _run_cli(registry, runtime, "todo", "add", "--goal-id", GOAL_ID, "--role", "agent", "--text", "Validate an independent follow-up.", "--claimed-by", AGENT_ID, cwd=project) @@ -160,7 +180,7 @@ def test_default_commit_keeps_basis_after_unrelated_todo_revision(provider, tmp_ def test_damaged_work_input_blocks_default_dispatch_and_preserves_admission(provider, tmp_path, monkeypatch): project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, - "--agent-id", AGENT_ID, "--todo-id", TODO_ID) + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) rc, first = _run_cli(registry, runtime, *args, cwd=project) assert rc == 0, first path = next((runtime / "goals" / GOAL_ID / "checkpoint-contexts").glob("*.json")) @@ -342,7 +362,7 @@ def lose_response(*args, **kwargs): context_path.write_text("{", encoding="utf-8") rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", goal, "--agent-id", agent, "--todo-id", "todo_fixture0001", - cwd=project) + "--turn-instance-id", failed["turn_key"], cwd=project) assert rc == 0 and guard.get("ok") is True, guard assert guard["heartbeat_receipt"]["settlement_identity"]["turn_instance_id"] == failed["turn_key"] assert guard.get("result_committed") is True and "delivery_result_context" not in guard, guard @@ -376,6 +396,28 @@ def lose_response(*args, **kwargs): assert rc == 0 and replay["replayed"] is True and replay["effects"]["host_invoked"] is False, replay +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_default_mcp_revalidation_cannot_mint_work_without_original_admission(provider, tmp_path, monkeypatch): + from tests.test_goal_mode_mcp_settlement import _write_fixture as mcp_fixture, _control, GOAL_ID as goal, AGENT_ID as agent + from loopx.todos import add_goal_todo + + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = mcp_fixture(tmp_path, provider=provider) + added = add_goal_todo(registry_path=registry, goal_id=goal, role="agent", + text="Verify a bounded candidate.", task_class="advancement_task", claimed_by=agent, + continuation_policy="same_agent_non_delivery") + before = state.read_bytes() + runtime = tmp_path / "runtime" + index = runtime / "goals" / goal / "runs/index.jsonl" + before_index = index.read_bytes() if index.exists() else b"" + rejected = json.loads(_control(registry).should_run(revalidate_todo_id=added["todo_id"])) + assert rejected["ok"] is False and rejected["error_code"] == "QUOTA_VALIDATION_FAILED", rejected + assert "requires --turn-instance-id" in rejected["reason"] + assert "heartbeat_receipt" not in rejected and "delivery_result_context" not in rejected + assert state.read_bytes() == before + assert (index.read_bytes() if index.exists() else b"") == before_index + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("receipt_loss", [None, "deleted", "damaged", "writeback_deleted", "writeback_damaged"]) def test_default_mcp_reads_before_work_and_rejects_stale_result(provider, receipt_loss, tmp_path, monkeypatch): @@ -448,7 +490,8 @@ def lose_result_response(args, **kwargs): def test_unpromoted_work_read_does_not_restore_retired_mutation_authority(operation, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture("legacy", tmp_path, monkeypatch) rc, admitted = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", - "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, cwd=project) + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", TURN_ID, cwd=project) assert rc == 1 and admitted["error_code"] == "canonical_authority_required", admitted assert "delivery_result_context" not in admitted original = state.read_bytes() diff --git a/tests/control_plane/test_default_terminal_frontier.py b/tests/control_plane/test_default_terminal_frontier.py index fa04b72a62..3129ec7ebf 100644 --- a/tests/control_plane/test_default_terminal_frontier.py +++ b/tests/control_plane/test_default_terminal_frontier.py @@ -64,6 +64,8 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): """A new dependency after terminal intent owes an actual route decision.""" turn = guard['heartbeat_receipt']['turn_instance_id'] identity = guard['heartbeat_receipt']['settlement_identity'] + obligation = guard['replan_action_packet']['obligation_id'] + assert guard['heartbeat_receipt']['semantic_replan_obligation_id'] == obligation assert identity['todo_id'] == new_todo assert (project / 'new-dependency.txt').read_text() == 'delivered-v2' assert not (project / 'new-proof.txt').exists(), 'Planning cannot claim the future validator result' @@ -77,6 +79,13 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): '': 'source-read', '': 'file:new-dependency.txt'}.items(): refresh = refresh.replace(key, value) refresh_args = list(_projected_cli_args(refresh, turn_instance_id=turn)) + # This route is qualified by the real dependency and Vision path below, + # not by an invented surface/hypothesis/probe progress observation. + for flag in ('--progress-result-class', '--progress-surface-id', '--progress-hypothesis-id', + '--progress-probe-kind', '--progress-evidence-id'): + if flag in refresh_args: + at = refresh_args.index(flag) + del refresh_args[at:at + 2] initial = refresh_args.copy() index = initial.index('--agent-vision-json') del initial[index:index + 2] @@ -96,6 +105,17 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): assert context['basis']['todo']['status'] == 'open' prior = context['basis']['agent_vision'] assert prior + # This original guard selected the still-pending semantic duty. A fresh + # unchanged checkpoint alone must neither acknowledge it nor spend. + before_rejection = (business_snapshot(project, runtime), + (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_bytes()) + denied = json.loads(caller.run_cli([*initial, + '--checkpoint-read-context', context['read_context_id'], + '--vision-unchanged-reason', 'The delivered dependency is visible; retain the current direction.', + '--no-global-sync', '--suppress-external-sinks'])) + assert denied['ok'] is False and 'typed semantic delta' in json.dumps(denied), denied + assert before_rejection == (business_snapshot(project, runtime), + (runtime / 'goals' / GOAL_ID / 'runs/index.jsonl').read_bytes()) vision = {key: prior[key] for key in ('schema_version', 'state', 'vision_patch', 'todo_delta') if key in prior} vision['vision_patch'] = {**vision['vision_patch'], 'acceptance_summary': 'Retain the accepted candidate; independently verify the newly delivered dependency.'} @@ -111,6 +131,8 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): '--no-global-sync', '--suppress-external-sinks'])) assert refreshed['ok'] is True, {key: refreshed.get(key) for key in ( 'error', 'error_code', 'reason', 'vision_checkpoint', 'autonomous_replan_ack')} + assert refreshed['autonomous_replan_ack']['recorded'] is True + assert refreshed['autonomous_replan_ack']['semantic_delta']['obligation_id'] == obligation spent = json.loads(caller.run_cli(list(spend))) assert spent['appended'] is True, spent assert json.loads(caller.run_cli(list(spend)))['appended'] is False @@ -219,8 +241,8 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): assert next(r for r in rows(runtime, state) if r['todo_id'] == new_todo)['status'] == 'open' if new_todo: from loopx.configure_goal import configure_goal - # Freeze the required-review premise: the old and new completion each - # owe their own current outcome judgment, even after a lawful replan. + # Each result owes a current direction judgment. A semantic replan is + # separate and binds only the exact duty selected by its Turn guard. assert configure_goal(registry_path=registry, goal_id=GOAL_ID, execution_replan_after_todos=1, execute=True)['written'] caller = _control(registry) @@ -236,7 +258,9 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): guard = json.loads(caller.run_cli([*current_args, *suffix], legacy_args=[*legacy_args, *suffix])) assert guard['heartbeat_receipt']['turn_instance_id'] == selection_turn if guard['effective_action'] == 'autonomous_replan_required': + settled_obligation = guard['replan_action_packet']['obligation_id'] guard = settle_frontier_replan(caller, guard, registry, project, runtime, new_todo) + assert guard['heartbeat_receipt'].get('semantic_replan_obligation_id') != settled_obligation # A paid replan is distinct from both the old result and new work. after = business_snapshot(project, runtime) counts = _work_counts(project) @@ -259,33 +283,15 @@ def test_default_terminal_frontier(provider, scenario, tmp_path, monkeypatch): if first_new.get('stage') == 'direction_pending': current = first_new['context'] assert new_todo in json.dumps(current['basis']) - # A required vision outcome cannot be discharged by an unchanged - # reason alone, even after the new result's independent validator. - denied = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, + assert not guard['heartbeat_receipt'].get('semantic_replan_obligation_id'), guard + assert (project / 'new-validation.txt').read_text().splitlines() == ['1'] + # The prior duty was settled with its own typed path evidence. This + # later result retains that verified route after a fresh Goal read; + # it must not inherit the old duty or repeat independent validation. + new_finished = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, 'The new delivered dependency was actually read and independently validated.', no_follow_up=True, read_context_id=current['read_context_id'], vision_unchanged_reason='The latest new dependency is verified; retain prior accepted results and only close this work.')) - assert denied['ok'] is False and denied['completed'] is True, denied - assert denied['settlement']['failed_stage'] == 'durable_writeback' - assert 'typed semantic delta' in denied['settlement']['reason'] - assert _business_rows(runtime)['quota_slot_spent'] == after['effects']['quota_slot_spent'], 'Rejected direction cannot spend the new Turn' - import hashlib - proof_refs = [f'todo:{new_todo}'] + [ - f'file:{name}#sha256:{hashlib.sha256((project/name).read_bytes()).hexdigest()}' - for name in ('new-dependency.txt', 'new-proof.txt')] - vision = {'schema_version': 'goal_vision_replan_contract_v0', - 'state': 'vision_patch_proposed', 'vision_patch': { - 'vision_summary': 'Verify the delivered dependency while preserving prior accepted results.', - 'acceptance_summary': 'The new dependency was read and its independent validator passed; retain the original result and settlement identities.'}, - 'path_delta': {'outcome': 'replan', - 'prior_assumption': 'The original terminal route had no delivered follow-up dependency.', - 'observed_reality': 'A new dependency and proof were read, and its native completion validator passed.', - 'retained': ['Prior accepted results and all original settlement identities.'], - 'changed': ['Include the independently verified dependency in the current direction.'], - 'evidence_refs': proof_refs}} - new_finished = json.loads(_control(registry).complete_task(new_todo, AGENT_ID, - 'The new delivered dependency was actually read and independently validated.', no_follow_up=True, - read_context_id=current['read_context_id'], agent_vision=vision)) else: new_finished = first_new assert new_finished.get('ok') is True and new_finished.get('settlement_complete') is True, new_finished diff --git a/tests/control_plane/test_inflight_result_freshness.py b/tests/control_plane/test_inflight_result_freshness.py index 582b7022a0..726a71592c 100644 --- a/tests/control_plane/test_inflight_result_freshness.py +++ b/tests/control_plane/test_inflight_result_freshness.py @@ -7,14 +7,15 @@ from tests.control_plane.test_default_result_freshness import _default_fixture from tests.control_plane.test_quota_settlement_cli import ( - AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _spend_run_count, + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count, ) def _admit(provider, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', - '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID, cwd=project) + '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', TURN_ID, cwd=project) assert rc == 0, guard context = guard['delivery_result_context'] args = ('refresh-state', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, @@ -39,7 +40,8 @@ def test_supplement_direction_keeps_primary_goal_source_fresh(provider, change, document['goals'][0]['authority_sources'] = [{'kind': 'goal_doc', 'path': 'GOAL.md'}] registry.write_text(json.dumps(document), encoding='utf-8') identity = ('--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID) - rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', *identity, cwd=project) + rc, guard = _run_cli(registry, runtime, 'quota', 'should-run', '--codex-app', *identity, + '--turn-instance-id', TURN_ID, cwd=project) assert rc == 0, guard turn = guard['delivery_result_context']['settlement_identity']['turn_instance_id'] args = ('refresh-state', *identity, '--turn-instance-id', turn, diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 803d89b9bb..107b4ba24b 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -233,6 +233,7 @@ def _run_cli( "PYTHONPATH": str(REPO_ROOT), }, ) + assert result.stdout.strip(), (result.returncode, result.stderr) return result.returncode, json.loads(result.stdout) diff --git a/tests/control_plane_ts/checkpoint_provider_head.test.ts b/tests/control_plane_ts/checkpoint_provider_head.test.ts index b9495cc6a2..4a4fda4a26 100644 --- a/tests/control_plane_ts/checkpoint_provider_head.test.ts +++ b/tests/control_plane_ts/checkpoint_provider_head.test.ts @@ -1,11 +1,12 @@ import {inspectCheckpointAttempt, inspectCheckpointReadHistory} from "../../loopx/control_plane/goals/checkpoint_commit.ts"; import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {mkdir, mkdtemp, rm} from "node:fs/promises"; +import {mkdir, mkdtemp, rm, symlink} from "node:fs/promises"; import {writeFileSync, readFileSync} from "node:fs"; import {tmpdir} from "node:os"; -import {join} from "node:path"; +import {join, resolve} from "node:path"; import test from "node:test"; +import {inspectCheckpointReplay} from "../../loopx/control_plane/goals/checkpoint_commit.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; @@ -186,3 +187,75 @@ test("an indexed work result cannot discharge another Turn's unindexed direction writeFileSync(direction.json_path, JSON.stringify({...direction, settlement_identity: current})); assert.throws(inspect, (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); }); + +test("indexed replay accepts a runtime directory alias without rewriting historical paths", async t => { + const directory = await mkdtemp(join(tmpdir(), "checkpoint-directory-alias-")); + t.after(() => rm(directory, {recursive: true, force: true})); + const root = join(directory, "physical"), alias = join(directory, "alias"); + const runs = join(root, "goals", "goal", "runs"); + await mkdir(runs, {recursive: true}); + await symlink(root, alias, process.platform === "win32" ? "junction" : "dir"); + const identity = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; + const prior = {settlement_identity: identity, refresh_recovery: {}, + vision_checkpoint: {satisfied: true, read_context: {read_context_id: "original"}}, + json_path: join(alias, "goals", "goal", "runs", "saved.json"), + markdown_path: join(alias, "goals", "goal", "runs", "saved.md")}; + const record = JSON.stringify(prior), index = record + "\n"; + writeFileSync(prior.json_path, record); + writeFileSync(prior.markdown_path, "Saved direction\n"); + writeFileSync(join(runs, "index.jsonl"), index); + for (const runtime_root of [root, alias]) { + assert.equal(inspectCheckpointReplay({runtime_root, goal_id: "goal", prior}).replayed, true); + assert.equal(inspectCheckpointAttempt({runtime_root, identity}).status, "committed"); + } + assert.equal(readFileSync(join(runs, "saved.json"), "utf8"), record); + assert.equal(readFileSync(join(runs, "index.jsonl"), "utf8"), index); + await rm(alias); + const redirected = join(directory, "redirected"); + await mkdir(join(redirected, "goals", "goal", "runs"), {recursive: true}); + await symlink(redirected, alias, process.platform === "win32" ? "junction" : "dir"); + assert.throws(() => inspectCheckpointReplay({runtime_root: root, goal_id: "goal", prior}), + (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + assert.equal(readFileSync(join(runs, "index.jsonl"), "utf8"), index); +}); + +for (const route of ["outside", "outside-directory-link", "wrong-goal", "leaf-directory-link", "leaf-link"] as const) { + test(`checkpoint artifact alias cannot authorize ${route}`, async t => { + const root = await mkdtemp(join(tmpdir(), "checkpoint-path-refusal-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runs = join(root, "goals", "goal", "runs"); + const outside = join(root, "outside"); + const otherRuns = join(root, "goals", "other-goal", "runs"); + for (const path of [runs, outside, otherRuns]) await mkdir(path, {recursive: true}); + let jsonPath = join(runs, "saved.json"); + if (route === "outside") jsonPath = resolve(runs, "..", "..", "..", "outside", "saved.json"); + if (route === "wrong-goal") jsonPath = join(otherRuns, "saved.json"); + if (route === "outside-directory-link") { + await symlink(outside, join(runs, "external"), process.platform === "win32" ? "junction" : "dir"); + jsonPath = join(runs, "external", "saved.json"); + } + const prior = {settlement_identity: {goal_id: "goal", effect_id: "effect"}, refresh_recovery: {}, + vision_checkpoint: {satisfied: true, read_context: {}}, json_path: jsonPath, + markdown_path: join(runs, "saved.md")}; + const record = JSON.stringify(prior); + if (route === "leaf-directory-link") { + await symlink(outside, jsonPath, process.platform === "win32" ? "junction" : "dir"); + } else if (route === "leaf-link") { + const target = join(runs, "target.json"); + writeFileSync(target, record); + // A file link to an otherwise valid in-directory record is still forbidden. + try { await symlink(target, jsonPath, "file"); } + catch (error) { + if (process.platform !== "win32" || (error as NodeJS.ErrnoException).code !== "EPERM") throw error; + t.skip("Windows file symlinks require a privilege unavailable on this host; directory junction negatives still execute"); + return; + } + } else writeFileSync(jsonPath, record); + writeFileSync(prior.markdown_path, "Saved direction\n"); + const index = record + "\n"; + writeFileSync(join(runs, "index.jsonl"), index); + assert.throws(() => inspectCheckpointReplay({runtime_root: root, goal_id: "goal", prior}), + (error: unknown) => (error as {code: string}).code === "checkpoint_commit_unknown"); + assert.equal(readFileSync(join(runs, "index.jsonl"), "utf8"), index); + }); +} From 77b9b2ad2cf329d8b9917cdbb988cc5ea4760b8d Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 15:23:31 +0800 Subject: [PATCH 44/80] fix(coordination): preserve cold imports across Windows long paths Use the existing extended-path adapter at the cold-import scratch boundary and POSIX normalization for tar member link names on every host. Reuse the existing identity-checked canary cleanup in peer and hot-path smokes. Keep archive checksums, source selection and unsafe-link rejection intact. Validation: all 14 cold-source scope cases pass, including nested 240/320-char workspaces, source witnesses, safe relative links and cycle/ancestor negatives; native cold-import owner: 15 passed/1 Windows file-symlink privilege skip. Peer and standard premerge smokes pass. Standard premerge has 6 direct and 19 selected checks without failures but retains one manual review hold. No timeout limit or permission guard was relaxed; historical failure attribution remains bounded. Signed-off-by: Tartar --- .../hot-path-interface-budget-smoke.py | 7 ++--- ...-agent-continuation-state-machine-smoke.py | 6 ++--- .../coordination/cold_source_backup.py | 6 ++++- .../test_cold_source_backup_scope.py | 26 +++++++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 5c459b83ed..f74dbe3629 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -10,7 +10,6 @@ import json import sys -import tempfile from pathlib import Path from typing import Any from unittest.mock import patch @@ -37,7 +36,9 @@ from loopx.control_plane.scheduler.execution_context import ( # noqa: E402 scheduler_execution_context_for_runtime_profile, ) -from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 +from loopx.control_plane.testing.canary_harness import ( # noqa: E402 + import_fixture_authority, temporary_canary_directory, +) GOAL_ID = "interface-budget-goal" @@ -398,7 +399,7 @@ def assert_cadence_projection( def main() -> int: # Prompt rendering inspects implicit routes even though status reads the # declared fixture registry. Keep both default-route probes in this fixture. - with tempfile.TemporaryDirectory(prefix="loopx-hot-path-budget-") as tmp, patch.multiple( + with temporary_canary_directory(prefix="loopx-hot-path-budget-") as tmp, patch.multiple( "loopx.paths", DEFAULT_RUNTIME_ROOT=Path(tmp) / "default-runtime", LEGACY_RUNTIME_ROOT=Path(tmp) / "legacy-runtime", diff --git a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py index 5e35983a42..652d5ce309 100644 --- a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py +++ b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py @@ -5,7 +5,6 @@ import sys import json -import tempfile from pathlib import Path @@ -16,6 +15,7 @@ from loopx.control_plane.testing.canary_harness import ( # noqa: E402 import_fixture_authority, run_json_cli, + temporary_canary_directory, write_fixture_registry, ) @@ -102,7 +102,7 @@ def should_run(project: Path, runtime: Path, registry_path: Path, agent_id: str) def assert_same_peer_continuation() -> None: - with tempfile.TemporaryDirectory(prefix="loopx-peer-continuation-") as tmp: + with temporary_canary_directory(prefix="loopx-peer-continuation-") as tmp: project, runtime, registry_path = write_fixture(Path(tmp)) first = should_run(project, runtime, registry_path, PEER_ALPHA) assert first["decision"] == "run", first @@ -177,7 +177,7 @@ def assert_same_peer_continuation() -> None: def assert_review_is_action_semantics_over_generic_handoff() -> None: - with tempfile.TemporaryDirectory(prefix="loopx-peer-review-handoff-") as tmp: + with temporary_canary_directory(prefix="loopx-peer-review-handoff-") as tmp: project, runtime, registry_path = write_fixture(Path(tmp), include_review=True) completed = run_cli( "todo", diff --git a/loopx/control_plane/coordination/cold_source_backup.py b/loopx/control_plane/coordination/cold_source_backup.py index 2d0cb1613d..b8f336a85c 100644 --- a/loopx/control_plane/coordination/cold_source_backup.py +++ b/loopx/control_plane/coordination/cold_source_backup.py @@ -14,6 +14,7 @@ import json import os from pathlib import Path, PurePosixPath +import posixpath import shutil import sqlite3 import stat @@ -26,6 +27,7 @@ build_scoped_state_backup_plan, execute_state_backup_plan, ) +from ..runtime.file_paths import windows_extended_path WITNESS_SCHEMA = "loopx_state_backup_source_witness_v1" @@ -114,7 +116,8 @@ def _safe_link(name: str, target: str, *, hardlink: bool) -> str: raise ColdSourceBackupError("cold_import_backup_link_unsafe") if hardlink: return _member_name(target) - resolved = os.path.normpath(str(PurePosixPath(name).parent / target)) + # Archive member names retain POSIX semantics on every host. + resolved = posixpath.normpath(str(PurePosixPath(name).parent / target)) if resolved == ".." or resolved.startswith("../"): raise ColdSourceBackupError("cold_import_backup_link_unsafe") return _member_name(resolved) @@ -192,6 +195,7 @@ def _matches(name: str, prefixes: list[str] | None) -> bool: def _scan_archive( archive: BinaryIO, *, selected: list[str] | None, workspace: Path ) -> tuple[dict[str, Any], list[dict[str, Any]], int, int, int]: + workspace = windows_extended_path(workspace) workspace.mkdir(mode=0o700, parents=True, exist_ok=True) workspace_info = workspace.lstat() if not stat.S_ISDIR(workspace_info.st_mode): diff --git a/tests/control_plane/test_cold_source_backup_scope.py b/tests/control_plane/test_cold_source_backup_scope.py index d34f33cc17..e54f8cf717 100644 --- a/tests/control_plane/test_cold_source_backup_scope.py +++ b/tests/control_plane/test_cold_source_backup_scope.py @@ -13,6 +13,7 @@ from loopx.control_plane.coordination.cold_source_backup import ( ColdSourceBackupError, read_cold_source_backup, ) +from loopx.control_plane.runtime.file_paths import windows_extended_path def backup(tmp_path: Path, unrelated: int = 0, unsafe: bool = False, @@ -79,6 +80,24 @@ def observe_scan(*args, **kwargs): assert max(len(scan.members) for scan in scans) <= 1 +@pytest.mark.parametrize("workspace_length", [240, 320]) +def test_nested_workspace_preserves_witness_and_removes_scratch_index(tmp_path, workspace_length): + manifest = backup(tmp_path) + archive = Path(json.loads(manifest.read_text())["execution"]["archive_path"]) + before = manifest.read_bytes(), archive.read_bytes() + workspace = tmp_path + while len(str(workspace)) < workspace_length: + workspace /= "w" * min(64, max(1, workspace_length - len(str(workspace)) - 1)) + windows_extended_path(workspace).mkdir(parents=True) + assert len(str(workspace)) >= workspace_length + witness = read_cold_source_backup(manifest, workspace=workspace, + source_paths=[tmp_path / "runtime/goals/example/state.md"]) + assert witness["members"] == [{"archive_path": "runtime/goals/example/state.md", + "sha256": hashlib.sha256(b"retained source").hexdigest()}] + assert (manifest.read_bytes(), archive.read_bytes()) == before + assert list(windows_extended_path(workspace).iterdir()) == [] + + @pytest.mark.parametrize("names", [("A.txt", "a.txt"), ("caf\u00e9.txt", "cafe\u0301.txt")]) def test_trusted_witness_preserves_distinct_source_names(tmp_path, names): paths = tuple(f"runtime/other/{name}" for name in names) @@ -206,6 +225,13 @@ def unsafe_manifest(name: str, members: list[tarfile.TarInfo]) -> Path: read_cold_source_backup(unsafe_manifest("ancestor", [link, descendant]), source_paths=[tmp_path]) + target = tarfile.TarInfo("root/target") + target.size = 1 + safe = read_cold_source_backup(unsafe_manifest("safe-relative", [target, link]), + source_paths=[tmp_path]) + assert safe["members"] == [{"archive_path": "root/target", + "sha256": hashlib.sha256(b"x").hexdigest()}] + first = tarfile.TarInfo("root/a") first.type, first.linkname = tarfile.SYMTYPE, "b" second = tarfile.TarInfo("root/b") From 804735aae0650bc752d4f0719254902872c0cb1a Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 15:23:32 +0800 Subject: [PATCH 45/80] docs(checkpoint): clarify alias and recovery evidence boundaries Document physical-parent alias handling, immutable historical replay, separate direction and pending-replan duties, and original-Turn MCP revalidation in both languages. Preserve independent Todo validation and whole-Goal terminal checks. The source regression results do not establish native macOS qualification, remote review approval or benchmark benefit. Historical failures and the combined regular/goal-change evaluation report remain unchanged. Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index c2dd72d88a..9c4e3b8001 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -2375,6 +2375,25 @@ validation still belongs to the later Todo completion. A settled Turn for a sele planning obligation cannot compete with its later work admission. Ordinary progress and completed Todos retain their original-Turn recovery paths. +Checkpoint artifact paths may use an alias of the same physical runtime +directory. New records use physical paths before their record/index projections +are constructed; replay verifies historical aliases without rewriting their +stored paths or fingerprints. The owner resolves the parent directory only, +rejects symbolic-link artifact leaves and external directories, and retains the original +identity, lock witnesses and provider fence. A replaced directory cannot supply +the admitted append merely by containing identical bytes. + +A required direction checkpoint and a selected semantic-replan obligation are +separate conditions. A current evidence-backed Vision can support a freshly +judged unchanged decision; it cannot discharge a pending selected replan without +the existing typed semantic evidence. Once that obligation is accepted and +settled, a later work Turn does not inherit it. Todo validation and whole-Goal +terminal checks still apply independently. + +An explicit retained-candidate reread through MCP restores the admitted original +Turn before running its guard. A missing original admission cannot be repaired +by starting another work identity. + ### 中文:方向停止与原 Turn 显式恢复 方向 Host 返回 `wait`、`user_action_required` 或 `iteration_failed` 后,保留成果, @@ -2389,3 +2408,16 @@ progress and completed Todos retain their original-Turn recovery paths. 待补的 checkpoint 不等于语义 delta 已接受,也不允许提前扣额;新补充判断仍须 验收原义务。新 Todo 的完成验证仍在其原有完成门执行。已结算且已验收所选规划义务的 Turn 不再与后续实际工作竞争;普通进度和已完成 Todo 仍保留原 Turn 恢复路径。 + +checkpoint 工件允许使用同一物理 runtime 目录的别名。新记录在构造 record/index +前生成物理路径;历史重放只验证别名,不改写原路径或摘要。owner 只解析父目录, +继续拒绝工件叶子的符号链接和外部目录,保留原身份、锁见证和 provider fence;目录被 +替换后,即使字节相同,也不能冒充原先准入的追加目标。 + +必需方向 checkpoint 与当前 Turn 选中的语义重规划义务分别判断。当前且关联证据的 +Vision 可以支持新读取后的 unchanged 判断,但不能替代 pending 重规划义务需要的 +typed semantic evidence。该义务验收并结算后,不会被自动施加给后续工作 Turn; +Todo 验证和全 Goal 终态检查仍各自执行。 + +MCP 显式重读保留候选时,先恢复已准入的原 Turn,再执行 guard;缺失的原准入 +不能通过新建另一工作身份补齐。 From 2b19da97dfcdcb08eca3f4164b3626116e41e29e Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 19:49:12 +0800 Subject: [PATCH 46/80] fix(checkpoint): preserve addressed locks across runtime directory aliases Signed-off-by: Tartar --- .../control_plane/goals/checkpoint_commit.ts | 4 +++- .../test_checkpoint_provider_fence.py | 4 +++- ...gresql_authority_store.integration.test.ts | 23 ++++++++++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 6c4195b6e9..9eb7554c58 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -314,7 +314,9 @@ export async function commitCheckpoint(value: unknown, const registryBound = jsonObject(jsonObject(request.facts)?.source)?.registry_goal != null; const registryPath = firstDelivery || resultWork || registryBound ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; - const targets = [indexPath, ...(registryPath ? [registryPath] : []), shadowMaintenanceLockPath(root, identity.goal_id), + // The handoff names the caller's acquired lock; physical IO stays frozen + // separately, and directory identity is rechecked before the append. + const targets = [join(runsDir, "index.jsonl"), ...(registryPath ? [registryPath] : []), shadowMaintenanceLockPath(root, identity.goal_id), legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); const owner = parseQuotaAccountingOwner({ goalRefValue: request.goal_ref, diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index d42d0d826d..a83889e757 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -214,12 +214,14 @@ def native(method, params, **kwargs): # Identical bytes do not make a replacement the directory whose # identity was admitted before the provider head was acquired. shutil.copytree(retained, runs) + assert (runs.stat().st_dev, runs.stat().st_ino) != ( + retained.stat().st_dev, retained.stat().st_ino) (barrier / "release").touch() stdout, stderr = child.communicate(timeout=20) assert child.returncode == 1, stdout + stderr rejected = json.loads(stdout) assert rejected["error_code"] == "checkpoint_commit_unknown", rejected - assert "directory changed" in rejected["error"], rejected + assert "read back the original Turn before retrying" in rejected["error"], rejected raise RuntimeError("replaced directory refused at the checkpoint owner") finally: (barrier / "release").touch() 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 43bd33ce7b..afa3bdefdd 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -14,7 +14,7 @@ import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/a import {TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import assert from "node:assert/strict"; import { createHash, randomUUID } from "node:crypto"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import {dirname, join} from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; @@ -720,11 +720,16 @@ if (database && installed) { await assert.rejects(missing.withCheckpointHead(() => {throw new Error("must not save");}), /head is missing/); }); - test("PostgreSQL production checkpoint effects protect each Turn and preserve original work reads", async t => { + for (const address of ["direct", "alias"] as const) { + test(`PostgreSQL production checkpoint effects protect each Turn and preserve original work reads (${address})`, async t => { await installed; - const root = await mkdtemp(join(tmpdir(), "checkpoint-pg-turns-")); + const directory = await mkdtemp(join(tmpdir(), "checkpoint-pg-turns-")); + const physical = join(directory, "physical"); + await mkdir(physical); + const root = address === "alias" ? join(directory, "alias") : physical; + if (address === "alias") await symlink(physical, root, process.platform === "win32" ? "junction" : "dir"); const tenant = `checkpoint-${randomUUID()}`, goal = "checkpoint-turns", agent = "agent", todo = "todo_task"; - t.after(async () => {await cleanScope(tenant, goal); await rm(root, {recursive: true, force: true});}); + t.after(async () => {await cleanScope(tenant, goal); await rm(directory, {recursive: true, force: true});}); const store = new PostgreSqlAuthorityStore(database, {tenant_id: tenant, goal_id: goal}); const projection = (text: string) => authorityProjectionFixture(goal, [{todo_id: todo, role: "agent", status: "open", done: false, claimed_by: agent, archive_state: "active", text}]); @@ -775,7 +780,7 @@ if (database && installed) { delivery_outcome: "outcome_progress", delivery_batch_scale: "implementation", refresh_recovery: recovery, vision_checkpoint: {required: false, satisfied: true, decision: "not_required", delivery_boundary: "in_flight_continuation"}, json_path: join(runs, `${turn}.json`), markdown_path: join(runs, `${turn}.md`)}; - const submit = async () => { + const submit = async (indexLockTarget = index) => { const targets = [index, registry, shadowMaintenanceLockPath(root, goal), legacyCoordinationTodoLockPath(root, goal), state]; const locks = []; for (const target of targets) locks.push(await acquireFileMutationLock(target, process.pid)); @@ -784,9 +789,14 @@ if (database && installed) { registry_path: registry, registry_sha256: hash(registry), state_sha256: hash(state), index_sha256: hash(index), identity, purpose: "delivery_result", read_context_id: context.read_context_id, facts, refresh_retry: retry, record: {...row}, index_record: {...row}, markdown: "Validated progress", - locks: locks.map((lock, i) => ({target: targets[i], pid: process.pid, token: lock.token}))}); + locks: locks.map((lock, i) => ({target: i === 0 ? indexLockTarget : targets[i], pid: process.pid, token: lock.token}))}); } finally {for (const [i, lock] of locks.entries()) await releaseFileMutationLock(targets[i]!, lock.token);} }; + if (turn === "one") { + const before = readFileSync(index, "utf8"); + await assert.rejects(submit(join(root, "foreign-index.jsonl")), /checkpoint lock target mismatch/); + assert.equal(readFileSync(index, "utf8"), before); + } if (turn === "two") { // An owner-file change alone must invalidate the original result, // even though the canonical Todo head has not changed. @@ -812,4 +822,5 @@ if (database && installed) { } assert.equal(readFileSync(index, "utf8").trim().split("\n").length, 2); }); + } } From c000e71869b14113c118a6940ba9e75f8f21c30f Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 19:49:12 +0800 Subject: [PATCH 47/80] fix(turn): recover missing terminal receipts without replacing original work Signed-off-by: Tartar --- loopx/cli_commands/turn_run_once.py | 5 +- .../test_declared_terminal_settlement_cli.py | 6 +- tests/test_goal_mode_mcp_settlement.py | 26 ++- tests/test_loopx_turn_driver.py | 213 +++++++++++------- 4 files changed, 164 insertions(+), 86 deletions(-) diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index e613f7b0d6..65e114377a 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -757,6 +757,9 @@ def terminal_closeout_resolver(effect_ref: str) -> dict[str, object]: # absent; an ordinary result event is not a closeout receipt. # Receipt classification remains with the TS settlement owner. terminal_failure = readback.terminal_closeout.failure + if (terminal_failure is not None and + terminal_failure.kind is not SettlementFailureKind.RECEIPT_MISSING): + return {"kind": "unknown", "reason": terminal_failure.reason} if (completion is None and terminal_failure is not None and terminal_failure.kind is SettlementFailureKind.RECEIPT_MISSING): return {"kind": "absent"} @@ -770,7 +773,7 @@ def terminal_closeout_resolver(effect_ref: str) -> dict[str, object]: "kind": "unknown", "reason": "Todo state does not prove terminal no-followup", } - if event is None: + if terminal_failure is not None: append_settlement_event( {"ok": True, "appended": True}, event_kind="todo_complete", diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index f7be333ffc..859ad243ba 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -241,8 +241,9 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID) code, guard = run("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, - *binding) + *binding, "--available-capability", "shell", "--available-capability", "filesystem_write") assert code == 0, guard.get("error") or guard.get("reason") + assert guard["should_run"] is True and guard.get("delivery_result_context"), guard command = ("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, "--evidence", "Only the original controller check can accept this deliverable.") code, invalid_lease = run(*command, *lease[:2], "--task-lease-expected-version", "999") @@ -289,8 +290,9 @@ def test_lease_change_during_validator_still_blocks_completion( binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID) code, guard = run("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, - *binding) + *binding, "--available-capability", "shell", "--available-capability", "filesystem_write") assert code == 0, guard + assert guard["should_run"] is True and guard.get("delivery_result_context"), guard code, rejected = run("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, "--evidence", "Validator renews the lease before final completion.") assert code == 1, rejected diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 94dd79191a..c127c91372 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -536,10 +536,13 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response( assert json.loads(control.should_run())["delivery_result_context"] injected = False bound_identity = None + completion_args = None def lose_once(args, **kwargs): - nonlocal injected, bound_identity + nonlocal injected, bound_identity, completion_args output = original(args, **kwargs) + if completion_args is None and args[:2] == ["todo", "complete"] and json.loads(output).get("ok"): + completion_args = tuple(args) step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], "spend": ["quota", "spend-slot"]}[lost_after] if not injected and args[:len(step)] == step and json.loads(output).get("ok"): @@ -552,7 +555,7 @@ def lose_once(args, **kwargs): first = json.loads(_complete_default_task(control, added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) - assert injected and first["ok"] is False + assert injected and first["ok"] is False, first from loopx.control_plane.quota.settlement import read_heartbeat_settlement readback = read_heartbeat_settlement( tmp_path / "runtime", goal_id=GOAL_ID, agent_id=AGENT_ID, @@ -567,8 +570,14 @@ def lose_once(args, **kwargs): "--turn-instance-id", bound_identity["turn_instance_id"], "--no-follow-up", "--evidence", "synthetic terminal intent", ) - # New default admissions retain the successful result while deferring - # closeout. Replaying it cannot close the Goal or change its evidence. + # Reusing the operation with different evidence is a changed request, + # not a replay. Recover the actual original request below. + assert rc == 1 and terminal["error_code"] == "coordination_operation_identity_mismatch", terminal + assert state_file.read_bytes() == before_terminal + assert completion_args is not None + rc, terminal = _run_cli(registry, *completion_args) + # The exact original replay retains the deferred terminal intent and + # cannot close the Goal or rewrite accepted evidence. assert rc == 0 and terminal["ok"] is True, terminal todo = next(row for row in parse_active_state_todos( state_file.read_text(encoding="utf-8"))["agent_todos"]["items"] @@ -601,9 +610,18 @@ def test_real_mcp_links_existing_successor_without_creating_another_todo( claimed_by=AGENT_ID, )["todo_id"]) for text in ("First accepted step.", "Already planned follow-up.")] control = _control(registry) + initial = json.loads(control.should_run()) + assert initial["interaction_contract"]["agent_channel"]["selection_required"] is True, initial + rc, admitted = _run_cli(registry, "quota", "should-run", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--runtime-profile", "claude_code", "--todo-id", ids[0], + "--turn-instance-id", initial["heartbeat_receipt"]["turn_instance_id"]) + assert rc == 0, admitted + assert admitted["selected_todo"]["todo_id"] == ids[0], admitted + assert admitted.get("delivery_result_context"), admitted before = state_file.read_bytes() work = json.loads(control.should_run(revalidate_todo_id=ids[0])) assert work.get("delivery_result_context"), work + assert work["delivery_result_context"]["settlement_identity"] == admitted["delivery_result_context"]["settlement_identity"] rejected = json.loads(_complete_default_task(control, ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], no_follow_up=True, )) diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index e3b2fb1667..20152f8836 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -17,6 +17,10 @@ from tests.control_plane.canonical_authority_fixture import ( initialize_canonical_authority, ) +from tests.control_plane.host_checkpoint_fixture import ( + checkpoint_codex_fixture_response, + checkpoint_fixture_script, +) from loopx.cli import main as cli_main from loopx.control_plane.coordination.runtime_shadow import ( build_todo_runtime_shadow_projection, @@ -1029,6 +1033,7 @@ def test_default_agent_session_reuses_planning_across_completed_todos(tmp_path, from benchmark.runtime.codex import Execution from benchmark.runtime.sessions import BenchmarkSessionWake from tests.test_loopx_turn_codex_cli import _fake_codex + from tests.control_plane.host_process_fixture import write_python_host_fixture project, runtime, registry = _write_live_fixture(tmp_path, extra_agent_todo_lines=( "- [ ] [P1] Complete the successor fixture.", @@ -1049,8 +1054,20 @@ def test_default_agent_session_reuses_planning_across_completed_todos(tmp_path, observed = tmp_path / "planning-stream.jsonl" observed.write_text('{"type":"thread.started","thread_id":"session-fixture-0001"}\n') wake.observe(observed) - binary.write_text(binary.read_text().replace('"validated_progress"', '"validated_completion"').replace( - 'output_path = ', 'pathlib.Path("completed-todo.txt").write_text("complete")\noutput_path = ', + script = binary.with_suffix(".py") if os.name == "nt" else binary + direction = ( + f"sys.path.insert(0, {str(Path(__file__).resolve().parents[1])!r})\n" + "from tests.control_plane.host_checkpoint_fixture import checkpoint_decision\n" + "from loopx.control_plane.turn_driver.host_candidate import parse_model_json\n" + "request = parse_model_json(prompt) or {}\n" + "if request.get('phase') == 'checkpoint_direction':\n" + " output = pathlib.Path(args[args.index('--output-last-message') + 1])\n" + " output.write_text(json.dumps(checkpoint_decision(request['checkpoint_context'])))\n" + " raise SystemExit(0)\n" + 'pathlib.Path("completed-todo.txt").write_text("complete")\n' + ) + binary = write_python_host_fixture(tmp_path / "fake-codex", script.read_text().replace('"validated_progress"', '"validated_completion"').replace( + 'output_path = ', direction + 'output_path = ', )) argv = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "turn", "run-once", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", @@ -1070,7 +1087,7 @@ def test_default_agent_session_reuses_planning_across_completed_todos(tmp_path, assert result["status"] == "committed", result results.append(result) calls = [json.loads(line) for line in log.read_text().splitlines()] - assert len(calls) == 2 and all("resume" in call and "session-fixture-0001" in call for call in calls) + assert len(calls) == 4 and all("resume" in call and "session-fixture-0001" in call for call in calls) assert results[0]["resume_turn_key"] != results[1]["resume_turn_key"] journals = [json.loads(path.read_text()) for path in (runtime / "goals/loopx-turn-fixture/turns").glob("*.json")] assert {journal["writeback"]["completion"]["todo_id"] for journal in journals} == { @@ -2071,11 +2088,14 @@ def interrupt_checkpoint(path, journal): assert policy_code == 0, policy_output.getvalue() host_project = project / "isolated-host-workspace" host_project.mkdir() - host_script = """ + host_script = checkpoint_fixture_script(""" import json import pathlib import sys +from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint request = json.load(sys.stdin) +if reply_to_checkpoint(request): + raise SystemExit(0) pathlib.Path("fixture-artifact.txt").write_text("validated", encoding="utf-8") json.dump({ "schema_version": "loopx_turn_result_v0", @@ -2090,7 +2110,9 @@ def interrupt_checkpoint(path, journal): "vision_unchanged_reason": "The fixture objective remains unchanged.", "summary": "One public fixture advanced." }, sys.stdout) -""" +""") + host_path = host_project / "progress-host.py" + host_path.write_text(host_script, encoding="utf-8") validation_script = """ import json import pathlib @@ -2120,7 +2142,7 @@ def interrupt_checkpoint(path, journal): "--project", str(host_project), "--host-adapter-command-json", - json.dumps([sys.executable, "-c", host_script]), + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", @@ -2191,9 +2213,12 @@ def interrupt_checkpoint(path, journal): index_path = runtime / "goals" / "loopx-turn-fixture" / "runs" / "index.jsonl" rows = [json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines()] assert [row["classification"] for row in rows] == [ + "fixture_progress", "fixture_progress", "quota_slot_spent", ] + assert rows[0]["vision_checkpoint"]["decision"] == "missing_required" + assert rows[1]["vision_checkpoint"]["satisfied"] is True assert rows[0]["recommended_action"] == "Continue the public fixture" assert not rows[0]["recommended_action_resolution"].get("step_revision") @@ -2218,7 +2243,7 @@ def interrupt_checkpoint(path, journal): "--project", str(host_project), "--host-command-json", - json.dumps([sys.executable, "-c", host_script]), + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", @@ -2241,10 +2266,7 @@ def interrupt_checkpoint(path, journal): json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines() ] - assert [row["classification"] for row in replayed_rows] == [ - "fixture_progress", - "quota_slot_spent", - ] + assert replayed_rows == rows next_output = io.StringIO() with contextlib.redirect_stdout(next_output): @@ -2253,7 +2275,7 @@ def interrupt_checkpoint(path, journal): "turn", "run-once", "--host", "generic-cli", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", "--turn-instance-id", "next-cadence-fixture", "--project", str(host_project), "--host-command-json", - json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", "--execute", ]) @@ -2264,9 +2286,7 @@ def interrupt_checkpoint(path, journal): assert waiting["effects"]["host_invoked"] is False assert waiting["effects"]["quota_spent"] is False assert "- next_eligible_at: " in render_loopx_turn_execution_markdown(waiting) - assert [json.loads(line)["classification"] for line in index_path.read_text(encoding="utf-8").splitlines()] == [ - "fixture_progress", "quota_slot_spent", - ] + assert [json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines()] == rows def test_turn_run_once_cli_dsh_fresh_sessions_and_iteration_failure( @@ -2399,9 +2419,18 @@ def test_turn_run_once_cli_dsh_preserves_material_path_delta( } runner = tmp_path / "material_replan_dsh_runner.py" runner.write_text( - "import json\nfrom pathlib import Path\n" + "import json, sys\nfrom pathlib import Path\n" + f"sys.path.insert(0, {str(Path(__file__).resolve().parents[1])!r})\n" + "from tests.control_plane.host_checkpoint_fixture import checkpoint_decision\n" + "from loopx.control_plane.turn_driver.host_candidate import parse_model_json\n" f"candidate = {candidate!r}\n" "def run_dsh_turn(**kwargs):\n" + " request = parse_model_json(kwargs['prompt']) or {}\n" + " if request.get('phase') == 'checkpoint_direction':\n" + " decision = checkpoint_decision(request['checkpoint_context'])\n" + " decision.pop('agent_vision', None)\n" + " decision.update(agent_vision_json=candidate['agent_vision_json'], vision_unchanged_reason='')\n" + " return json.dumps(decision)\n" " counter = Path(kwargs['workspace']) / 'host-count.txt'\n" " counter.write_text(str(int(counter.read_text()) + 1 if counter.exists() else 1))\n" " Path(kwargs['workspace'], 'validated-artifact.txt').write_text('validated')\n" @@ -2441,8 +2470,11 @@ def test_turn_run_once_cli_dsh_preserves_material_path_delta( assert payload["effects"]["quota_spent"] is True rows_path = runtime / "goals/loopx-turn-fixture/runs/index.jsonl" rows = [json.loads(line) for line in rows_path.read_text(encoding="utf-8").splitlines()] - durable = next(row for row in rows if row.get("classification") == "fixture_replan_successor") - recorded = json.loads(Path(durable["json_path"]).read_text(encoding="utf-8")) + durable = [row for row in rows if row.get("classification") == "fixture_replan_successor"] + assert len(durable) == 2 + assert durable[0]["vision_checkpoint"]["decision"] == "missing_required" + assert durable[1]["vision_checkpoint"]["satisfied"] is True + recorded = json.loads(Path(durable[1]["json_path"]).read_text(encoding="utf-8")) assert recorded["agent_vision"]["path_delta"]["outcome"] == "replan" @@ -2457,62 +2489,14 @@ def test_turn_run_once_cli_completes_selected_todo_after_validation( project, runtime, registry = _write_live_fixture(tmp_path) host_project = project / "isolated-host-workspace" host_project.mkdir() - host_script = """ -import json -import pathlib -import sys -request = json.load(sys.stdin) -pathlib.Path("completion-artifact.txt").write_text("completed", encoding="utf-8") -json.dump({ - "schema_version": "loopx_turn_result_v0", - "turn_key": request["turn_key"], - "result_kind": "validated_completion", - "completed_phases": ["host_execute", "typed_result"], - "classification": "fixture_completion", - "recommended_action": "Refresh the active goal after completion.", - "next_action": "Select the next Todo from a fresh decision.", - "delivery_batch_scale": "implementation", - "delivery_outcome": "outcome_progress", - "vision_unchanged_reason": "The active goal may have further work.", - "summary": "One public fixture completed." -}, sys.stdout) -""" + host_script, validation_script = _completion_host_and_validation_scripts() host_script = host_script.replace( "Select the next Todo from a fresh decision.", next_action, ) - validation_script = """ -import json -import pathlib -import sys -json.load(sys.stdin) -artifact = pathlib.Path("completion-artifact.txt") -raise SystemExit(0 if artifact.read_text(encoding="utf-8") == "completed" else 7) -""" output = io.StringIO() - argv = [ - "--registry", - str(registry), - "--runtime-root", - str(runtime), - "--format", - "json", - "turn", - "run-once", - "--host", - "generic-cli", - "--goal-id", - "loopx-turn-fixture", - "--agent-id", - "codex-fixture", - "--project", - str(host_project), - "--host-adapter-command-json", - json.dumps([sys.executable, "-c", host_script]), - "--validation-command-json", - json.dumps([sys.executable, "-c", validation_script]), - "--no-global-sync", - "--execute", - ] + argv = _turn_run_once_completion_argv( + host_project, runtime, registry, host_script, validation_script, + ) with contextlib.redirect_stdout(output): exit_code = cli_main(argv) @@ -2613,6 +2597,19 @@ def test_turn_run_once_cli_completes_selected_todo_after_validation( assert replayed_exit_code == 0, replayed assert replayed["replayed"] is True assert not any(replayed["effects"].values()) + calls = [json.loads(line) for line in (host_project / "host-calls.jsonl").read_text().splitlines()] + assert len(calls) == 2 + assert calls[0]["phase"] != "checkpoint_direction" + assert calls[1]["phase"] == "checkpoint_direction" + assert calls[1]["turn_key"] == calls[0]["turn_key"] == payload["resume_turn_key"] + assert calls[1]["session_action"] == "resume" + assert calls[1]["read_context_id"] + assert (host_project / "validation-calls.txt").read_text().splitlines() == ["validated"] + quota_events = [json.loads(line) for line in ( + runtime / "goals" / "loopx-turn-fixture" / "rollout-event-log.jsonl" + ).read_text().splitlines()] + assert sum(event.get("event_kind") == "quota_spend" + and event.get("run_id") == payload["resume_turn_key"] for event in quota_events) == 1 def test_promoted_turn_completion_replays_after_commit_before_journal_crash( @@ -2706,11 +2703,18 @@ def crash_after_canonical_completion(**kwargs: object) -> dict[str, object]: def _completion_host_and_validation_scripts() -> tuple[str, str]: - host_script = """ + host_script = checkpoint_fixture_script(""" import json import pathlib import sys +from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint request = json.load(sys.stdin) +with pathlib.Path("host-calls.jsonl").open("a", encoding="utf-8") as calls: + calls.write(json.dumps({"phase": request.get("phase"), "turn_key": request["turn_key"], + "session_action": request["session"]["action"], + "read_context_id": request.get("checkpoint_context", {}).get("read_context_id")}) + "\\n") +if reply_to_checkpoint(request): + raise SystemExit(0) pathlib.Path("completion-artifact.txt").write_text("completed", encoding="utf-8") json.dump({ "schema_version": "loopx_turn_result_v0", @@ -2725,12 +2729,14 @@ def _completion_host_and_validation_scripts() -> tuple[str, str]: "vision_unchanged_reason": "The active goal may have further work.", "summary": "One public fixture completed." }, sys.stdout) -""" +""") validation_script = """ import json import pathlib import sys json.load(sys.stdin) +with pathlib.Path("validation-calls.txt").open("a", encoding="utf-8") as calls: + calls.write("validated\\n") artifact = pathlib.Path("completion-artifact.txt") raise SystemExit(0 if artifact.read_text(encoding="utf-8") == "completed" else 7) """ @@ -2744,6 +2750,10 @@ def _turn_run_once_completion_argv( host_script: str, validation_script: str, ) -> list[str]: + # Keep the real command's per-argument budget; fixture source is a file, + # rather than an ever-growing inline Python argument. + host_path = project / "completion-host.py" + host_path.write_text(host_script, encoding="utf-8") return [ "--registry", str(registry), @@ -2762,7 +2772,7 @@ def _turn_run_once_completion_argv( "--project", str(project), "--host-adapter-command-json", - json.dumps([sys.executable, "-c", host_script]), + json.dumps([sys.executable, str(host_path)]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", @@ -2950,13 +2960,18 @@ def test_turn_run_once_cli_projects_durable_no_followup_continuation( [None, "sha256:different-turn"], ids=["missing-turn-key", "different-turn-key"], ) +@pytest.mark.parametrize("terminal_event_fault", ["missing", "contradictory"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( tmp_path: Path, display_completion_turn_key: str | None, + terminal_event_fault: str, + provider: str, ) -> None: project, runtime, registry = _write_live_fixture( tmp_path, todo_metadata_extra="no_followup=true", + provider=provider, ) host_project = project / "isolated-host-workspace" host_project.mkdir() @@ -3008,9 +3023,19 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( if not ( event.get("event_kind") == "todo_complete" and event.get("run_id") == turn_key + and event.get("status") == "terminal_no_followup" ) ] assert len(events_without_terminal) == len(events) - 1 + # Keep the result's deferred completion receipt; only the terminal event + # is lost at this crash boundary. + deferred = [event for event in events_without_terminal + if event.get("event_kind") == "todo_complete" and event.get("run_id") == turn_key] + assert len(deferred) == 1 and deferred[0]["status"] == "completed" + if terminal_event_fault == "contradictory": + terminal_event = next(event for event in events if event not in events_without_terminal) + terminal_event["details"]["no_followup"] = False + events_without_terminal.append(terminal_event) event_path.write_text( "".join(json.dumps(event) + "\n" for event in events_without_terminal), encoding="utf-8", @@ -3044,6 +3069,9 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( ): journal.pop(key, None) journal_path.write_text(json.dumps(journal), encoding="utf-8") + event_bytes = event_path.read_bytes() + host_calls = (host_project / "host-calls.jsonl").read_bytes() + validation_calls = (host_project / "validation-calls.txt").read_bytes() resumed_output = io.StringIO() with contextlib.redirect_stdout(resumed_output): @@ -3056,6 +3084,14 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( ] ) resumed = json.loads(resumed_output.getvalue()) + assert (host_project / "host-calls.jsonl").read_bytes() == host_calls + assert (host_project / "validation-calls.txt").read_bytes() == validation_calls + if terminal_event_fault == "contradictory": + assert resumed_exit_code == 1, resumed + assert not any(resumed["effects"].values()) + assert event_path.read_bytes() == event_bytes + assert "effect_outcome_unknown" in json.dumps(resumed), resumed + return assert resumed_exit_code == 0, resumed assert resumed["status"] == "committed" @@ -3075,11 +3111,13 @@ def test_turn_run_once_cli_terminal_recovery_ignores_stale_display_identity( if ( event.get("event_kind") == "todo_complete" and event.get("run_id") == turn_key + and event.get("status") == "terminal_no_followup" ) ] assert len(terminal_events) == 1 assert terminal_events[0]["status"] == "terminal_no_followup" assert terminal_events[0]["details"]["command"] == "turn recovery probe" + assert deferred[0] in recovered_events assert not any( event.get("event_kind") == "todo_complete" and event.get("run_id") != turn_key @@ -3437,7 +3475,9 @@ def test_turn_run_once_cli_rejects_unproven_host_claim_before_writeback( assert payload["effects"]["state_written"] is False assert payload["effects"]["quota_spent"] is False assert state_path.read_text(encoding="utf-8") == before_state - assert not (runtime / "goals" / "loopx-turn-fixture" / "runs").exists() + # Admission may persist its captured work basis; failed validation must + # produce neither a business result nor a debit. + assert not (runtime / "goals" / "loopx-turn-fixture" / "runs" / "index.jsonl").exists() @pytest.mark.parametrize( @@ -3517,6 +3557,9 @@ def recording_spend_quota_slot(*args: object, **kwargs: object) -> dict[str, obj return real_spend_quota_slot(*args, **kwargs) def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, object]: + direction = checkpoint_codex_fixture_response(request, runtime) + if direction is not None: + return direction return { "schema_version": "loopx_turn_result_v0", "turn_key": request["turn_key"], @@ -3585,9 +3628,9 @@ def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, assert payload["host"] == {"executable": "built-in", "kind": "codex-cli"} assert payload["effects"]["state_written"] is True assert payload["effects"]["quota_spent"] is True - assert refresh_workspace_paths == [project] + assert refresh_workspace_paths == [project, project] assert spend_workspace_paths == [project] - assert refresh_todo_ids == ["todo_fixture0001"] + assert refresh_todo_ids == ["todo_fixture0001", "todo_fixture0001"] state = ( project / ".codex" @@ -3673,6 +3716,10 @@ def fake_codex_host( request: Mapping[str, Any], **_kwargs: object, ) -> dict[str, object]: + if host_kind == "codex-cli": + direction = checkpoint_codex_fixture_response(request, runtime) + if direction is not None: + return direction return { "schema_version": "loopx_turn_result_v0", "turn_key": request["turn_key"], @@ -3729,8 +3776,14 @@ def fake_ingest(**kwargs: object) -> dict[str, object]: runner = tmp_path / "reflection_runner.py" candidate = fake_codex_host({"turn_key": "unused"}) runner.write_text( - "import json\n" + "import json, sys\n" + f"sys.path.insert(0, {str(Path(__file__).resolve().parents[1])!r})\n" + "from tests.control_plane.host_checkpoint_fixture import checkpoint_decision\n" + "from loopx.control_plane.turn_driver.host_candidate import parse_model_json\n" "def run_dsh_turn(**kwargs):\n" + " request = parse_model_json(kwargs['prompt']) or {}\n" + " if request.get('phase') == 'checkpoint_direction':\n" + " return json.dumps(checkpoint_decision(request['checkpoint_context']))\n" f" return {json.dumps(candidate)!r}\n", encoding="utf-8", ) @@ -3738,12 +3791,14 @@ def fake_ingest(**kwargs: object) -> dict[str, object]: elif host_kind == "generic-cli": runner = tmp_path / "generic_reflection_runner.py" candidate = fake_codex_host({"turn_key": "unused"}) - runner.write_text( + runner.write_text(checkpoint_fixture_script( "import json, sys\n" + "from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint\n" "request = json.load(sys.stdin)\n" + "if reply_to_checkpoint(request): raise SystemExit(0)\n" f"result = json.loads({json.dumps(candidate)!r})\n" "result['turn_key'] = request['turn_key']\n" - "json.dump(result, sys.stdout)\n", + "json.dump(result, sys.stdout)\n"), encoding="utf-8", ) host_args += ["--host-adapter-command-json", json.dumps([sys.executable, str(runner)])] From 817b1dc09956e20ee360e5234c8b9631b5d9e204 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 20:31:23 +0800 Subject: [PATCH 48/80] fix(turn): retain main recovery safeguards across freshness integration Signed-off-by: Tartar --- loopx/cli_commands/turn_run_once.py | 1 + .../todos/provider_terminal_lifecycle.py | 61 ++++++++++++------- loopx/control_plane/turn_driver/executor.py | 36 +++++++---- .../work_items/semantic_replan_writeback.py | 51 +++++++++++----- 4 files changed, 102 insertions(+), 47 deletions(-) diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 65e114377a..ab880261fd 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json from collections.abc import Callable, Mapping from contextlib import nullcontext from pathlib import Path diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 557115a9b7..f7d27a6019 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -242,6 +242,40 @@ def _persist_validated_completion_result( raise ValueError("completion result changed during acceptance validation") +def _terminal_validation_updates( + result: Mapping[str, Any], + *, + request: dict[str, Any], + runtime_root: Path, + registry_path: Path, + goal_id: str, + delivery_workspace: Mapping[str, Any] | None, + validation_workspace_path: Path | None, +) -> tuple[dict[str, Any], bool]: + context = request.get("delivery_context") + recovery = None + if isinstance(context, Mapping) and context.get("read_context_id"): + from ..goals.checkpoint_context_io import terminal_validation_effect_io + + recovery = terminal_validation_effect_io( + runtime_root=runtime_root, request=request, context=context, execution_result=result, + ) + if recovery is not None and recovery["effect"] == "replay": + updates = recovery["updates"] + else: + updates = execute_completion_validation_effects( + result, registry_path=registry_path, goal_id=goal_id, + delivery_workspace=delivery_workspace, + validation_workspace_path=validation_workspace_path, + ) + if recovery is not None: + terminal_validation_effect_io( + runtime_root=runtime_root, request=request, context=context, + execution_result=result, updates=updates, + ) + return updates, recovery is None or recovery["effect"] == "run" + + def terminal_canonical_todo_if_promoted( *, registry_path: Path, @@ -454,27 +488,13 @@ def invoke() -> Any: completion_validation_executed = False if isinstance(result, Mapping) and result.get("status") == "execute_validation": request["validation_source_provider_revision"] = result["provider_revision"] + updates, completion_validation_executed = _terminal_validation_updates( + result, request=request, runtime_root=runtime_root, + registry_path=registry_path, goal_id=goal_id, + delivery_workspace=completion_delivery_workspace, + validation_workspace_path=completion_validation_workspace_path, + ) context = request.get("delivery_context") - recovery = None - if isinstance(context, Mapping) and context.get("read_context_id"): - from ..goals.checkpoint_context_io import terminal_validation_effect_io - - recovery = terminal_validation_effect_io( - runtime_root=runtime_root, request=request, context=context, execution_result=result, - ) - if recovery is not None and recovery["effect"] == "replay": - updates = recovery["updates"] - else: - updates = execute_completion_validation_effects( - result, registry_path=registry_path, goal_id=goal_id, - delivery_workspace=completion_delivery_workspace, - validation_workspace_path=completion_validation_workspace_path, - ) - if recovery is not None: - terminal_validation_effect_io( - runtime_root=runtime_root, request=request, context=context, - execution_result=result, updates=updates, - ) request.update(updates) if isinstance(context, Mapping) and context.get("read_context_id"): from ..goals.checkpoint_context_io import bind_terminal_result_validation @@ -487,7 +507,6 @@ def invoke() -> Any: request=request, source=completion_result_file, runtime_root=runtime_root, goal_id=goal_id, expected_descriptor=result_descriptor, ) - completion_validation_executed = recovery is None or recovery["effect"] == "run" request["observed_at"] = now_local() result = invoke() if not isinstance(result, Mapping): diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 70cec1184c..5df28d440f 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -1556,6 +1556,27 @@ def persist_tail_checkpoint(*, release: bool) -> None: ) +def _execution_host( + plan: Mapping[str, Any], + *, + host_runner: HostRunner | None, + host_argv: Sequence[str] | None, +) -> tuple[list[str] | None, dict[str, Any]]: + if host_runner is not None and host_argv is not None: + raise ValueError("run-once accepts either host_argv or host_runner, not both") + if host_runner is None: + argv = normalize_host_argv(host_argv or []) + host_projection = {"executable": Path(argv[0]).name, "argv_count": len(argv)} + else: + argv = None + planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} + host_projection = { + "executable": "built-in", + "kind": str(planned_host.get("kind") or "codex-cli"), + } + return argv, host_projection + + @single_executor_per_turn_lane(execution_payload) def run_loopx_turn_once( plan: Mapping[str, Any], @@ -1589,18 +1610,9 @@ def run_loopx_turn_once( result_work: TurnResultWork | None = None, post_result_checkpoint: Callable[[], bool] | None = None, ) -> dict[str, Any]: - if host_runner is not None and host_argv is not None: - raise ValueError("run-once accepts either host_argv or host_runner, not both") - if host_runner is None: - argv = normalize_host_argv(host_argv or []) - host_projection = {"executable": Path(argv[0]).name, "argv_count": len(argv)} - else: - argv = None - planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} - host_projection = { - "executable": "built-in", - "kind": str(planned_host.get("kind") or "codex-cli"), - } + argv, host_projection = _execution_host( + plan, host_runner=host_runner, host_argv=host_argv, + ) transaction_plan = ( plan.get("transaction") if isinstance(plan.get("transaction"), dict) else {} ) diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index bf2afd51cd..2997d71888 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -210,6 +210,36 @@ def _obligation_was_created_by_current_completion( ) +def _retained_writeback_obligation( + source: dict[str, Any] | None, + *, + guard_scoped: bool, + selected_id: str | None, + lifecycle: Mapping[str, Any] | None, + current_obligation: dict[str, Any] | None, + guard_recorded_at: str | None, + agent_id: str, + todo_items: list[dict[str, Any]], +) -> dict[str, Any] | None: + if source is None or not guard_scoped: + return current_obligation + obligation = current_obligation + if source.get("obligation_id") != selected_id: + raise ValueError("guard_source_invalid: writeback source identity differs") + if not lifecycle: + obligation = source + if (source.get("resolution_mode") == "todo_lifecycle_settlement" + and guard_recorded_at and todo_items): + from ..effect_runtime import effect_runtime_result + obligation = effect_runtime_result("work_item.replan_semantics.project", { + "operation": "lifecycle_obligation", "obligation": source, + "selected_obligation_id": selected_id, + "guard_recorded_at": guard_recorded_at, + "agent_id": agent_id, "items": todo_items, + })["obligation"] + return obligation + + @effect_runtime_request_scope() def qualify_replan_writeback( *, @@ -375,20 +405,13 @@ def qualify_replan_writeback( context.get("replan_transition_candidates", []) if isinstance(candidate.get("obligation"), dict) and candidate["obligation"].get("obligation_id") == guard_semantic_replan_obligation_id), None) - if receipt_bound_source is not None and guard_scoped: - if receipt_bound_source.get("obligation_id") != guard_semantic_replan_obligation_id: - raise ValueError("guard_source_invalid: writeback source identity differs") - if not lifecycle: - obligation = receipt_bound_source - if (receipt_bound_source.get("resolution_mode") == "todo_lifecycle_settlement" - and receipt_bound_guard_recorded_at and agent_todo_source_items): - from ..effect_runtime import effect_runtime_result - obligation = effect_runtime_result("work_item.replan_semantics.project", { - "operation": "lifecycle_obligation", "obligation": receipt_bound_source, - "selected_obligation_id": guard_semantic_replan_obligation_id, - "guard_recorded_at": receipt_bound_guard_recorded_at, - "agent_id": safe_agent_id, "items": agent_todo_source_items, - })["obligation"] + obligation = _retained_writeback_obligation( + receipt_bound_source, guard_scoped=guard_scoped, + selected_id=guard_semantic_replan_obligation_id, + lifecycle=lifecycle, current_obligation=obligation, + guard_recorded_at=receipt_bound_guard_recorded_at, + agent_id=safe_agent_id, todo_items=agent_todo_source_items, + ) # Every selected replan retains its exact receipt-bound source. A missing # reconstruction must not fall through as an unselected future obligation # and commit an unqualified writeback. Plain outcome reviews additionally From daee77dccf3243c77c982d5d68da9f13143ff459 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 20:31:24 +0800 Subject: [PATCH 49/80] test(turn): qualify ambiguous recovery and native host fixtures after rebase Signed-off-by: Tartar --- tests/control_plane/test_default_result_freshness.py | 12 ++++++++++++ tests/control_plane/test_operation_requests.py | 4 ++-- tests/test_local_delegation.py | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index 7d85ecaf5b..d01a07aca7 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -368,6 +368,18 @@ def lose_response(*args, **kwargs): assert guard.get("result_committed") is True and "delivery_result_context" not in guard, guard rc, resumed = _run_cli(registry, runtime, *args, "--resume-turn-key", failed["turn_key"], "--retry-failed-turn", cwd=project) + if recovery_window == "input_persisted": + # Removing the prior result does not prove that its dispatched Host + # never ran. Keep main's ambiguous-attempt fence, even with a newer + # work input and the valid original candidate still on disk. + assert rc == 1, resumed + assert resumed["recovery_decision"]["reason"] == "host_outcome_unknown" + assert resumed["effects"]["host_invoked"] is None + assert (project / "candidate.txt").read_text() == "valid candidate" + assert not (project / "revalidation_started").exists() + assert _spend_run_count(runtime) == 0 + assert json.loads(journal_path.read_text(encoding="utf-8")) == failed + return if recovery_window in {"terminal_validation_failed", "terminal_stop"}: assert rc == 1 and resumed["status"] == "failed", resumed pending = json.loads(journal_path.read_text(encoding="utf-8")) diff --git a/tests/control_plane/test_operation_requests.py b/tests/control_plane/test_operation_requests.py index 55c0c64436..c8d31c8456 100644 --- a/tests/control_plane/test_operation_requests.py +++ b/tests/control_plane/test_operation_requests.py @@ -271,8 +271,8 @@ def test_codex_app_server_resolves_ref_and_freezes_profile_while_inline_remains_ ' answer = {name: "" for name, schema in properties.items()', ) assert server != FAKE_SERVER - executable.write_text(server) - executable.chmod(0o700) + from tests.control_plane.host_process_fixture import write_python_host_fixture + executable = write_python_host_fixture(executable, server) options = dict(runtime_root=store.root.parent.parent, registry_path=service.registry_path, project=tmp_path / "project", codex_bin=str(executable), model="test-model", reasoning_effort="xhigh", timeout_seconds=5, operation_request_file=source.path, operation_request_sha256=source.sha256) diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index e433ed99d0..6adea33ca1 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -297,9 +297,9 @@ def test_confirmed_wake_reaches_native_acceptance_through_real_delegation_and_tu f"counter = Path({str(native_starts)!r})\n" "counter.write_text(str(int(counter.read_text()) + 1 if counter.exists() else 1))\n" ) - executable.write_text(FAKE_SERVER.replace('thread = "owned-app-server-thread"', - startup_probe + 'thread = "owned-app-server-thread"')) - executable.chmod(0o700) + from tests.control_plane.host_process_fixture import write_python_host_fixture + executable = write_python_host_fixture(executable, FAKE_SERVER.replace( + 'thread = "owned-app-server-thread"', startup_probe + 'thread = "owned-app-server-thread"')) config = json.loads(runner.config.read_text()) binding = config["bindings"][0] binding["host_args"] = ["--host", "codex-cli", "--codex-bin", str(executable), From a3d8fe7e477dc7e00a94cd6730f7aed0c83b7ff4 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 21:07:31 +0800 Subject: [PATCH 50/80] test(turn): keep original observations through fresh direction and interrupted work Signed-off-by: Tartar --- examples/loopx-turn-codex-cli-e2e-smoke.py | 10 +++++++++ .../test_turn_host_decision_capture.py | 21 +++++++++++++------ .../test_turn_interruption_recovery.py | 2 +- tests/test_codex_operation_host.py | 2 +- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/examples/loopx-turn-codex-cli-e2e-smoke.py b/examples/loopx-turn-codex-cli-e2e-smoke.py index d88318fc39..c90f0d7702 100755 --- a/examples/loopx-turn-codex-cli-e2e-smoke.py +++ b/examples/loopx-turn-codex-cli-e2e-smoke.py @@ -137,6 +137,16 @@ def _write_fake_codex(root: Path) -> Path: args = sys.argv[1:] prompt = sys.stdin.read() +if '\"phase\":\"checkpoint_direction\"' in prompt: + sys.path.insert(0, {str(REPO_ROOT)!r}) + from tests.control_plane.host_checkpoint_fixture import checkpoint_decision + decision = checkpoint_decision(json.loads(prompt.rsplit("\\n", 1)[-1])["checkpoint_context"]) + if decision.get("agent_vision"): + decision["agent_vision_json"] = json.dumps(decision.pop("agent_vision")) + output_path = pathlib.Path(args[args.index("--output-last-message") + 1]) + output_path.write_text(json.dumps(decision), encoding="utf-8") + print(json.dumps({{"type":"thread.started","thread_id":"session-fixture-0001"}}), flush=True) + raise SystemExit(0) turn_key = re.search(r'"turn_key":"([^"]+)"', prompt).group(1) marker = pathlib.Path({MARKER_NAME!r}) turn_number = 1 diff --git a/tests/control_plane/test_turn_host_decision_capture.py b/tests/control_plane/test_turn_host_decision_capture.py index 804ee99515..ebc005a24c 100644 --- a/tests/control_plane/test_turn_host_decision_capture.py +++ b/tests/control_plane/test_turn_host_decision_capture.py @@ -3,6 +3,7 @@ import importlib.util import json +import os import shlex import subprocess import sys @@ -50,8 +51,10 @@ def test_real_host_reads_original_capture_after_source_change( todos=summary["agent_todos"]["items"]), state_path=state, provider=provider) executable = f._write_fake_codex(tmp_path) - source = executable.read_text().replace("#!/usr/bin/env python3", "#!" + sys.executable) - source = source.replace("prompt = sys.stdin.read()", 'prompt = sys.stdin.read()\npathlib.Path("received.txt").write_text(prompt)') + source_path = executable.with_suffix(".py") if os.name == "nt" else executable + source = source_path.read_text() + source = source.replace("marker = pathlib.Path(", + 'pathlib.Path("received.txt").write_text(prompt)\nmarker = pathlib.Path(') if first_attempt_fails: source = source.replace("marker = pathlib.Path(", 'failed = pathlib.Path("failed-once")\n' @@ -61,7 +64,8 @@ def test_real_host_reads_original_capture_after_source_change( ' print("Selected model is at capacity. Please try a different model.", file=sys.stderr)\n' ' raise SystemExit(9)\n' 'marker = pathlib.Path(') - executable.write_text(source) + from tests.control_plane.host_process_fixture import write_python_host_fixture + executable = write_python_host_fixture(tmp_path / "captured-fake-codex", source) base = f._base_argv(registry=registry, runtime=runtime, workspace=workspace, codex_bin=executable, model=None, timeout_seconds=20, expected_marker=f._marker_value(1), turn_instance_id="original-observation") @@ -83,7 +87,8 @@ def test_real_host_reads_original_capture_after_source_change( assert capture["turn_key"] == request["turn_key"] path = Path(capture["path"]) original = path.read_bytes() - assert path.stat().st_mode & 0o077 == 0 + if os.name != "nt": # POSIX mode bits do not describe Windows ACLs. + assert path.stat().st_mode & 0o077 == 0 if legacy_pretty: # Model an observation produced by the older writer. Recovery must # preserve those original bytes rather than rewriting its layout. @@ -137,10 +142,14 @@ def test_real_host_reads_original_capture_after_source_change( path.write_bytes(original) code, recovered = f._run_cli([*replay, "--resume-turn-key", request["turn_key"], "--retry-failed-turn", "--execute"]) - assert code == 0 and recovered["status"] == "committed", json.dumps(recovered) + # The original observation stays immutable, but SOURCE_B is now the + # authoritative requirement. A retry of old work must fail freshness + # before any debit; a new observation cannot silently replace it. + assert code == 1 and recovered["validation_stage"] == "result_freshness", recovered retried = json.loads((workspace / "received.txt").read_text().split("Turn request:\n", 1)[1]) assert retried["turn_envelope"] == request["turn_envelope"] - assert path.read_bytes() == original + assert f._quota_spend_count(runtime) == 0 and path.read_bytes() == original + return code, repeated = f._run_cli([*replay, "--resume-turn-key", request["turn_key"], "--execute"]) assert code == 0, repeated assert not any(repeated["effects"].values()) diff --git a/tests/control_plane/test_turn_interruption_recovery.py b/tests/control_plane/test_turn_interruption_recovery.py index f767c4142b..d0ca3397fa 100644 --- a/tests/control_plane/test_turn_interruption_recovery.py +++ b/tests/control_plane/test_turn_interruption_recovery.py @@ -150,7 +150,7 @@ def setup_export(tmp_path, monkeypatch, provider, fifo): executable.write_text( executable.read_text() .replace("#!/usr/bin/env python3", "#!" + sys.executable) - .replace("prompt = sys.stdin.read()", "prompt = sys.stdin.read()\n" + injected) + .replace("marker = pathlib.Path(", injected + "\nmarker = pathlib.Path(") ) base = f._base_argv( registry=registry, diff --git a/tests/test_codex_operation_host.py b/tests/test_codex_operation_host.py index 5731e7191e..effa3bddfa 100644 --- a/tests/test_codex_operation_host.py +++ b/tests/test_codex_operation_host.py @@ -616,7 +616,7 @@ def test_checkpoint_direction_resumes_session_without_operation_or_child_adoptio actions.write_text('{}') def forbidden(*args, **kwargs): pytest.fail('Direction continuation must not adopt an operation or native child') - monkeypatch.setattr('loopx.control_plane.turn_driver.codex_operation_host.pending_operation_handoffs', forbidden) + monkeypatch.setattr('loopx.control_plane.turn_driver.managed_operation_host.pending_operation_handoffs', forbidden) monkeypatch.setattr('loopx.control_plane.turn_driver.codex_operation_host.native_child_observer', forbidden) request = _request(session_action='resume') request.update(phase='checkpoint_direction', checkpoint_context={'read_context_id': 'direction-read', From 4deab4d9edfa0c09b29b08342684b699000a0f7d Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 28 Oct 2026 22:22:33 +0800 Subject: [PATCH 51/80] fix(ci): restore module budget and checkpoint regression setup Move the internal coordinator transport under hosts without changing checkpoint authority or the pinned top-level module count. Resolve the full kernel-static lint findings with scoped formatting and unused-import cleanup. Admit the original Turn before adding/revalidating a linked successor, and exercise supported File/SQLite stores; legacy receipt identity compatibility remains covered separately. Validation: full kernel-static ruff passes; Linux original static gate and checkpoint regressions: 97 passed. Broader Linux run retains an unchanged-main Lark import-boundary failure and platform skips; no full-CI or merge qualification claimed. Signed-off-by: Tartar --- loopx/chat_loopx_mode.py | 2 +- .../control_plane/quota/heartbeat_receipt.py | 34 +++++-- .../todos/provider_terminal_lifecycle.py | 2 +- loopx/control_plane/turn_driver/executor.py | 5 +- .../chat_coordinator_checkpoint.py | 8 +- .../test_checkpoint_provider_fence.py | 2 +- .../test_completion_original_identity.py | 20 ++-- .../test_default_terminal_admission_gates.py | 5 - .../test_default_terminal_frontier.py | 2 - .../test_native_journal_settlement.py | 2 +- .../test_project_registry_long_paths.py | 1 - .../test_receipt_bound_lifecycle_recovery.py | 60 +++++++----- .../test_turn_host_decision_capture.py | 1 - .../test_windows_snapshot_privacy.py | 92 ++++++++++++++----- tests/test_chat_coordinator_checkpoint.py | 4 +- tests/test_file_lock_windows_atomic.py | 46 +++++++--- 16 files changed, 189 insertions(+), 97 deletions(-) rename loopx/{ => hosts}/chat_coordinator_checkpoint.py (92%) diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py index bd7e73b15d..8d7eff252c 100644 --- a/loopx/chat_loopx_mode.py +++ b/loopx/chat_loopx_mode.py @@ -935,7 +935,7 @@ def dispatch(name, arguments): raise ValueError("invalid coordinator checkpoint arguments") if service.path(operation_id).exists(): raise ValueError("a member operation cannot be reinterpreted as a coordinator checkpoint") - from .chat_coordinator_checkpoint import CoordinatorCheckpoints + from .hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints checkpoint = CoordinatorCheckpoints(self.controller.registry_path, self.controller.coordination_runtime_root, session["goal_id"], settings["agent_id"]) return checkpoint.read(operation_id) if action == "read" else checkpoint.resume(operation_id, {k: v for k, v in brief.items() if k != "purpose"}) diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index 5683131926..e81b0c0c82 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -733,7 +733,8 @@ def fail_heartbeat_receipt( def _guard_source_digest(value): - import hashlib, json + import hashlib + import json return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")).hexdigest() @@ -747,12 +748,13 @@ def guard_cadence(registry_path, runtime_root, goal_id): def bounded_guard_source(obligation, cadence): - import copy, json + import copy + import json from ..work_items.autonomous_replan_obligation import ensure_replan_novelty_policy if not isinstance(obligation, dict) or not obligation.get("obligation_id"): return None value = copy.deepcopy(obligation) - context = value.pop("replan_context", None) + value.pop("replan_context", None) # The current native history remains the novelty ledger. Receipt source # stores the complete obligation identity/triggers, not a partial ledger schema. if ensure_replan_novelty_policy(value)["obligation_id"] != value["obligation_id"]: @@ -789,25 +791,39 @@ def guard_source_for_turn(runtime_root, *, goal_id, agent_id, turn_instance_id, def store_guard_source_blob(runtime_root,goal_id,source): - import os,json,hashlib + import os + import json + import hashlib raw=json.dumps(source,ensure_ascii=False,sort_keys=True,separators=(",",":")).encode("utf-8") digest=hashlib.sha256(raw).hexdigest() path=rollout_event_log_path(Path(runtime_root),goal_id).parent/"heartbeat-guard-sources"/(digest+".json") path.parent.mkdir(parents=True,exist_ok=True) try: - with path.open("xb") as f:f.write(raw);f.flush();os.fsync(f.fileno()) + with path.open("xb") as f: + f.write(raw) + f.flush() + os.fsync(f.fileno()) except FileExistsError: - if path.read_bytes()!=raw:raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: immutable blob conflict") + if path.read_bytes() != raw: + raise HeartbeatReceiptIdentityConflictError( + "guard_source_invalid: immutable blob conflict" + ) return digest def read_guard_source_blob(runtime_root,goal_id,ref): - import re,json,hashlib + import re + import json + import hashlib if not isinstance(ref,str) or not re.fullmatch(r"[0-9a-f]{64}",ref): raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: malformed source ref") path=rollout_event_log_path(Path(runtime_root),goal_id).parent/"heartbeat-guard-sources"/(ref+".json") - try:raw=path.read_bytes() - except OSError:raise HeartbeatReceiptIdentityConflictError("guard_source_missing: immutable source blob unavailable") from None + try: + raw = path.read_bytes() + except OSError: + raise HeartbeatReceiptIdentityConflictError( + "guard_source_missing: immutable source blob unavailable" + ) from None if hashlib.sha256(raw).hexdigest()!=ref: raise HeartbeatReceiptIdentityConflictError("guard_source_invalid: stored source bytes changed") return json.loads(raw) diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index f7d27a6019..83b70ec3f3 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -39,7 +39,7 @@ from .path_resolution import resolve_todo_state_path from .provider_projection import projection_delivery_requires_ack, settle_canonical_todo_projection from .successor_derivation import build_successor_intents -from .completion_result import read_completion_result, store_completion_result, resolve_terminal_result_descriptor +from .completion_result import store_completion_result, resolve_terminal_result_descriptor _TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v3" _ARCHIVE_REQUEST_SCHEMA = "loopx_local_coordination_todo_archive_request_v0" diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 5df28d440f..37601842bf 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -57,12 +57,11 @@ from .lane_fence import single_executor_per_turn_lane from .recovery import ( preview_turn_recovery, prepare_terminal_host_recovery, reset_failed_turn_for_retry, - recover_existing_turn, admit_unfinished_host, - assess_existing_turn_recovery, + recover_existing_turn, assess_existing_turn_recovery, build_turn_recovery_audit, require_turn_recovery_continuation, ) -from .post_settlement import PostSettlement, recover_post_settlement, run_post_settlement_callback +from .post_settlement import PostSettlement, run_post_settlement_callback from ..capability_hooks import capability_result_fields, project_turn_context from ..reward_memory import reward_memory_automation_enabled from .session_recovery import ( diff --git a/loopx/chat_coordinator_checkpoint.py b/loopx/hosts/chat_coordinator_checkpoint.py similarity index 92% rename from loopx/chat_coordinator_checkpoint.py rename to loopx/hosts/chat_coordinator_checkpoint.py index 29fef449f2..e4253d1544 100644 --- a/loopx/chat_coordinator_checkpoint.py +++ b/loopx/hosts/chat_coordinator_checkpoint.py @@ -5,10 +5,10 @@ import subprocess from pathlib import Path -from .collaboration_mcp import _pinned_release_environment, _python_module_command -from .goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane -from .agent_registry import load_goal_from_registry -from .todos import list_goal_todos +from loopx.collaboration_mcp import _pinned_release_environment, _python_module_command +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane +from loopx.agent_registry import load_goal_from_registry +from loopx.todos import list_goal_todos class _CheckpointControlPlane(GoalModeMCPControlPlane): diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index a83889e757..70b403294f 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -9,7 +9,7 @@ import time import pytest -from canonical_authority_fixture import initialize_canonical_authority, advance_canonical_fixture_authority, isolate_sqlite_runtime +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.effect_runtime import EffectRuntimeConflict diff --git a/tests/control_plane/test_completion_original_identity.py b/tests/control_plane/test_completion_original_identity.py index e07ddf9d50..1ee356a816 100644 --- a/tests/control_plane/test_completion_original_identity.py +++ b/tests/control_plane/test_completion_original_identity.py @@ -9,7 +9,7 @@ completion_identity_for_original_turn, ) from loopx.control_plane.quota.settlement import SettlementIdentity -from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints from loopx.todos import add_goal_todo, list_goal_todos from tests.control_plane.test_default_checkpoint_continuation import _fixture from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID @@ -128,7 +128,7 @@ def test_terminal_upgrade_cannot_remove_committed_successor(key_kind): _resolve(identity, todo, {"no_followup": True}) -@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("intent", ["generated", "linked", "terminal"]) def test_coordinator_resumes_original_completion_intent( provider, intent, tmp_path, monkeypatch @@ -153,6 +153,13 @@ def test_coordinator_resumes_original_completion_intent( validation_command_json=json.dumps([sys.executable, str(validator)]), ) candidate.write_text("valid", encoding="utf-8") + control = CoordinatorCheckpoints( + registry, tmp_path / "runtime", GOAL_ID, AGENT_ID + ).control + guard = json.loads(control.should_run()) + assert guard.get("delivery_result_context"), json.dumps(guard, indent=2) + assert guard["delivery_result_context"]["settlement_identity"]["todo_id"] == todo_id + original_guard_identity = guard["delivery_result_context"]["settlement_identity"] if intent == "linked": successor = add_goal_todo( registry_path=registry, @@ -166,11 +173,10 @@ def test_coordinator_resumes_original_completion_intent( options = {"next_agent_todo": "Verify generated remaining work."} else: options = {"no_follow_up": True} - control = CoordinatorCheckpoints( - registry, tmp_path / "runtime", GOAL_ID, AGENT_ID - ).control - guard = json.loads(control.should_run(todo_id)) - assert guard["delivery_result_context"]["settlement_identity"]["todo_id"] == todo_id + if intent == "linked": + guard = json.loads(control.should_run(todo_id)) + assert guard.get("delivery_result_context"), json.dumps(guard, indent=2) + assert guard["delivery_result_context"]["settlement_identity"] == original_guard_identity first = json.loads( control.complete_task(todo_id, AGENT_ID, "Candidate verified.", **options) ) diff --git a/tests/control_plane/test_default_terminal_admission_gates.py b/tests/control_plane/test_default_terminal_admission_gates.py index 1a0cabe4ba..80608fd80c 100644 --- a/tests/control_plane/test_default_terminal_admission_gates.py +++ b/tests/control_plane/test_default_terminal_admission_gates.py @@ -1,18 +1,13 @@ """Native terminal admission gates; all mutations target disposable fixture state.""" from collections import Counter from copy import deepcopy -import inspect import json from pathlib import Path import subprocess import sys -from urllib.parse import quote import pytest -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(ROOT/'tests/control_plane')) from loopx.configure_goal import configure_goal from tests.control_plane.test_default_turn_recovery import ( diff --git a/tests/control_plane/test_default_terminal_frontier.py b/tests/control_plane/test_default_terminal_frontier.py index 3129ec7ebf..32c3e0a4c2 100644 --- a/tests/control_plane/test_default_terminal_frontier.py +++ b/tests/control_plane/test_default_terminal_frontier.py @@ -6,8 +6,6 @@ import pytest -ROOT = Path(__file__).resolve().parents[2] -sys.path[:0] = [str(ROOT), str(ROOT / 'tests/control_plane')] from loopx.todos import add_goal_todo from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.status import parse_active_state_todos diff --git a/tests/control_plane/test_native_journal_settlement.py b/tests/control_plane/test_native_journal_settlement.py index adf17951a3..ba2c69b9ea 100644 --- a/tests/control_plane/test_native_journal_settlement.py +++ b/tests/control_plane/test_native_journal_settlement.py @@ -11,7 +11,7 @@ from loopx.control_plane.coordination.local_authority import ( read_canonical_todos_if_promoted, ) -from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints @pytest.mark.parametrize("provider", ["file", "sqlite"]) diff --git a/tests/control_plane/test_project_registry_long_paths.py b/tests/control_plane/test_project_registry_long_paths.py index 8a47f1fb25..0c2ab1c6bb 100644 --- a/tests/control_plane/test_project_registry_long_paths.py +++ b/tests/control_plane/test_project_registry_long_paths.py @@ -1,7 +1,6 @@ """Logical registry identities retain real native long-path IO semantics.""" import json -from pathlib import Path import pytest diff --git a/tests/control_plane/test_receipt_bound_lifecycle_recovery.py b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py index 0e94c7cfa4..d2e3d8e719 100644 --- a/tests/control_plane/test_receipt_bound_lifecycle_recovery.py +++ b/tests/control_plane/test_receipt_bound_lifecycle_recovery.py @@ -25,22 +25,30 @@ def test_incomplete_lifecycle_candidate_cannot_replace_receipt_source(case): source = copy.deepcopy(SOURCE) context = {'replan_obligation': {'obligation_id': 'replan-fedcba9876543210'}, 'replan_transition_candidates': [candidate]} - if case == 'unsettled': candidate['lifecycle_settled'] = False - if case == 'non_boolean': candidate['lifecycle_settled'] = 'true' - if case == 'wrong_candidate': candidate['obligation']['obligation_id'] = 'replan-fedcba9876543210' - if case == 'no_candidate': context['replan_transition_candidates'] = [] - if case == 'wrong_source': source['obligation_id'] = 'replan-fedcba9876543210' - if case == 'no_source': source = None + if case == "unsettled": + candidate["lifecycle_settled"] = False + if case == "non_boolean": + candidate["lifecycle_settled"] = "true" + if case == "wrong_candidate": + candidate["obligation"]["obligation_id"] = "replan-fedcba9876543210" + if case == "no_candidate": + context["replan_transition_candidates"] = [] + if case == "wrong_source": + source["obligation_id"] = "replan-fedcba9876543210" + if case == "no_source": + source = None with patch.object(writer, 'build_goal_frontier_projection_context_from_status', return_value=context), \ patch.object(writer, 'autonomous_replan_obligation_from_runs', return_value=None), \ patch.object(writer, 'guarded_replan_transition_delta', return_value=None), \ patch.object(writer, 'semantic_delta_from_writeback', return_value={'outcomes': []}): - call = lambda: writer.qualify_replan_writeback(newest_first_runs=[], state_text='', - agent_id='fixture-agent', goal_id='fixture-goal', todo_fields={}, - guard_scoped=case != 'unguarded', guard_semantic_replan_obligation_id=OID, - receipt_bound_source=source) + def call(): + return writer.qualify_replan_writeback(newest_first_runs=[], state_text='', + agent_id='fixture-agent', goal_id='fixture-goal', todo_fields={}, + guard_scoped=case != 'unguarded', guard_semantic_replan_obligation_id=OID, + receipt_bound_source=source) if case == 'wrong_source': - with pytest.raises(ValueError, match='source identity differs'): call() + with pytest.raises(ValueError, match="source identity differs"): + call() return chosen, _ = call() # A candidate's settled boolean is not native lifecycle proof. These @@ -101,7 +109,8 @@ def test_native_lifecycle_writeback_and_replay(tmp_path, monkeypatch, provider): 'observed_reality': 'Native original-identity terminal reentry is now proven.', 'retained': ['Original acceptance and immutable admitted source.'], 'evidence_refs': ['native:' + closed['completion_receipt_id']]}} - vp = tmp_path / 'vision173.json'; vp.write_text(json.dumps(vision), encoding='utf-8') + vp = tmp_path / "vision173.json" + vp.write_text(json.dumps(vision), encoding="utf-8") binding = [*base, '--turn-instance-id', turn, '--replan-obligation-id', oid] refresh = ['refresh-state', *binding, '--classification', 'receipt-bound-review', '--progress-scope', 'agent_lane', '--delivery-batch-scale', 'single_surface', @@ -144,21 +153,30 @@ def test_original_source_cardinality(case): source = {**SOURCE, 'agent_id':'fixture-agent', 'trigger_count':5, 'triggers':[{'kind':'completed_advancement_without_successor','agent_id':'fixture-agent', 'todo_id':f'todo_{i}','completion_turn_key':f'original-turn-{i}'} for i in range(3)]} - if case=='wrong_turn':source['triggers'][0]['completion_turn_key']='wrong' - if case=='wrong_actor':items[0]['last_actor_agent_id']='peer' - if case=='unsettled':items[0]['no_followup']=False - if case=='closed_before_guard':items[0]['updated_at']='2026-08-13T04:50:00Z' - if case=='wrong_todo':source['triggers'][0]['todo_id']='wrong' + if case == "wrong_turn": + source["triggers"][0]["completion_turn_key"] = "wrong" + if case == "wrong_actor": + items[0]["last_actor_agent_id"] = "peer" + if case == "unsettled": + items[0]["no_followup"] = False + if case == "closed_before_guard": + items[0]["updated_at"] = "2026-08-13T04:50:00Z" + if case == "wrong_todo": + source["triggers"][0]["todo_id"] = "wrong" with patch.object(writer,'build_goal_frontier_projection_context_from_status',return_value={}), \ patch.object(writer,'autonomous_replan_obligation_from_runs',return_value=None), \ patch.object(writer,'guarded_replan_transition_delta',return_value=None), \ patch.object(writer,'select_planning_inventory_source_items',return_value=items), \ patch.object(writer,'semantic_delta_from_writeback',return_value={'outcomes':[]}): - call=lambda: writer.qualify_replan_writeback(newest_first_runs=[],state_text='',agent_id='fixture-agent', - goal_id='fixture-goal',todo_fields={},guard_scoped=True,guard_semantic_replan_obligation_id=OID, - receipt_bound_guard_recorded_at='2026-08-13T05:00:00Z',receipt_bound_source=source) + def call(): + return writer.qualify_replan_writeback(newest_first_runs=[],state_text='',agent_id='fixture-agent', + goal_id='fixture-goal',todo_fields={},guard_scoped=True,guard_semantic_replan_obligation_id=OID, + receipt_bound_guard_recorded_at='2026-08-13T05:00:00Z',receipt_bound_source=source) if case not in {'matching', 'unsettled'}: - with pytest.raises(EffectRuntimeRejected,match='lifecycle source does not match'):call() + with pytest.raises( + EffectRuntimeRejected, match="lifecycle source does not match" + ): + call() return chosen,_=call() assert chosen['obligation_id']==OID diff --git a/tests/control_plane/test_turn_host_decision_capture.py b/tests/control_plane/test_turn_host_decision_capture.py index ebc005a24c..f63bfebe80 100644 --- a/tests/control_plane/test_turn_host_decision_capture.py +++ b/tests/control_plane/test_turn_host_decision_capture.py @@ -6,7 +6,6 @@ import os import shlex import subprocess -import sys from pathlib import Path import pytest diff --git a/tests/control_plane/test_windows_snapshot_privacy.py b/tests/control_plane/test_windows_snapshot_privacy.py index 3e3e5b193d..0a811bb903 100644 --- a/tests/control_plane/test_windows_snapshot_privacy.py +++ b/tests/control_plane/test_windows_snapshot_privacy.py @@ -1,6 +1,11 @@ """Native Windows private IO behavior; all state is disposable.""" from pathlib import Path -import hashlib,json,os,subprocess,sys,tempfile +import hashlib +import json +import os +import subprocess +import sys +import tempfile import pytest from loopx.control_plane import effect_runtime as er from loopx.control_plane.runtime import private_permissions as permissions @@ -23,7 +28,12 @@ def broaden(path): assert r.returncode==0,r.stdout+r.stderr def typed(path,*,op='read',bad_digest=False): - raw=path.read_bytes();ref={'path':str(path),'byte_count':len(raw),'sha256':'0'*64 if bad_digest else hashlib.sha256(raw).hexdigest()} + raw = path.read_bytes() + ref = { + "path": str(path), + "byte_count": len(raw), + "sha256": "0" * 64 if bad_digest else hashlib.sha256(raw).hexdigest(), + } r=subprocess.run([er._node_executable(),'--no-warnings','--experimental-strip-types','--input-type=module','-e',NODE,json.dumps({'op':op,'ref':ref})],env=dict(os.environ,LOOPX_EFFECT_RUNTIME_PYTHON_BIN=sys.executable),capture_output=True,text=True,timeout=30) assert r.returncode==0,r.stdout+r.stderr return json.loads(r.stdout) @@ -52,8 +62,11 @@ def __enter__(self):return self def __exit__(self,*args):return None def settimeout(self,value):pass def sendall(self,raw): - request=json.loads(raw);sink=Path(request['response_sink']);params=Path(request['params_ref']['path']) - for p in (sink.parent,sink,params):assert_private_acl(p) + request = json.loads(raw) + sink = Path(request["response_sink"]) + params = Path(request["params_ref"]["path"]) + for p in (sink.parent, sink, params): + assert_private_acl(p) assert len(params.read_bytes())>er.MAX_REQUEST_BYTES seen.append(request) def recv(self,size):return json.dumps({'schema_version':er.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,'request_id':'producer','ok':True,'result':{}}).encode()+b'\n' @@ -62,32 +75,43 @@ def recv(self,size):return json.dumps({'schema_version':er.EFFECT_RUNTIME_RESPON assert len(seen)==1 and not Path(seen[0]['response_sink']).exists() def test_large_protected_bytes_and_digest(snapshot): - path,payload=snapshot;assert_private_acl(path.parent);assert_private_acl(path) + path, payload = snapshot + assert_private_acl(path.parent) + assert_private_acl(path) assert typed(path)=={'accepted':True,'effects':1} assert pyread(path)==payload assert typed(path,bad_digest=True)['accepted'] is False - bad=envelope(path);bad['result_ref']['sha256']='0'*64 - with pytest.raises(er.EffectRuntimeResponseAmbiguous):pyread(path,bad) + bad = envelope(path) + bad["result_ref"]["sha256"] = "0" * 64 + with pytest.raises(er.EffectRuntimeResponseAmbiguous): + pyread(path, bad) + @pytest.mark.parametrize('op',['read','sink']) @pytest.mark.parametrize('target',['file','parent']) def test_typed_rejects_broad_acl_before_effect(snapshot,op,target): - path,_=snapshot;broaden(path if target=='file' else path.parent) + path, _ = snapshot + broaden(path if target == "file" else path.parent) result=typed(path,op=op) assert result['accepted'] is False and result['effects']==0 @pytest.mark.parametrize('target',['file','parent']) def test_python_postdispatch_acl_failure_is_ambiguous(snapshot,target): - path,_=snapshot;broaden(path if target=='file' else path.parent) - with pytest.raises(er.EffectRuntimeResponseAmbiguous) as error:pyread(path) + path, _ = snapshot + broaden(path if target == "file" else path.parent) + with pytest.raises(er.EffectRuntimeResponseAmbiguous) as error: + pyread(path) assert error.value.diagnostic_code=='runtime_response_ambiguous' def test_session_writer_protects_and_keeps_original_identity(tmp_path): lineage={'goal_id':'isolated-privacy','agent_id':'fixture','todo_id':'original'} - path=sessions._session_path(tmp_path,lineage,'agent');path.parent.mkdir(parents=True) - permissions.protect_private_path(path.parent,directory=True);broaden(path.parent) + path = sessions._session_path(tmp_path, lineage, "agent") + path.parent.mkdir(parents=True) + permissions.protect_private_path(path.parent, directory=True) + broaden(path.parent) sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='original-session-id') - assert_private_acl(path.parent);assert_private_acl(path) + assert_private_acl(path.parent) + assert_private_acl(path) value=sessions.select_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent') assert value['session_id']=='original-session-id' and value['agent_id']=='fixture' assert value['todo_id']=='original' and value['session_scope']=='agent' @@ -96,32 +120,52 @@ def test_session_writer_protects_and_keeps_original_identity(tmp_path): def test_session_publication_failure_keeps_prior_session(tmp_path,monkeypatch,failure): lineage={'goal_id':'isolated-privacy','agent_id':'fixture','todo_id':'original'} sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='original-session-id') - path=sessions._session_path(tmp_path,lineage,'agent');old=path.read_bytes() + path = sessions._session_path(tmp_path, lineage, "agent") + old = path.read_bytes() + def deny(*a,**k):raise PermissionError('injected native IO refusal') if failure=='protect': - if not hasattr(sessions,'protect_private_path'):pytest.fail('Original session writer has no protection admission') + if not hasattr(sessions, "protect_private_path"): + pytest.fail("Original session writer has no protection admission") monkeypatch.setattr(sessions,'protect_private_path',deny) - else:monkeypatch.setattr(sessions.os,'replace',deny) - with pytest.raises(PermissionError):sessions._store_codex_cli_session(tmp_path,lineage=lineage,session_scope='agent',session_id='replacement') + else: + monkeypatch.setattr(sessions.os, "replace", deny) + with pytest.raises(PermissionError): + sessions._store_codex_cli_session( + tmp_path, lineage=lineage, session_scope="agent", session_id="replacement" + ) assert path.read_bytes()==old assert not [x for x in path.parent.iterdir() if x.name.startswith('.'+path.name+'.')] def test_python_rejects_junction_parent(snapshot,tmp_path): - path,_=snapshot;link=tmp_path/'loopx-effect-junction' + path, _ = snapshot + link = tmp_path / "loopx-effect-junction" cmd=['pwsh','-NoLogo','-NoProfile','-Command',"New-Item -ItemType Junction -Path $env:LX_LINK -Target $env:LX_TARGET | Out-Null"] r=subprocess.run(cmd,env=dict(os.environ,LX_LINK=str(link),LX_TARGET=str(path.parent)),capture_output=True,text=True,timeout=30) assert r.returncode==0,r.stderr try: other=link/path.name assert typed(other)['accepted'] is False - with pytest.raises(er.EffectRuntimeResponseAmbiguous):pyread(other) - finally:os.rmdir(link) + with pytest.raises(er.EffectRuntimeResponseAmbiguous): + pyread(other) + finally: + os.rmdir(link) + @pytest.mark.parametrize('helper',['missing','invalid']) def test_typed_missing_os_fact_helper_rejects(snapshot,helper): - path,_=snapshot;raw=path.read_bytes();ref={'path':str(path),'byte_count':len(raw),'sha256':hashlib.sha256(raw).hexdigest()} - env=dict(os.environ);env.pop('LOOPX_EFFECT_RUNTIME_PYTHON_BIN',None) - if helper=='invalid':env['LOOPX_EFFECT_RUNTIME_PYTHON_BIN']=str(path.parent/'absent-python.exe') + path, _ = snapshot + raw = path.read_bytes() + ref = { + "path": str(path), + "byte_count": len(raw), + "sha256": hashlib.sha256(raw).hexdigest(), + } + env = dict(os.environ) + env.pop("LOOPX_EFFECT_RUNTIME_PYTHON_BIN", None) + if helper == "invalid": + env["LOOPX_EFFECT_RUNTIME_PYTHON_BIN"] = str(path.parent / "absent-python.exe") r=subprocess.run([er._node_executable(),'--no-warnings','--experimental-strip-types','--input-type=module','-e',NODE,json.dumps({'op':'read','ref':ref})],env=env,capture_output=True,text=True,timeout=30) assert r.returncode==0,r.stderr - result=json.loads(r.stdout);assert result['accepted'] is False and result['effects']==0 + result = json.loads(r.stdout) + assert result["accepted"] is False and result["effects"] == 0 diff --git a/tests/test_chat_coordinator_checkpoint.py b/tests/test_chat_coordinator_checkpoint.py index 1495d189ee..dcc23124a8 100644 --- a/tests/test_chat_coordinator_checkpoint.py +++ b/tests/test_chat_coordinator_checkpoint.py @@ -5,7 +5,7 @@ from test_chat_loopx_mode import mode as mode, apply from test_chat_project_coordination import project as project import pytest -from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints +from loopx.hosts.chat_coordinator_checkpoint import CoordinatorCheckpoints from loopx.chat_runtime import ChatRuntimeController from loopx.chat_store import ChatSessionStore from loopx.collaboration_mcp import _pinned_release_environment, _python_module_command @@ -132,7 +132,7 @@ def resume(self, todo, decision): return {"ok": True} monkeypatch.setattr( - "loopx.chat_coordinator_checkpoint.CoordinatorCheckpoints", Checkpoints + "loopx.hosts.chat_coordinator_checkpoint.CoordinatorCheckpoints", Checkpoints ) adapter = SimpleNamespace( goal_driver=SimpleNamespace(stopped=Event()), session=SimpleNamespace() diff --git a/tests/test_file_lock_windows_atomic.py b/tests/test_file_lock_windows_atomic.py index 7f043251dd..39e5105dba 100644 --- a/tests/test_file_lock_windows_atomic.py +++ b/tests/test_file_lock_windows_atomic.py @@ -60,7 +60,8 @@ def release(): assert not worker.is_alive() def test_transient_reader_allows_atomic_holder_replace(tmp_path,monkeypatch): - path=tmp_path/'holder.json';path.write_text(json.dumps({'sequence':0})) + path = tmp_path / "holder.json" + path.write_text(json.dumps({"sequence": 0})) with transient_reader(path,monkeypatch) as attempts: locks._write_holder_sidecar(path,{'sequence':1}) assert len(attempts)>=2 @@ -69,35 +70,46 @@ def test_transient_reader_allows_atomic_holder_replace(tmp_path,monkeypatch): def test_transient_reader_preserves_real_kernel_lock_body(tmp_path,monkeypatch): target=tmp_path/'state.json' - with locks.exclusive_file_lock(target):pass - holder=locks.lock_holder_path(target);effects=[] + with locks.exclusive_file_lock(target): + pass + holder = locks.lock_holder_path(target) + effects = [] with transient_reader(holder,monkeypatch): with locks.exclusive_file_lock(target,agent_id='writer',operation='atomic-publish'): effects.append('business') assert effects==['business'] assert json.loads(holder.read_text())['released_at'] - with locks.try_exclusive_file_lock(target) as acquired:assert acquired is not None + with locks.try_exclusive_file_lock(target) as acquired: + assert acquired is not None + def test_persistent_reader_keeps_old_bytes_and_propagates_denial(tmp_path): - path=tmp_path/'holder.json';path.write_text(json.dumps({'sequence':0}));before=path.read_bytes() + path = tmp_path / "holder.json" + path.write_text(json.dumps({"sequence": 0})) + before = path.read_bytes() with reader_without_delete_share(path): start=time.monotonic() - with pytest.raises(OSError) as failure:locks._write_holder_sidecar(path,{'sequence':1}) + with pytest.raises(OSError) as failure: + locks._write_holder_sidecar(path, {"sequence": 1}) assert failure.value.winerror==5 and time.monotonic()-start<1.2 assert path.read_bytes()==before assert not list(tmp_path.glob('*.tmp')) def test_failed_holder_publication_releases_kernel_without_business(tmp_path): target=tmp_path/'state.json' - with locks.exclusive_file_lock(target):pass - holder=locks.lock_holder_path(target);effects=[] + with locks.exclusive_file_lock(target): + pass + holder = locks.lock_holder_path(target) + effects = [] with reader_without_delete_share(holder): start=time.monotonic() with pytest.raises(OSError) as failure: - with locks.exclusive_file_lock(target):effects.append('business') + with locks.exclusive_file_lock(target): + effects.append("business") assert failure.value.winerror==5 and time.monotonic()-start<2 assert effects==[] - with locks.try_exclusive_file_lock(target) as acquired:assert acquired is not None + with locks.try_exclusive_file_lock(target) as acquired: + assert acquired is not None assert not list(tmp_path.glob('*.tmp')) def test_unrelated_error_has_no_retry_or_fallback(tmp_path,monkeypatch): @@ -107,11 +119,13 @@ def fail(source,destination): raise FileNotFoundError(errno.ENOENT,'unrelated source error') monkeypatch.setattr(locks.os,'replace',fail) monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('unrelated error slept')) - with pytest.raises(FileNotFoundError):locks._write_holder_sidecar(tmp_path/'holder.json',{'sequence':1}) + with pytest.raises(FileNotFoundError): + locks._write_holder_sidecar(tmp_path / "holder.json", {"sequence": 1}) assert attempts==[1] and not list(tmp_path.glob('*.tmp')) def test_successful_holder_publication_does_not_sleep(tmp_path,monkeypatch): - path=tmp_path/'holder.json';path.write_text(json.dumps({'sequence':0})) + path = tmp_path / "holder.json" + path.write_text(json.dumps({"sequence": 0})) monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('success slept')) locks._write_holder_sidecar(path,{'sequence':1}) assert json.loads(path.read_text())=={'sequence':1} @@ -120,9 +134,13 @@ def test_posix_adapter_permission_error_remains_immediate(tmp_path,monkeypatch): attempts=[] def fail(source,destination): attempts.append(1) - error=PermissionError(errno.EACCES,'denied');error.winerror=5;raise error + error = PermissionError(errno.EACCES, "denied") + error.winerror = 5 + raise error + adapter=SimpleNamespace(**{name:getattr(os,name) for name in ('fdopen','fsync')},name='posix',replace=fail) monkeypatch.setattr(locks,'os',adapter) monkeypatch.setattr(locks.time,'sleep',lambda _:pytest.fail('POSIX denial slept')) - with pytest.raises(PermissionError):locks._write_holder_sidecar(tmp_path/'holder.json',{'sequence':1}) + with pytest.raises(PermissionError): + locks._write_holder_sidecar(tmp_path / "holder.json", {"sequence": 1}) assert attempts==[1] and not list(tmp_path.glob('*.tmp')) From 834f6ae74fd4dc9158c454bdc23f2577169ddef2 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 00:02:41 +0800 Subject: [PATCH 52/80] fix(runtime): narrow native Windows permission APIs by platform Signed-off-by: Tartar --- loopx/control_plane/runtime/private_permissions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/runtime/private_permissions.py b/loopx/control_plane/runtime/private_permissions.py index 628da79111..e7b4f57c1d 100644 --- a/loopx/control_plane/runtime/private_permissions.py +++ b/loopx/control_plane/runtime/private_permissions.py @@ -4,6 +4,7 @@ import os from pathlib import Path import stat +import sys def protect_private_path(path: Path, *, directory: bool = False) -> None: @@ -26,6 +27,8 @@ def protect_private_path(path: Path, *, directory: bool = False) -> None: def _protect_windows_path(path: Path, *, directory: bool) -> None: # Native APIs are loaded only on Windows. No subprocess, external package, # ambient inherited ACE or caller-supplied principal is trusted here. + if sys.platform != "win32": + raise OSError("Windows private IO requires a Windows host") import ctypes as ct from ctypes import wintypes as w @@ -92,7 +95,7 @@ def require_private_path(path: Path, *, directory: bool = False, descriptor: int if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): raise OSError("private IO path changed after open") info = opened - if os.name != "nt": + if sys.platform != "win32": if info.st_uid != os.getuid() or info.st_mode & 0o077: raise OSError("private IO permissions are invalid") return @@ -100,6 +103,8 @@ def require_private_path(path: Path, *, directory: bool = False, descriptor: int def _require_windows_private_path(path: Path, *, descriptor: int | None) -> None: + if sys.platform != "win32": + raise OSError("Windows private IO requires a Windows host") import ctypes as ct from ctypes import wintypes as w import msvcrt @@ -124,7 +129,7 @@ def _require_windows_private_path(path: Path, *, descriptor: int | None) -> None adv.GetAce.argtypes = [pointer, w.DWORD, out] token, sd, owner, dacl = w.HANDLE(), pointer(), pointer(), pointer() - def sid_text(sid: pointer) -> str: + def sid_text(sid: ct.c_void_p | int | None) -> str: value = w.LPWSTR() if not sid or not adv.ConvertSidToStringSidW(sid, ct.byref(value)): raise OSError("private IO SID unavailable") From ae3da78eb4ccae5d87bcbafe7aa5d9587e81fb6f Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 00:02:41 +0800 Subject: [PATCH 53/80] test(browser): assert original Turn readback and real owner recovery Signed-off-by: Tartar --- examples/personal-workspace-browser/goal-suggestions.mjs | 2 +- .../personal-workspace-browser/research-worker-readback.mjs | 3 ++- tests/control_plane/research_worker_readback_fixture.py | 6 +++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/personal-workspace-browser/goal-suggestions.mjs b/examples/personal-workspace-browser/goal-suggestions.mjs index 88fbfe2876..cae5a29244 100644 --- a/examples/personal-workspace-browser/goal-suggestions.mjs +++ b/examples/personal-workspace-browser/goal-suggestions.mjs @@ -129,7 +129,7 @@ export const goalSuggestionsScenario = { await other.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); await other.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); const vitals = (await other.getByTestId("goal-vitals").innerText()).replace(/\s+/g, " "); - assert.match(vitals, /0 等你决定|可选/, `the lane shows the item as optional: ${vitals}`); + assert.match(vitals, /另有 1 项可选/, `the lane shows the item as optional: ${vitals}`); // The work vital marks its unknown total; its help carries the partial counts. await other.getByTestId("goal-vitals").getByRole("button", { name: "工作 · 总数未知", exact: true }).focus(); await other.getByRole("tooltip").getByText(/已列出 5 项中的 0 项/).waitFor(); diff --git a/examples/personal-workspace-browser/research-worker-readback.mjs b/examples/personal-workspace-browser/research-worker-readback.mjs index 5d75523b95..f0551f4008 100644 --- a/examples/personal-workspace-browser/research-worker-readback.mjs +++ b/examples/personal-workspace-browser/research-worker-readback.mjs @@ -99,7 +99,8 @@ export const researchWorkerReadbackScenario = { await page.screenshot({path: resolve(outputDir, `research-worker-${mode}-${locale}-${mobile ? "mobile" : "desktop"}.png`), animations: "disabled"}); await region.locator("summary").click(); assert.ok((await region.innerText()).includes(authority.obligation_id)); - assert.match(await region.innerText(), /未绑定|Unbound/u); + await region.getByText(authority.turn_instance_id, {exact: true}).waitFor(); + assert.doesNotMatch(await region.innerText(), /未绑定|Unbound/u); assert.equal(await region.evaluate(element => element.scrollWidth > element.clientWidth + 1), false); await region.getByRole("button", {name: zh ? "查看任务与依赖" : "View work and dependencies", exact: true}).click(); await page.locator('[data-goal-panel="tasks"]:not([hidden])').waitFor(); diff --git a/tests/control_plane/research_worker_readback_fixture.py b/tests/control_plane/research_worker_readback_fixture.py index 4d7ae5ba7d..05ea2d622a 100644 --- a/tests/control_plane/research_worker_readback_fixture.py +++ b/tests/control_plane/research_worker_readback_fixture.py @@ -112,7 +112,10 @@ def serve(root: Path, mode: str) -> None: assert index.read_bytes() == before state = json.loads(Path(args.state_file).read_text()) original = state["replan_attempts"][0]["obligation_id"] + original_turn = state["replan_attempts"][0]["settlement_identity"]["turn_instance_id"] + assert original_turn receipt = receipt_path_for_goal({"id": GOAL}, root / "project" / "ACTIVE_GOAL_STATE.md") + assert json.loads(receipt.read_text())["worker_observations"][AGENT]["turn_instance_id"] == original_turn synced = sync_project_registry_to_global(registry_path=Path(args.registry), runtime_root_override=args.runtime_root, goal_id=GOAL) assert synced["ok"] is True @@ -128,7 +131,8 @@ class ObservableChatServer(ChatHTTPServer): def serve_forever(self, poll_interval=0.5): print(json.dumps({"port": self.server_address[1], "registry": args.registry, "runtime": args.runtime_root, "goal_id": GOAL, "agent_id": AGENT, - "obligation_id": original, "receipt": str(receipt), "dispatches": str(dispatches), + "obligation_id": original, "turn_instance_id": original_turn, + "receipt": str(receipt), "dispatches": str(dispatches), "dashboard_registry": str(dashboard_registry), "history": str(index)}), file=output, flush=True) From 16d23205d22a6af5e79d6f1d6270b2b8d8226d44 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 00:10:16 +0800 Subject: [PATCH 54/80] test(cli): qualify original Turn admission cost on matched native probes Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 45 ++++++++ ...cli-output-base-head-differential-smoke.py | 5 +- .../cli-output-budget-regression-smoke.py | 4 +- .../control_plane/cli-output-probe-runner.py | 15 +++ loopx/control_plane/testing/canary_harness.py | 4 +- .../testing/cli_output_differential.py | 43 +++++++ .../testing/cli_output_semantics.py | 78 ++++++++++++- tests/control_plane/cli_output_fixture.py | 4 +- tests/control_plane/test_cli_output_budget.py | 4 +- .../test_cli_output_differential.py | 105 ++++++++++++++++++ 10 files changed, 298 insertions(+), 9 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index cb5cb32227..c9e1ad7267 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1401,6 +1401,51 @@ fixture 的 selected Todo 不在 runnable 集合,human gate 没有 selected To 要求、有意义备选项及末尾约束验证选择与后续工作;计入全部批读/恢复成本。新必读契约 合格前,当前完整上下文 preflight 保持生效,不能靠删候选或首屏变小宣称收益。 +#### Original-Turn admission qualification + +Keep the first admitted result basis and its replay/settlement identity in the +caller output. A matched native Windows measurement of main `f5e9be367` and +candidate `9394b2395` used the unchanged File fixture, Python 3.11 and Node 22.22.3: +These measurements belong to those source revisions; they do not qualify a +different integrated head. Retain the target branch ceilings until a matched +current-source probe supports recalibration. + +上述结果只属于指定版本,不能直接作为另一集成 head 的资格;在当前源码的匹配探针 +支持重新校准前,保留目标分支既有上限。 + +| quota JSON scenario | main | candidate | Previous ceiling | Candidate proposal ceiling | +| --- | ---: | ---: | ---: | ---: | +| One Todo | 23,291 | 28,001 | 25,000 | 30,000 | +| 36 Todos / 12 runs | 35,862 | 43,202 | 42,000 | 46,000 | +| 18 Todos / three Agents | 25,852 | 27,984 | 25,500 | 29,500 | + +The failed Windows CI host emitted 28,334 characters for the one-Todo candidate; +its main multi-Agent observation was already 25,975. Preserve both failures. +The added information is the original read basis, causal admission/replan +receipt, original-work progress readback and memory-call observation; the replan also carries the current planning +choice. Do not remove these inputs to meet an older presentation ceiling. + +The differential permits only measured carrier growth on a matching first +admission transition. It still rejects repeated growth, mismatched original +identity, other surfaces, action changes and oversized carriers. Selector views +must compare the same explicitly admitted Turn, rather than an unbound planning +choice on one revision with a restored original Turn on the other. Status's +original-work progress remains a readback, never Goal acceptance. + +The unchanged 96-row candidate completed its assertions in a diagnostic run; +CLI calls alone took 250 seconds, exceeding the former 240-second child bound. +A loaded Windows rerun exceeded 300 seconds; retain that timeout as a failure. +The qualification child bound is 420 seconds, with 960 seconds for two children +and checkout/cleanup. These are bounded test-batch limits, not a production +latency qualification or a change to execution quota, product timeouts or the +8,192-byte Turn Envelope diagnostic. Per-Todo growth, fixed replan growth, +Markdown/line ceilings and complete action/semantic checks stay independent. + +原 Turn 验收保留首次读取依据、原身份、重规划来源和记忆调用状态。相同 Windows +负载的新增成本及原失败如表所示;仅按实际内容核算一次迁移,不允许用新标记放宽 +重复增长或动作变化。展示 selector 必须比较同一已准入 Turn;测试批次时限不代表 +产品性能或 Goal 达成,也不改变执行配额、产品超时和生产 Envelope 诊断。 + Complete registry routes exposed an existing brief heartbeat regression on the unchanged 128-character-root fixture. Commit `fb20c153ba2c2a7376efe68091a76ca470effbde` added those routes: its immediate parent diff --git a/examples/control_plane/cli-output-base-head-differential-smoke.py b/examples/control_plane/cli-output-base-head-differential-smoke.py index d303940087..b7328ccd7e 100644 --- a/examples/control_plane/cli-output-base-head-differential-smoke.py +++ b/examples/control_plane/cli-output-base-head-differential-smoke.py @@ -44,7 +44,10 @@ def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, - timeout=240, + # The unchanged 96-row Windows candidate completes all semantic checks + # with 250 seconds inside CLI calls; a loaded Windows run exceeded 300s. + # Bound this qualification batch; do not change any product timeout. + timeout=420, ) if completed.returncode != 0: raise AssertionError( diff --git a/examples/control_plane/cli-output-budget-regression-smoke.py b/examples/control_plane/cli-output-budget-regression-smoke.py index 7c4b41052c..da2e97e0be 100644 --- a/examples/control_plane/cli-output-budget-regression-smoke.py +++ b/examples/control_plane/cli-output-budget-regression-smoke.py @@ -46,9 +46,9 @@ def main() -> int: errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, - # The child runs two separately bounded 240-second probes, plus + # The child runs two separately bounded 420-second probes, plus # checkout/cleanup. Do not cut off a valid second native Windows probe. - timeout=600, + timeout=960, ) if completed.returncode != 0: raise AssertionError( diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index a296adde8f..4d6f8dc73d 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -66,6 +66,7 @@ def _receipt_row( "scenario": scenario, "format": output_format, "qualification_policy": qualification_policy, + "native_admission": semantics.native_admission_observation(payload), "output_contract_version": output_contract_version, "chars": measurement["chars"], "utf8_bytes": measurement["utf8_bytes"], @@ -273,6 +274,18 @@ def _variant_rows( probe.SCENARIOS[0], ) import_fixture_authority(registry_path, probe.GOAL_ID) + # These rows compare display selectors, not first admission with later + # original-Turn recovery. Keep that causal phase identical on both arms; + # _default_rows still measures the actual first automatic admission. + turn = ["--turn-instance-id", "cli-output-selector-turn", "--todo-id", "todo_fixture_000"] + guard = probe._surface_commands(project=project, runtime=runtime, + registry_path=registry_path, state_file=state_file, output_format="json")["quota_should_run"] + code, text = probe._invoke_cli([*guard, *turn]) + admitted = json.loads(text) + if (code != 0 or admitted.get("ok") is not True + or (admitted.get("heartbeat_receipt") or {}).get("status") != "committed" + or admitted["heartbeat_receipt"].get("turn_instance_id") != "cli-output-selector-turn"): + raise AssertionError("selector comparison requires its original admitted Turn") rows: list[dict] = [] for output_format in ("json", "markdown"): commands = probe._mode_variant_commands( @@ -290,6 +303,8 @@ def _variant_rows( continue if output_format not in variant.output_formats: continue + if any(command[index:index + 2] == ["quota", "should-run"] for index in range(len(command) - 1)): + command = [*command, *turn] exit_code, text = probe._invoke_cli(command) if exit_code != 0: raise AssertionError(f"{variant_id}/{output_format} failed") diff --git a/loopx/control_plane/testing/canary_harness.py b/loopx/control_plane/testing/canary_harness.py index 4c8927c8aa..c5658bd2e6 100644 --- a/loopx/control_plane/testing/canary_harness.py +++ b/loopx/control_plane/testing/canary_harness.py @@ -16,9 +16,9 @@ @contextmanager -def temporary_canary_directory(*, prefix: str): +def temporary_canary_directory(*, prefix: str, dir: str | Path | None = None): """Keep fixture cleanup valid for canonical Windows authority paths.""" - with tempfile.TemporaryDirectory(prefix=prefix) as name: + with tempfile.TemporaryDirectory(prefix=prefix, dir=dir) as name: owned = Path(name).resolve() try: yield name diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index a2aea89c1a..4b1d49996c 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -939,6 +939,46 @@ def _turn_projection_migration( return {}, [] +def _native_admission_migration( + base: dict[str, Any], candidate: dict[str, Any], +) -> tuple[dict[str, int], list[str]]: + """One-time measured cost of original-Turn admission, not general headroom.""" + before, after = base.get("native_admission"), candidate.get("native_admission") + if (base.get("surface_id") not in {"quota_should_run", "status"} + or candidate.get("row_id") != base.get("row_id") + or base.get("format") != "json" or candidate.get("format") != "json" + or not isinstance(before, dict) or before.get("kind") is not None + or not isinstance(after, dict)): + return {}, [] + carriers = { + "delivery_result": (("heartbeat_receipt", "delivery_result_context", "reward_memory_recall", + "first_delivery_progress"), 6_400), + "replan": (("heartbeat_receipt", "reward_memory_recall", "planning_horizon", "action_portfolio"), 9_500), + "unbound": (("heartbeat_receipt", "reward_memory_recall"), 2_600), + "progress": (("first_delivery_progress",), 900), + } + if after.get("kind") not in carriers: + return {}, [] + if (after["kind"] == "progress") != (base.get("surface_id") == "status"): + return {}, [] + fields, ceiling = carriers[after["kind"]] + allowance = {} + for metric in ("chars", "utf8_bytes"): + try: + pairs = [(before["costs"][field][metric], after["costs"][field][metric]) for field in fields] + except (KeyError, TypeError): + return {}, [] + if any(type(value) is not int or value < 0 for pair in pairs for value in pair): + return {}, [] + growth = sum(max(new - old, 0) for old, new in pairs) + if growth > ceiling: + return {}, [] + # Field names/separators and receipt clocks, not another context copy. + allowance[metric] = growth + 64 + allowance.update(lines=0, compact_payload_chars=allowance["chars"]) + return allowance, ["original-Turn admission carriers migrated; measured bounded one-time cost"] + + def _compare_row_growth( base: dict[str, Any], candidate: dict[str, Any], *, migration: _SchemaMigrationState, @@ -963,6 +1003,8 @@ def _compare_row_growth( review_signals.extend(continuation_signals) context_allowance, context_signals = _turn_projection_migration(base, candidate) review_signals.extend(context_signals) + admission_allowance, admission_signals = _native_admission_migration(base, candidate) + review_signals.extend(admission_signals) deltas: dict[str, int | None] = {} allowances: dict[str, int | None] = {} for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): @@ -1006,6 +1048,7 @@ def _compare_row_growth( authoring_allowance.get(metric, 0), continuation_allowance.get(metric, 0), context_allowance.get(metric, 0), + admission_allowance.get(metric, 0), ) # Thin installed prompts contain bilingual lifecycle instructions. A # small character-level clarification can cost three bytes per CJK diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index 536fd42ca0..a5fe197adc 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -5,10 +5,86 @@ import re import shlex from typing import Any - from loopx.control_plane.content_digest import ENVELOPED_SHA256_PATTERN +def native_admission_observation(payload: Any) -> dict[str, Any] | None: + """Measure reviewed admission carriers, without retaining their contents.""" + queue = payload.get("attention_queue") if isinstance(payload, dict) else None + if isinstance(queue, dict) and isinstance(queue.get("items"), list): + progress = [] + valid = True + for item in queue["items"]: + row = item.get("first_delivery_progress") if isinstance(item, dict) else None + if row is None: + continue + identity = row.get("settlement_identity") if isinstance(row, dict) else None + if (not isinstance(row, dict) or row.get("schema_version") != "first_delivery_progress_v0" + or row.get("goal_completion_certified") is not False + or not isinstance(identity, dict) + or identity.get("schema_version") != "quota_settlement_identity_v0" + or identity.get("goal_id") != item.get("goal_id") + or not all(isinstance(identity.get(key), str) and identity[key] + for key in ("agent_id", "todo_id", "turn_instance_id"))): + valid = False + progress.append(row) + text = "" if not progress else json.dumps(progress, ensure_ascii=False) + return {"kind": "progress" if progress and valid else None, + "costs": {"first_delivery_progress": { + "chars": len(text), "utf8_bytes": len(text.encode("utf-8"))}}} + if not isinstance(payload, dict) or payload.get("mode") != "should-run": + return None + fields = ("heartbeat_receipt", "delivery_result_context", "reward_memory_recall", + "planning_horizon", "action_portfolio", "first_delivery_progress") + costs = {} + for field in fields: + text = json.dumps(payload[field], ensure_ascii=False) if field in payload else "" + costs[field] = {"chars": len(text), "utf8_bytes": len(text.encode("utf-8"))} + receipt = payload.get("heartbeat_receipt") or {} + kind = None + if (isinstance(receipt, dict) + and receipt.get("schema_version") == "heartbeat_quota_receipt_v0" + and receipt.get("status") in {"committed", "replayed"} + and isinstance(receipt.get("turn_instance_id"), str) + and receipt["turn_instance_id"]): + context = payload.get("delivery_result_context") or {} + identity = receipt.get("settlement_identity") or {} + selected = payload.get("selected_todo") or {} + agent = payload.get("agent_identity") or {} + if (receipt.get("result_freshness") == "turn_result_freshness_required_v1" + and receipt.get("closeout_required") is True + and isinstance(identity, dict) and isinstance(context, dict) + and isinstance(selected, dict) and isinstance(agent, dict) + and identity.get("schema_version") == "quota_settlement_identity_v0" + and identity.get("goal_id") == payload.get("goal_id") + and identity.get("turn_instance_id") == receipt["turn_instance_id"] + and identity.get("todo_id") == selected.get("todo_id") + and identity.get("agent_id") == agent.get("agent_id") + and context.get("ok") is True and isinstance(context.get("read_context_id"), str) + and context["read_context_id"] + and isinstance(context.get("basis"), dict) + and context.get("settlement_identity") == identity): + progress = payload.get("first_delivery_progress") + if progress is None or (isinstance(progress, dict) + and progress.get("schema_version") == "first_delivery_progress_v0" + and progress.get("goal_completion_certified") is False + and progress.get("settlement_identity") == identity): + kind = "delivery_result" + elif receipt.get("settlement_binding_owed") is True: + source = receipt.get("semantic_replan_source") or {} + obligation = payload.get("autonomous_replan_obligation") or {} + if (isinstance(source, dict) + and source.get("schema_version") == "semantic_replan_source_v1" + and isinstance(obligation, dict) + and receipt.get("semantic_replan_obligation_id") == obligation.get("obligation_id") + and obligation.get("obligation_id")): + kind = "replan" + elif not source and not receipt.get("semantic_replan_obligation_id"): + kind = "unbound" + return {"kind": kind, "costs": costs} + + + def authoring_input_observations(text: str) -> dict[str, bool]: """Recognize complete reviewed guidance for one-time output qualification. diff --git a/tests/control_plane/cli_output_fixture.py b/tests/control_plane/cli_output_fixture.py index 13cc73d505..47425a5a67 100644 --- a/tests/control_plane/cli_output_fixture.py +++ b/tests/control_plane/cli_output_fixture.py @@ -9,6 +9,8 @@ import shutil import tempfile +from loopx.control_plane.runtime.file_paths import windows_extended_path + def budget_fixture_path(root: Path) -> Path: suffix = hashlib.sha256(str(root).encode("utf-8")).hexdigest()[:12] @@ -27,4 +29,4 @@ def stable_budget_fixture_root(root: Path): try: yield fixture finally: - shutil.rmtree(fixture) + shutil.rmtree(windows_extended_path(fixture)) diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 671e2c6018..4537e2f9c2 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -15,7 +15,7 @@ from loopx.cli import main as cli_main from loopx.control_plane.scheduler.execution_context import SchedulerRuntimeProfile -from loopx.control_plane.testing.canary_harness import import_fixture_authority +from loopx.control_plane.testing.canary_harness import import_fixture_authority, temporary_canary_directory from loopx.control_plane.testing.cli_output_budget import ( CLI_OUTPUT_BUDGET_BY_ID, CLI_OUTPUT_BUDGET_SPECS, @@ -1824,7 +1824,7 @@ def test_brief_budget_retains_full_commands_on_real_long_paths() -> None: # tempfile contributes an eight-character random suffix. Hold input size # constant even when the selected parent is a drive or share root. prefix += "p" * (128 - len(str(parent / prefix)) - 8) - with tempfile.TemporaryDirectory(prefix=prefix, dir=parent) as directory: + with temporary_canary_directory(prefix=prefix, dir=parent) as directory: root = Path(directory).resolve() assert len(str(root)) == 128 _assert_mode_variant_budgets(root, only="heartbeat_prompt_brief") diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index c0690526da..b927b1663d 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -2045,3 +2045,108 @@ def test_objective_replan_cost_is_complete_once_and_carrier_scoped(): assert _compare_row(base, {**head, "chars": 1769})["failures"] assert not observe(prior + " " + " ".join(reversed(clauses)))["objective_replan_after"] assert not observe(json.dumps({"prior": prior, "clauses": clauses}))["objective_replan_after"] + +def _admission_packet(): + identity = {"schema_version": "quota_settlement_identity_v0", "goal_id": "fixture-goal", + "agent_id": "fixture-agent", "todo_id": "todo_fixture", "turn_instance_id": "work:original"} + return {"mode": "should-run", "goal_id": "fixture-goal", + "selected_todo": {"todo_id": "todo_fixture"}, "agent_identity": {"agent_id": "fixture-agent"}, + "heartbeat_receipt": {"schema_version": "heartbeat_quota_receipt_v0", "status": "committed", + "turn_instance_id": "work:original", "result_freshness": "turn_result_freshness_required_v1", + "closeout_required": True, "settlement_identity": identity}, + "first_delivery_progress": {"schema_version": "first_delivery_progress_v0", + "goal_completion_certified": False, "settlement_identity": dict(identity)}, + "delivery_result_context": {"ok": True, "basis": {"todo": {"todo_id": "todo_fixture"}}, + "read_context_id": "original-read", "settlement_identity": dict(identity)}} + + +@pytest.mark.parametrize("status", ["committed", "replayed"]) +def test_native_admission_cost_requires_matching_committed_original_identity(status): + from loopx.control_plane.testing.cli_output_semantics import native_admission_observation + packet = _admission_packet() + packet["heartbeat_receipt"]["status"] = status + observed = native_admission_observation(packet) + assert observed["kind"] == "delivery_result" + assert observed["costs"]["delivery_result_context"]["chars"] == len(json.dumps(packet["delivery_result_context"])) + assert "original-read" not in json.dumps(observed) + for path, value in [(("heartbeat_receipt", "status"), "failed"), + (("heartbeat_receipt", "turn_instance_id"), "work:other"), + (("goal_id",), "other-goal"), (("agent_identity", "agent_id"), "other-agent"), + (("selected_todo", "todo_id"), "todo_other"), + (("delivery_result_context", "read_context_id"), ""), + (("first_delivery_progress", "goal_completion_certified"), True), + (("first_delivery_progress", "settlement_identity"), {}), + (("delivery_result_context", "settlement_identity"), {})]: + damaged = copy.deepcopy(packet) + target = damaged + for part in path[:-1]: + target = target[part] + target[path[-1]] = value + assert native_admission_observation(damaged)["kind"] is None + + +@pytest.mark.parametrize("kind", ["delivery_result", "replan", "unbound"]) +def test_native_admission_growth_is_measured_bounded_once_and_quota_json_only(kind): + from loopx.control_plane.testing.cli_output_semantics import native_admission_observation + packet = _admission_packet() + if kind != "delivery_result": + packet.pop("delivery_result_context") + packet.pop("first_delivery_progress") + receipt = packet["heartbeat_receipt"] + for field in ("result_freshness", "closeout_required", "settlement_identity"): + receipt.pop(field) + receipt["settlement_binding_owed"] = True + if kind == "replan": + receipt.update(semantic_replan_obligation_id="replan-original", + semantic_replan_source={"schema_version": "semantic_replan_source_v1"}) + packet["autonomous_replan_obligation"] = {"obligation_id": "replan-original"} + packet["reward_memory_recall"] = { + "status": "disabled", "automatic_recall": False, "grants_new_action_authority": False, + "quota_spend_performed": False, "external_writes_performed": False, + "context": None, "provider_call_count": 0, + } + empty = {"mode": "should-run"} + before = native_admission_observation(empty) + after = native_admission_observation(packet) + assert after["kind"] == kind + base = _row(row_id="surface/quota_should_run/small/json", surface_id="quota_should_run", native_admission=before) + candidate = {**base, "native_admission": after} + growth = sum(value["chars"] for value in after["costs"].values()) + for metric in ("chars", "utf8_bytes", "compact_payload_chars"): + candidate[metric] = base[metric] + growth + assert compare_cli_output_receipts(_receipt(base), _receipt(candidate))["ok"] + for mutated_base, mutated_candidate in [ + (candidate, {**candidate, "chars": candidate["chars"] + growth}), + ({**base, "surface_id": "status"}, {**candidate, "surface_id": "status"}), + ({**base, "format": "markdown"}, {**candidate, "format": "markdown"}), + (base, {**candidate, "chars": candidate["chars"] + 10_000}), + (base, {**candidate, "action_signature_sha256": "changed-action"}), + ]: + assert not compare_cli_output_receipts(_receipt(mutated_base), _receipt(mutated_candidate))["ok"] + oversized = copy.deepcopy(candidate) + oversized["native_admission"]["costs"]["heartbeat_receipt"]["chars"] = 100_000 + assert not compare_cli_output_receipts(_receipt(base), _receipt(oversized))["ok"] + +def test_first_delivery_status_cost_is_readback_only_and_one_time(): + from loopx.control_plane.testing.cli_output_semantics import native_admission_observation + identity = _admission_packet()["heartbeat_receipt"]["settlement_identity"] + progress = {"schema_version": "first_delivery_progress_v0", "stage": "result_review_pending", + "settlement_identity": identity, "goal_completion_certified": False, + "result_committed": False, "quota_spent": False} + empty = {"attention_queue": {"items": [{"goal_id": "fixture-goal"}]}} + packet = copy.deepcopy(empty) + packet["attention_queue"]["items"][0]["first_delivery_progress"] = progress + before, after = native_admission_observation(empty), native_admission_observation(packet) + assert after["kind"] == "progress" + base = _row(native_admission=before) + grown = {**base, "native_admission": after} + for metric in ("chars", "utf8_bytes", "compact_payload_chars"): + grown[metric] = base[metric] + after["costs"]["first_delivery_progress"]["chars"] + assert compare_cli_output_receipts(_receipt(base), _receipt(grown))["ok"] + for change in [{"goal_completion_certified": True}, {"settlement_identity": {}}]: + damaged = copy.deepcopy(packet) + damaged["attention_queue"]["items"][0]["first_delivery_progress"].update(change) + assert native_admission_observation(damaged)["kind"] is None + assert not compare_cli_output_receipts( + _receipt(grown), _receipt({**grown, "chars": grown["chars"] + 1_000}), + )["ok"] From e4b9b54643f8ce67c38ce0f3412cea8bb8663c67 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 02:59:15 +0800 Subject: [PATCH 55/80] test(freshness): qualify batch IO and retain recovery fault boundaries Signed-off-by: Tartar --- .../checkpoint-recovery-retained-desktop.png | Bin 0 -> 84148 bytes .../turn_driver/first_delivery.ts | 118 ++++++++++++++ .../test_checkpoint_history_isolation.py | 70 ++++++++ .../test_default_turn_recovery.py | 2 +- .../first_delivery_observation.test.ts | 151 ++++++++++++++++++ .../first_delivery_observation_probe.mjs | 16 ++ 6 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png create mode 100644 tests/control_plane_ts/first_delivery_observation.test.ts create mode 100644 tests/control_plane_ts/first_delivery_observation_probe.mjs diff --git a/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png b/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..a3510e2730f09c03bd7fc625e59ed0505b0423cc GIT binary patch literal 84148 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

dW+f-P_Iw#eJfTVH2pxSqPtNrAV0Yfuo0r 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 literal 0 HcmV?d00001 diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts index 71a27c1613..4df9c6efb6 100644 --- a/loopx/control_plane/turn_driver/first_delivery.ts +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -1,7 +1,125 @@ /** Receipt-backed delivery observation; never settlement or direction authority. */ import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentityFromPlan, SETTLEMENT_IDENTITY_SCHEMA_VERSION, SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION} from "../effect_program.ts"; +import {createHash} from "node:crypto"; +import {readFileSync, readdirSync} from "node:fs"; +import {basename, join} from "node:path"; import {jsonObject, requireJsonObject} from "../runtime_decode.ts"; import {originalTurnRequestsNoFollowup} from "../todos/completion_state.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {inspectCheckpointReadHistoryFromRows, readCheckpointHistoryRows} from "../goals/checkpoint_commit.ts"; +import {checkpointContinuationRequired} from "../quota/heartbeat_receipt_identity.ts"; +import {parseQuotaAccountingOwner, withQuotaAccountingOwner} from "../quota/source_admission.ts"; +import {QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlementForAdmittedOwnerFromSnapshot, + readQuotaSettlementSnapshot, validateQuotaSettlementScope} from "../quota/settlement_readback.ts"; + +function receiptName(identity: JsonObject, work = false): string { + return createHash("sha256").update(String(identity.effect_id) + (work ? ":delivery_result" : "")).digest("hex") + ".json"; +} + +/** Shared snapshots, original identities and disposable read proofs. Observation + * cannot repair receipts, spend quota, rearm a Host or select subsequent work. */ +export async function observeFirstDelivery(value: unknown): Promise { + const request = requireJsonObject(value, "delivery observation"); + const {runtimeRoot: root, goalId: goal} = validateQuotaSettlementScope(request.runtime_root, request.goal_id); + const owner = parseQuotaAccountingOwner({runtimeRoot: root, goalId: goal, + goalRefValue: request.goal_ref, sourceAdmissionValue: request.source_admission}); + return withQuotaAccountingOwner(owner, async () => { + const identities = new Map(); + const receipts = new Map(); + let errors: JsonObject[] = []; + const directory = join(root, "goals", goal, "checkpoint-contexts"); + let names: string[] = []; + try { names = readdirSync(directory).filter(name => name.endsWith(".json")); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (request.receipt_names !== undefined) { + if (!Array.isArray(request.receipt_names) || request.receipt_names.some(name => + typeof name !== "string" || basename(name) !== name || !name.endsWith(".json"))) throw new TypeError("invalid receipt scan"); + names = request.receipt_names as string[]; + } + for (const name of names) { + let receipt: JsonObject; + try { receipt = requireJsonObject(JSON.parse(readFileSync(join(directory, name), "utf8")), "checkpoint receipt"); } + catch { errors.push({scope: "goal", code: "checkpoint_receipt_unavailable", receipt: name.slice(0, -5)}); continue; } + receipts.set(name, receipt); + if (!["first_delivery", "delivery_result"].includes(String(receipt.purpose))) continue; + const binding = jsonObject(receipt.identity); + if (typeof binding?.agent_id === "string" && binding.agent_id && request.agent_id != null && binding.agent_id !== request.agent_id) continue; + try { + if (!binding) throw new TypeError("missing identity"); + const decoded = settlementIdentityFromPlan({settlement_plan: {identity: binding}}); + if (decoded.failure !== null || ![SETTLEMENT_IDENTITY_SCHEMA_VERSION, SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION] + .includes(String(binding.schema_version))) throw new TypeError("invalid identity"); + const identity = binding; // Retain original facts; do not normalize historical metadata away. + if (receipt.schema_version !== "checkpoint_read_context_v2" || identity.goal_id !== goal || + receiptName(identity, receipt.purpose === "delivery_result") !== name) throw new TypeError("invalid binding"); + identities.set(String(identity.effect_id), identity); + } catch { errors.push({scope: "goal", code: "checkpoint_receipt_invalid", receipt: name.slice(0, -5)}); } + } + // The ordinary settlement parser and strict checkpoint parser intentionally + // differ on incomplete tails. Capture each once; never mix changed indexes. + let rows: JsonObject[] | null = null; + try { rows = readCheckpointHistoryRows(root, goal); } + catch { errors.push({scope: "goal", code: "checkpoint_history_unavailable"}); } + const verifiedDirections = new Set(); + if (rows !== null) { + let verified: JsonObject[] = []; + try { + const recovered = inspectCheckpointReadHistoryFromRows({runtime_root: root, goal_id: goal, agent_id: request.agent_id, + receipt_names: errors.filter(error => error.receipt != null).map(error => String(error.receipt) + ".json")}, rows); + verified = recovered.verified as JsonObject[]; + } catch { errors.push({scope: "goal", code: "checkpoint_history_unavailable"}); } + for (const item of verified) { + errors = errors.filter(error => String(error.receipt) + ".json" !== item.receipt_name); + const identity = requireJsonObject(item.identity, "verified checkpoint identity"); + if (request.agent_id == null || identity.agent_id === request.agent_id) { + identities.set(String(identity.effect_id), identity); + if (item.purpose === "first_delivery") verifiedDirections.add(String(identity.effect_id)); + } + } + } + let snapshot: Awaited> | null = null; + if (identities.size) { + try { snapshot = await readQuotaSettlementSnapshot(root, goal); } + catch { errors.push({scope: "goal", code: "checkpoint_history_unavailable"}); } + if (request.todos == null) errors.push({scope: "goal", code: "checkpoint_authority_unavailable"}); + } + const changedIndex = rows !== null && snapshot !== null && + canonicalAuthoritySha256(rows) !== canonicalAuthoritySha256(snapshot.runs); + const todos = new Map(); + for (const value of Array.isArray(request.todos) ? request.todos : []) { + const todo = requireJsonObject(value, "observed Todo"); + if (!todos.has(todo.todo_id)) todos.set(todo.todo_id, todo); + } + for (const identity of [...identities.values()].reverse()) { + let progress: JsonObject; + try { + if (snapshot === null || request.todos == null) throw new Error("observation source unavailable"); + const readback = readQuotaSettlementForAdmittedOwnerFromSnapshot({schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, + runtime_root: root, goal_id: goal, agent_id: identity.agent_id, todo_id: identity.todo_id || null, + turn_instance_id: identity.turn_instance_id, replan_obligation_id: identity.replan_obligation_id ?? null, + infer_turn_instance_id: false, allow_unbound_binding: false}, owner, snapshot); + if (readback.found !== true || jsonObject(jsonObject(readback.identity)?.result)?.value == null) throw new Error("identity unavailable"); + const continuation = checkpointContinuationRequired(jsonObject(readback.heartbeat_receipt)); + const direction = receipts.get(receiptName(identity)); + const work = receipts.get(receiptName(identity, true)); + if (!verifiedDirections.has(String(identity.effect_id)) && !continuation && + direction?.purpose !== "first_delivery" && !(work?.purpose === "delivery_result" && work.result_only !== true)) continue; + progress = evaluateFirstDelivery({phase: "project", identity, completion_todo: todos.get(identity.todo_id) ?? {}, + checkpoint_continuation: continuation, completion_event: readback.completion_event, + writeback_run: readback.writeback_run, direction_receipt: direction, + unknown: changedIndex || errors.some(error => [receiptName(identity), receiptName(identity, true)].includes(String(error.receipt) + ".json")), + quota_spent: readback.spend_run != null, + settlement_complete: jsonObject(jsonObject(readback.settlement)?.result)?.failure === null, + terminal_settlement_complete: jsonObject(jsonObject(readback.terminal_settlement)?.result)?.failure === null}); + } catch { progress = evaluateFirstDelivery({phase: "project", unknown: true}); } + if (progress.stage !== "settled") return {...progress, settlement_identity: identity, + ...(errors.length ? {observation_errors: errors} : {})}; + } + return errors.length ? {...evaluateFirstDelivery({phase: "project", observation_unavailable: true}), + goal_id: goal, agent_id: request.agent_id ?? null, observation_errors: errors} : null; + }); +} export function evaluateFirstDelivery(value: unknown): JsonObject { const request = requireJsonObject(value, "first delivery"); diff --git a/tests/control_plane/test_checkpoint_history_isolation.py b/tests/control_plane/test_checkpoint_history_isolation.py index 6571aac1cd..f18dd510fa 100644 --- a/tests/control_plane/test_checkpoint_history_isolation.py +++ b/tests/control_plane/test_checkpoint_history_isolation.py @@ -17,6 +17,76 @@ ) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_growing_settled_backend_history_uses_one_observation_batch(provider, tmp_path, monkeypatch): + import tempfile + from loopx.control_plane.goals import checkpoint_context_io as io + + # A distinct real Effect process inherits this test-only IO probe before + # any production handler is imported. It never touches an active Goal. + transport = tmp_path / "observation-transport" + transport.mkdir() + for key in ("TMPDIR", "TEMP", "TMP"): + monkeypatch.setenv(key, str(transport)) + monkeypatch.setattr(tempfile, "tempdir", str(transport)) + probe = Path(__file__).resolve().parents[1] / "control_plane_ts/first_delivery_observation_probe.mjs" + sink = tmp_path / "observed-reads.txt" + monkeypatch.setenv("NODE_OPTIONS", "--import=" + probe.as_uri()) + monkeypatch.setenv("LOOPX_OBSERVATION_TEST_READS", str(sink)) + monkeypatch.setenv("LOOPX_OBSERVATION_TEST_GOAL", GOAL_ID) + registry, _, first_todo = _fixture(provider, tmp_path, monkeypatch) + for count in range(1, 6): + peer = AGENT_ID if count == 1 else f"history-peer-{count}" + if count == 1: + control = _control(registry) + todo_id = first_todo + else: + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["coordination"]["registered_agents"].append(peer) + registry.write_text(json.dumps(registration), encoding="utf-8") + todo_id = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", claimed_by=peer, + text="Verify an independent retained candidate.", task_class="advancement_task", + continuation_policy="same_agent_non_delivery")["todo_id"] + rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", peer, + "--vision-summary", "Verify the independent route.", "--vision-acceptance", "Verified candidate.", + "--no-global-sync", "--suppress-external-sinks") + assert rc == 0, baseline + control = GoalModeMCPControlPlane(GoalModeMCPConfig(server_name="history-probe", + runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": GOAL_ID, "registry": str(registry), "agent_id": peer}) + control.command_prefix = lambda: [sys.executable, "-m", "loopx.cli"] + args = ("quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", peer, + "--todo-id", todo_id, "--runtime-profile", "claude_code", + "--turn-instance-id", f"history-original-turn-{count}") + rc, guard = _run_cli(registry, *args) + assert rc == 0 and "delivery_result_context" in guard, {key: guard.get(key) for key in + ("ok", "error", "effective_action", "should_run", "selected_todo")} + rc, selected = _run_cli(registry, *args) + assert rc == 0 and selected["interaction_contract"]["agent_channel"]["delivery_allowed"] is True, selected + identity = guard["delivery_result_context"]["settlement_identity"] + completed = json.loads(_complete_default_task(control, identity["todo_id"], peer, + "Synthetic candidate independently verified.", next_agent_todo=f"Verify the next candidate after delivery {count}.")) + assert completed.get("settlement_complete") is True, completed + if count not in (1, 3, 5): + continue + calls = [] + effect = io._checkpoint_effect + + def observe(method, request): + calls.append(method) + return effect(method, request) + + sink.write_text("") + with monkeypatch.context() as patch: + patch.setattr(io, "_checkpoint_effect", observe) + assert pending_first_delivery_progress(tmp_path / "runtime", GOAL_ID, + registry_path=registry) is None + assert calls == ["turn.first_delivery.observe"] + reads = sink.read_text().splitlines() + assert reads.count("index") == 2 + assert reads.count("rollout") == 1 + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("purpose", ["first_delivery", "delivery_result"]) def test_settled_peer_read_damage_preserves_independent_delivery(provider, purpose, tmp_path, monkeypatch): diff --git a/tests/control_plane/test_default_turn_recovery.py b/tests/control_plane/test_default_turn_recovery.py index 7c98a06da7..1a093be461 100644 --- a/tests/control_plane/test_default_turn_recovery.py +++ b/tests/control_plane/test_default_turn_recovery.py @@ -90,7 +90,7 @@ def lose(self,path,receipt): elif FAULT in {'response','bound_response'}: original=cp._checkpoint_effect def lose(method,params): - response=params.get('phase')=='direction_response' + response=params.get('phase')=='direction_return' if response and FAULT=='response': os._exit(97) value=original(method,params) if response and value.get('ok') and FAULT=='bound_response': os._exit(97) diff --git a/tests/control_plane_ts/first_delivery_observation.test.ts b/tests/control_plane_ts/first_delivery_observation.test.ts new file mode 100644 index 0000000000..ed31b8cba3 --- /dev/null +++ b/tests/control_plane_ts/first_delivery_observation.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import {createHash} from "node:crypto"; +import {syncBuiltinESMExports} from "node:module"; +import {tmpdir} from "node:os"; +import {join, resolve} from "node:path"; +import test from "node:test"; +import {settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} from "../../loopx/control_plane/effect_runtime_handlers.ts"; + +const goal = "observation-goal", agent = "observation-agent"; +async function fixture(count: number, loss = "intact", pending = false) { + const root = await fsp.mkdtemp(join(tmpdir(), "loopx-delivery-observation-")); + const runs = join(root, "goals", goal, "runs"), contexts = join(root, "goals", goal, "checkpoint-contexts"); + await fsp.mkdir(runs, {recursive: true}); await fsp.mkdir(contexts, {recursive: true}); + const rows: JsonObject[] = [], events: JsonObject[] = [], todos: JsonObject[] = [], identities: JsonObject[] = []; + for (let i = 0; i < count; i++) { + const identity = settlementIdentityPayload({goal_id: goal, agent_id: agent, todo_id: `todo_work_${i}`, turn_instance_id: `turn-${i}`}); + identities.push(identity); + const common = {goal_id: goal, agent_id: agent, todo_id: identity.todo_id, turn_instance_id: identity.turn_instance_id, + settlement_identity: identity}; + const row = {...common, classification: "state_refreshed", delivery_outcome: "outcome_progress", + refresh_recovery: null, + vision_checkpoint: {required: true, satisfied: true, read_context: {purpose: "first_delivery"}}, + json_path: join(runs, `run-${i}.json`), markdown_path: join(runs, `run-${i}.md`)}; + rows.push(row); + await fsp.writeFile(row.json_path, JSON.stringify(row)); await fsp.writeFile(row.markdown_path, "Verified direction\n"); + const name = createHash("sha256").update(String(identity.effect_id)).digest("hex") + ".json"; + if (loss !== "deleted") await fsp.writeFile(join(contexts, name), loss === "damaged" ? "{" : JSON.stringify({ + schema_version: "checkpoint_read_context_v2", purpose: "first_delivery", identity})); + for (const kind of ["quota_should_run", "refresh_state", ...(pending && i === 0 ? [] : ["quota_spend"])]) { + events.push({schema_version: "loopx_rollout_event_v0", event_id: `${kind}-${i}`, event_kind: kind, + goal_id: goal, agent_id: agent, run_id: identity.turn_instance_id, + details: {todo_id: identity.todo_id, settlement_effect_id: identity.effect_id}}); + } + if (!(pending && i === 0)) rows.push({...common, classification: "quota_slot_spent"}); + todos.push({todo_id: identity.todo_id, status: "done", completion_turn_key: identity.effect_id}); + } + const index = join(runs, "index.jsonl"), log = join(root, "goals", goal, "rollout-event-log.jsonl"); + await fsp.writeFile(index, rows.map(row => JSON.stringify(row) + "\n").join("")); + await fsp.writeFile(log, events.map(row => JSON.stringify(row) + "\n").join("")); + return {root, index, log, rows, identities, request: {runtime_root: root, goal_id: goal, agent_id: agent, todos}}; +} + +const observe = (request: JsonObject) => dispatchEffectRuntimeMethod(createEffectRuntimeHandlers(), "turn.first_delivery.observe", request); + +for (const loss of ["intact", "damaged", "deleted"]) for (const count of [0, 1, 100, 1000]) { + test(`one batch bounds source reads across ${count} ${loss} historical Turns`, async t => { + const f = await fixture(count, loss); + const syncRead = fs.readFileSync, asyncRead = fsp.readFile; + const reads = {index: 0, rollout: 0}; + const record = (path: unknown) => { + if (resolve(String(path)) === resolve(f.index)) reads.index++; + if (resolve(String(path)) === resolve(f.log)) reads.rollout++; + }; + const a = t.mock.method(fs, "readFileSync", (...args: Parameters) => {record(args[0]); return syncRead(...args);}); + const b = t.mock.method(fsp, "readFile", (...args: Parameters) => {record(args[0]); return asyncRead(...args);}); + syncBuiltinESMExports(); + try { + assert.equal(await observe(f.request), null); + assert.equal(reads.index, count ? 2 : 1, "strict proof and settlement each capture the full index once"); + assert.equal(reads.rollout, count ? 1 : 0); + } finally {a.mock.restore(); b.mock.restore(); syncBuiltinESMExports(); await fsp.rm(f.root, {recursive: true, force: true});} + }); +} + +for (const loss of ["intact", "damaged", "deleted"]) test(`old pending accounting survives newer settled ${loss} history`, async () => { + const f = await fixture(100, loss, true); + try { + const progress = await observe(f.request) as JsonObject; + assert.equal(progress.stage, "settlement_pending"); + assert.deepEqual(progress.settlement_identity, f.identities[0]); + assert.equal(progress.quota_spent, false); + assert.equal(progress.result_committed, true); + assert.equal(await observe({...f.request, agent_id: "independent-peer"}), null); + assert.deepEqual(await observe(f.request), progress, "readback cannot repair or debit the original Turn"); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +for (const fault of ["tail", "conflicting-row", "artifact", "changed-index"]) test(`unproved ${fault} history stays uncertain`, async t => { + const f = await fixture(3, "damaged"); + const before = fs.readFileSync(f.index, "utf8"); + let hook: ReturnType | undefined; + try { + if (fault === "tail") await fsp.writeFile(f.index, before.trimEnd()); + if (fault === "conflicting-row") await fsp.appendFile(f.index, JSON.stringify({...f.rows[0], json_path: join(f.root, "other.json")}) + "\n"); + if (fault === "artifact") await fsp.writeFile(String(f.rows[0].json_path), JSON.stringify({...f.rows[0], settlement_identity: f.identities[1]})); + if (fault === "changed-index") { + const read = fsp.readFile; + hook = t.mock.method(fsp, "readFile", async (...args: Parameters) => { + if (resolve(String(args[0])) === resolve(f.index)) await fsp.appendFile(f.index, JSON.stringify({classification: "state_refreshed"}) + "\n"); + return read(...args); + }); syncBuiltinESMExports(); + } + const observed = await observe(f.request) as JsonObject; + assert.ok(["observation_unavailable", "operation_unknown"].includes(String(observed.stage)), JSON.stringify(observed)); + assert.equal(observed.goal_completion_certified, false); + } finally {hook?.mock.restore(); syncBuiltinESMExports(); await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("batch observation cannot invent or partially supply exact Goal admission", async () => { + const f = await fixture(1); + try { + await assert.rejects(observe({...f.request, goal_ref: {goal_id: goal, goal_instance_id: "ginst_" + "a".repeat(32)}}), /supplied together/); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("the caller's historical scan order selects between pending original Turns", async () => { + const f = await fixture(2, "intact", true); + try { + await fsp.writeFile(f.index, f.rows.filter(row => !(row.classification === "quota_slot_spent" && + row.turn_instance_id === "turn-1")).map(row => JSON.stringify(row) + "\n").join("")); + const events = fs.readFileSync(f.log, "utf8").trim().split("\n").map(line => JSON.parse(line)); + await fsp.writeFile(f.log, events.filter(row => !(row.event_kind === "quota_spend" && row.run_id === "turn-1")) + .map(row => JSON.stringify(row) + "\n").join("")); + const names = f.identities.map(identity => createHash("sha256").update(String(identity.effect_id)).digest("hex") + ".json"); + assert.deepEqual((await observe({...f.request, receipt_names: names}) as JsonObject).settlement_identity, f.identities[1]); + assert.deepEqual((await observe({...f.request, receipt_names: names.reverse()}) as JsonObject).settlement_identity, f.identities[0]); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("an unassignable malformed receipt name is scoped unavailable evidence", async () => { + const f = await fixture(1); + try { + const path = join(f.root, "goals", goal, "checkpoint-contexts", "unassignable.json"); + await fsp.writeFile(path, "{"); + const observed = await observe(f.request) as JsonObject; + assert.equal(observed.stage, "observation_unavailable"); + assert.equal(observed.settlement_identity, undefined); + assert.equal(fs.readFileSync(path, "utf8"), "{"); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); + +test("the existing identity owner rejects forged effects and retains opaque historical facts", async () => { + const f = await fixture(1, "intact", true); + try { + const name = createHash("sha256").update(String(f.identities[0].effect_id)).digest("hex") + ".json"; + const path = join(f.root, "goals", goal, "checkpoint-contexts", name); + const receipt = JSON.parse(fs.readFileSync(path, "utf8")); + receipt.identity = {...receipt.identity, historical_note: "retained opaque fact"}; + await fsp.writeFile(path, JSON.stringify(receipt)); + const observed = await observe(f.request) as JsonObject; + assert.deepEqual(observed.settlement_identity, receipt.identity); + receipt.identity.effect_id = "forged-effect"; + await fsp.writeFile(path, JSON.stringify(receipt)); + const rejected = await observe(f.request) as JsonObject; + assert.equal(rejected.stage, "observation_unavailable"); + assert.equal(rejected.settlement_identity, undefined); + } finally {await fsp.rm(f.root, {recursive: true, force: true});} +}); diff --git a/tests/control_plane_ts/first_delivery_observation_probe.mjs b/tests/control_plane_ts/first_delivery_observation_probe.mjs new file mode 100644 index 0000000000..5c8234c343 --- /dev/null +++ b/tests/control_plane_ts/first_delivery_observation_probe.mjs @@ -0,0 +1,16 @@ +// Test-process instrumentation only: count actual reads without changing facts. +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import {syncBuiltinESMExports} from "node:module"; +const sink = process.env.LOOPX_OBSERVATION_TEST_READS; +const goal = process.env.LOOPX_OBSERVATION_TEST_GOAL; +function record(path) { + const name = String(path).replaceAll("\\", "/"); + if (!sink || !goal || !name.includes(`/goals/${goal}/`)) return; + if (name.endsWith("/runs/index.jsonl")) fs.appendFileSync(sink, "index\n"); + if (name.endsWith("/rollout-event-log.jsonl")) fs.appendFileSync(sink, "rollout\n"); +} +const syncRead = fs.readFileSync, asyncRead = fsp.readFile; +fs.readFileSync = (...args) => {record(args[0]); return syncRead(...args);}; +fsp.readFile = (...args) => {record(args[0]); return asyncRead(...args);}; +syncBuiltinESMExports(); From e3fd0534d793aaa7b36263c1e6abaf199ab6ee75 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 09:10:31 +0800 Subject: [PATCH 56/80] fix(freshness): settle reviewed maintainability and lint debt Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 23 +++++++++++++++++++ loopx/chat_store.py | 2 -- .../testing/cli_output_semantics.py | 9 +++++--- tests/canary/test_maintainability_ratchet.py | 2 +- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index c9e1ad7267..4ca8dc2833 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1350,6 +1350,29 @@ This is no output reduction or model-compliance qualification. 校准为 3,300,留 38 字符余量;不删指令,不改生产正文、4,000 宿主硬边界、 长路径检查、执行权限或冻结资格结果,也不据此声称输出节省或模型遵从。 +The following qualification belongs to the named historical revisions. It does +not lower the newer target branch's existing 2,026-line baseline to 2,025. +以下保留指定版本的历史资格,不将较新目标分支已有的 2,026 行基线降为 2,025。 + +The freshness HTTP history composition measures 2,020 lines at `898363635` and +2,025 at `3ed36655a`; `Any`/`dict[str, Any]` counts remain 25/0. The five lines +join provider reply history onto the selected session page at the existing HTTP +composition root. Moving that join into the shared session projection would +couple the generic projection to a provider. Retain the join and its paging, +private-history and recovery coverage; calibrate only `chat_server.py`'s reviewed +line ceiling from 2,020 to 2,025, with no extra headroom. The default 2,000-line +hotspot limit, other metric ceilings, runtime behavior and transport budgets +stay unchanged. The original two unreviewed findings remain historical failures. +The admission diagnostic instead separates byte measurement from quota-identity +observation within its existing module; its 90-statement/60-decision limits and +all identity, malformed-payload and growth assertions remain unchanged. + +freshness 的 HTTP 历史拼接在相同文件边界上为 2,020 → 2,025 行,两个类型计数仍为 +25/0。五行在既有 HTTP 组合处把 provider 回复历史接入当前分页;不为减少行数将 +provider 耦合下沉到通用投影。仅将此文件已审行数基线校准为 2,025,不额外放宽, +保留分页、私有历史和恢复覆盖以及原失败记录。诊断函数则在原模块内分开成本测量与 +身份观察,90/60 复杂度上限及全部负例保持不变;这不改变准入、预算或执行权限。 + The actual-default model portfolio retains the complete capability candidate context required by the shared diagnostic projection. On the unchanged three noisy fixtures, `74b3d526ab1c1fd3d5f96308bef2482fcad7e636` emitted 4,944 / diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 7e5dcd13c3..bfce56eed7 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -10,11 +10,9 @@ from pathlib import Path import re import threading -import time from typing import Any import uuid from weakref import WeakValueDictionary -from .control_plane.runtime.file_paths import windows_extended_path from .chat import ( require_matching_message_replay, diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index a5fe197adc..b84c5d36a5 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -40,6 +40,11 @@ def native_admission_observation(payload: Any) -> dict[str, Any] | None: for field in fields: text = json.dumps(payload[field], ensure_ascii=False) if field in payload else "" costs[field] = {"chars": len(text), "utf8_bytes": len(text.encode("utf-8"))} + return {"kind": _native_quota_admission_kind(payload), "costs": costs} + + +def _native_quota_admission_kind(payload: dict[str, Any]) -> str | None: + """Observe the original quota identity; this diagnostic grants no authority.""" receipt = payload.get("heartbeat_receipt") or {} kind = None if (isinstance(receipt, dict) @@ -81,9 +86,7 @@ def native_admission_observation(payload: Any) -> dict[str, Any] | None: kind = "replan" elif not source and not receipt.get("semantic_replan_obligation_id"): kind = "unbound" - return {"kind": kind, "costs": costs} - - + return kind def authoring_input_observations(text: str) -> dict[str, bool]: """Recognize complete reviewed guidance for one-time output qualification. diff --git a/tests/canary/test_maintainability_ratchet.py b/tests/canary/test_maintainability_ratchet.py index 6e8f1a6a56..1ad9fa00c2 100644 --- a/tests/canary/test_maintainability_ratchet.py +++ b/tests/canary/test_maintainability_ratchet.py @@ -33,7 +33,7 @@ def test_current_repository_debt_is_reviewed_without_line_count_pins() -> None: ] assert report["unreviewed_count"] == 0 assert report["stale_exception_count"] == 0 - assert report["category_counts"] == {"compatibility_facade": 2} + assert report["category_counts"] == {"compatibility_facade": 2, "dependency_debt": 1} assert report["category_counts"].get("oversized_decision_function", 0) == 0 assert report["reviewed_exception_count"] == report["finding_count"] # One scan of the immutable checkout owns both debt and metric assertions. From 2e45c0ef805e50cc0a66a1d4bc852839e4e805e5 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 10:26:01 +0800 Subject: [PATCH 57/80] fix(freshness): qualify integrated recovery paths on Windows Signed-off-by: Tartar --- .../reward_memory/outcome_lifecycle.py | 24 +++++-------------- .../test_reward_memory_outcome_lifecycle.py | 17 +++++++++---- .../test_delegation_request_subject.py | 7 ++++-- .../test_selection_replan_reentry.py | 11 ++++++++- tests/test_chat_queued_write_recovery.py | 12 +++++----- tests/test_loopx_turn_driver.py | 6 +++-- 6 files changed, 44 insertions(+), 33 deletions(-) diff --git a/loopx/capabilities/reward_memory/outcome_lifecycle.py b/loopx/capabilities/reward_memory/outcome_lifecycle.py index 7d37b96d76..41f643c788 100644 --- a/loopx/capabilities/reward_memory/outcome_lifecycle.py +++ b/loopx/capabilities/reward_memory/outcome_lifecycle.py @@ -2,15 +2,15 @@ import hashlib import json -import os import re -import tempfile from collections.abc import Mapping from datetime import datetime, timezone from pathlib import Path from typing import Any from ...control_plane.runtime.public_safety import public_safe_compact_text +from ...control_plane.runtime.file_paths import windows_extended_path +from ...file_lock import write_json_atomic from ...history import load_registry from ...materials import find_registry_goal, goal_repo from ..context_providers.base import ContextProvider @@ -130,7 +130,7 @@ def turn_outcome_ingest_sidecar_path( def _load_sidecar(path: Path) -> dict[str, Any] | None: try: - payload = json.loads(path.read_text(encoding="utf-8")) + payload = json.loads(windows_extended_path(path).read_text(encoding="utf-8")) except FileNotFoundError: return None except (OSError, json.JSONDecodeError) as exc: @@ -144,19 +144,7 @@ def _load_sidecar(path: Path) -> dict[str, Any] | None: def _write_sidecar(path: Path, payload: Mapping[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent) - try: - if hasattr(os, "fchmod"): - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) - handle.write("\n") - os.replace(temporary, path) - path.chmod(0o600) - finally: - if os.path.exists(temporary): - os.unlink(temporary) + write_json_atomic(path, payload, mode=0o600, sort_keys=True) def _replayed_completed_receipt(value: Mapping[str, Any]) -> dict[str, Any]: @@ -726,12 +714,12 @@ def reconcile_pending_turn_outcome_ingests( "status": "explicitly_disabled", "reason_code": "automatic_ingest_explicitly_disabled", } - directory = turn_outcome_ingest_sidecar_path( + directory = windows_extended_path(turn_outcome_ingest_sidecar_path( _goal_repo(registry_path, goal_id), goal_id=goal_id, agent_id=agent_id, source_event_id="placeholder", - ).parent + ).parent) if not directory.is_dir(): return base | {"status": "empty"} pending_count = 0 diff --git a/tests/capabilities/test_reward_memory_outcome_lifecycle.py b/tests/capabilities/test_reward_memory_outcome_lifecycle.py index f883daab18..70affc39e8 100644 --- a/tests/capabilities/test_reward_memory_outcome_lifecycle.py +++ b/tests/capabilities/test_reward_memory_outcome_lifecycle.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from pathlib import Path import pytest @@ -161,9 +162,11 @@ def _settlement_evidence(reflection_json: str | None = None): } +@pytest.mark.parametrize("long_path", [False, True], ids=["ordinary-path", "long-path"]) def test_validated_turn_reflection_writes_reads_back_and_replays( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + long_path: bool, ) -> None: config = _config(tmp_path) provider = OutcomeProvider() @@ -172,7 +175,8 @@ def test_validated_turn_reflection_writes_reads_back_and_replays( "resolve_reward_memory_experiment", lambda **_kwargs: (_status(), config), ) - monkeypatch.setattr(outcome_lifecycle, "_goal_repo", lambda *_args: tmp_path) + repo = tmp_path / ("bounded-project-" + "x" * 100) if long_path else tmp_path + monkeypatch.setattr(outcome_lifecycle, "_goal_repo", lambda *_args: repo) monkeypatch.setattr( recall_runtime, "resolve_reward_memory_experiment", @@ -413,9 +417,11 @@ def test_sibling_agent_scope_is_rejected_before_provider( assert provider.retrieve_calls == 0 +@pytest.mark.parametrize("long_path", [False, True], ids=["ordinary-path", "long-path"]) def test_ambiguous_write_reconciles_same_event_on_next_turn_without_duplication( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + long_path: bool, ) -> None: config = _config(tmp_path) provider = AmbiguousOnceProvider() @@ -424,7 +430,8 @@ def test_ambiguous_write_reconciles_same_event_on_next_turn_without_duplication( "resolve_reward_memory_experiment", lambda **_kwargs: (_status(), config), ) - monkeypatch.setattr(outcome_lifecycle, "_goal_repo", lambda *_args: tmp_path) + repo = tmp_path / ("bounded-project-" + "x" * 100) if long_path else tmp_path + monkeypatch.setattr(outcome_lifecycle, "_goal_repo", lambda *_args: repo) request = { "registry_path": tmp_path / "registry.json", "goal_id": "goal", @@ -475,12 +482,14 @@ def test_ambiguous_write_reconciles_same_event_on_next_turn_without_duplication( assert provider.sync_calls == 2 path = turn_outcome_ingest_sidecar_path( - tmp_path, + repo, goal_id="goal", agent_id="pilot", source_event_id=first["source_event_id"], ) - assert path.stat().st_mode & 0o777 == 0o600 + if os.name == "posix": + # Windows chmod does not express POSIX owner/group access bits. + assert path.stat().st_mode & 0o777 == 0o600 def test_pending_outcome_direct_retry_reuses_stored_settlement_evidence( diff --git a/tests/control_plane/test_delegation_request_subject.py b/tests/control_plane/test_delegation_request_subject.py index 0480f47040..85401e34be 100644 --- a/tests/control_plane/test_delegation_request_subject.py +++ b/tests/control_plane/test_delegation_request_subject.py @@ -1,6 +1,7 @@ """An ordinary request keeps its identity without inventing a canonical task.""" # ruff: noqa: F811 - import the existing parametrized real-backend fixture import json +import os import sys from pathlib import Path @@ -164,9 +165,11 @@ def ordinary_source(delegation_service, monkeypatch, request): root, runner, binding = _ordinary_binding(delegation_service) if getattr(request, "param", None) == "codex": from test_loopx_turn_codex_cli import _fake_codex + from tests.control_plane.host_process_fixture import write_python_host_fixture executable, log = _fake_codex(root) monkeypatch.setenv("FAKE_CODEX_LOG", str(log)) - host_source = executable.read_text().replace('output_path = pathlib.Path', f''' + source_path = executable.with_suffix(".py") if os.name == "nt" else executable + host_source = source_path.read_text(encoding="utf-8").replace('output_path = pathlib.Path', f''' from loopx.control_plane.collaboration.peers import read_inbox, return_result from loopx.control_plane.collaboration.inbox import acknowledge from loopx.capabilities.manager_context.roundtrip import report @@ -180,7 +183,7 @@ def ordinary_source(delegation_service, monkeypatch, request): else: report(root / 'runtime', {runner.goal_id!r}, 'analyst', item['request_id'], 'conclusion', '42') output_path = pathlib.Path''') - executable.write_text(host_source) + executable = write_python_host_fixture(source_path.with_suffix("") if os.name == "nt" else executable, host_source) config_data = json.loads(runner.config.read_text()) config_data["bindings"][0]["host_args"] = ["--host", "codex-cli", "--codex-bin", str(executable)] runner.config.write_text(json.dumps(config_data)) diff --git a/tests/control_plane/test_selection_replan_reentry.py b/tests/control_plane/test_selection_replan_reentry.py index fdcc1d4947..b89a794c69 100644 --- a/tests/control_plane/test_selection_replan_reentry.py +++ b/tests/control_plane/test_selection_replan_reentry.py @@ -1,5 +1,6 @@ """A deferred selection must expose a runnable recovery before settlement.""" import json +import os import shlex import pytest @@ -97,7 +98,14 @@ def test_replan_selection_recovers_inline_or_refuses_ineligible_choice_and_settl cli = resumed["interaction_contract"]["cli_channel"] assert cli["settlement_plan"]["identity"] == identity refresh = next(c for c in cli["next_cli_actions"] if "refresh-state" in c) + checkpoint_args = () if binding == "todo": + rc, context = _run_cli(registry, runtime, "checkpoint-context", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", selected_id, "--turn-instance-id", turn, + "--purpose", "first_delivery") + assert rc == 0, context + assert context["basis"]["todo"]["todo_id"] == selected_id + checkpoint_args = ("--first-delivery", "--checkpoint-read-context", context["read_context_id"]) decision = tmp_path / "selection-replan-vision.json" decision.write_text( json.dumps( @@ -140,7 +148,7 @@ def test_replan_selection_recovers_inline_or_refuses_ineligible_choice_and_settl "": "accepted-artifact", "": "adoption", "": "acceptance", "": "evidence:readback"}.items(): refresh = refresh.replace(key, value) - rc, result = _run_cli(registry, runtime, *_projected_cli_args(refresh, turn_instance_id=turn), cwd=project) + rc, result = _run_cli(registry, runtime, *_projected_cli_args(refresh, turn_instance_id=turn), *checkpoint_args, cwd=project) assert rc == 0, result assert result["settlement_result"]["ok"] spend = next(c for c in cli["next_cli_actions"] if "spend-slot" in c) @@ -461,6 +469,7 @@ def concurrent_receipt(*args, **kwargs): assert _spend_run_count(runtime) == 0 +@pytest.mark.skipif(os.name != "posix", reason="Native context continuity requires POSIX") def test_native_first_selection_publishes_only_final_bound_capture(tmp_path, monkeypatch): from loopx.control_plane.turn_driver.codex_context import codex_context_hook diff --git a/tests/test_chat_queued_write_recovery.py b/tests/test_chat_queued_write_recovery.py index 9dda79f5c3..76a0fda11a 100644 --- a/tests/test_chat_queued_write_recovery.py +++ b/tests/test_chat_queued_write_recovery.py @@ -9,8 +9,10 @@ import pytest import loopx.chat_store as chat_store +import loopx.file_lock as file_lock from loopx.chat_runtime import ChatRuntimeController from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.runtime.file_paths import windows_extended_path from test_chat_loopx_mode import mode, project # noqa: F401 from test_file_lock_windows_atomic import reader_without_delete_share @@ -38,7 +40,7 @@ def transient_reader(path, monkeypatch): attempts = [] def observed_replace(src, dst): - if Path(dst) == path: + if windows_extended_path(Path(dst)) == windows_extended_path(path): attempts.append(1) observed.set() return replace(src, dst) @@ -50,7 +52,7 @@ def release(): close() worker = threading.Thread(target=release) worker.start() - monkeypatch.setattr(chat_store.os, "replace", observed_replace) + monkeypatch.setattr(file_lock.os, "replace", observed_replace) try: yield attempts finally: @@ -88,7 +90,7 @@ def test_unrelated_replace_failure_propagates_without_retry(tmp_path, monkeypatc def fail(src, dst): attempts.append(1) raise FileNotFoundError(errno.ENOENT, "missing source") - monkeypatch.setattr(chat_store.os, "replace", fail) + monkeypatch.setattr(file_lock.os, "replace", fail) with pytest.raises(FileNotFoundError): chat_store._atomic_write_json(tmp_path / "turn.json", {"status": "queued"}) assert attempts == [1] and not list(tmp_path.glob("*.tmp")) @@ -101,7 +103,7 @@ def fail(src, dst): error = PermissionError(errno.EACCES, "denied") error.winerror = 5 raise error - monkeypatch.setattr(chat_store, "os", SimpleNamespace(name="posix", fsync=os.fsync, chmod=os.chmod, replace=fail)) + monkeypatch.setattr(file_lock, "os", SimpleNamespace(**{**vars(os), "name": "posix", "replace": fail})) with pytest.raises(PermissionError): chat_store._atomic_write_json(tmp_path / "turn.json", {"status": "queued"}) assert attempts == [1] and not list(tmp_path.glob("*.tmp")) @@ -135,7 +137,6 @@ def test_original_operation_recovers_queued_after_failed_start_fact(mode, monkey controller = service.controller # Persist the real request capsule/display message; hold only scheduling. monkeypatch.setattr(controller, "submit_turn", ChatRuntimeController.submit_turn.__get__(controller)) - monkeypatch.setattr(controller, "_check_codex_home", lambda _: None) monkeypatch.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: SimpleNamespace()) monkeypatch.setattr(controller, "_start_accepted_turn_worker", lambda **k: False) if operation == "resume": @@ -161,7 +162,6 @@ def test_original_operation_recovers_queued_after_failed_start_fact(mode, monkey service = controller.loopx_mode store = service.store monkeypatch.setattr(controller, "submit_turn", ChatRuntimeController.submit_turn.__get__(controller)) - monkeypatch.setattr(controller, "_check_codex_home", lambda _: None) monkeypatch.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: SimpleNamespace()) monkeypatch.setattr(controller, "_start_accepted_turn_worker", ChatRuntimeController._start_accepted_turn_worker.__get__(controller)) dispatches = [] diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index 20152f8836..ba8781ab5d 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -1291,9 +1291,9 @@ def test_turn_plan_rejects_contradictory_scheduler_owner() -> None: @pytest.mark.parametrize( "effective_action", - ["autonomous_replan", "autonomous_replan_required", "successor_replan_required"], + ["normal_run", "autonomous_replan", "autonomous_replan_required", "successor_replan_required"], ) -def test_turn_plan_keeps_todoless_replan_for_controller(effective_action: str) -> None: +def test_turn_plan_keeps_unbound_todoless_work_for_controller(effective_action: str) -> None: envelope = _envelope(effective_action=effective_action) envelope["action"]["selected_todo"] = None payload = build_loopx_turn_plan( @@ -1353,6 +1353,8 @@ def test_turn_plan_without_todo_requires_signature_and_scoped_binding( envelope["action"]["selected_todo"] = None if invalid_signature: envelope["action_signature"]["matches"] = False + else: + envelope["schema_version"] = "unsupported_turn_envelope" payload = build_loopx_turn_plan( envelope, host="generic-cli", execution_mode="isolated-headless", ) From 0090c13e41ad6ebcc0b0d03f10c4880580853093 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 10:57:53 +0800 Subject: [PATCH 58/80] fix(freshness): preserve replan and native session recovery Signed-off-by: Tartar --- .../turn_driver/codex_sessions.py | 3 ++- .../test_selection_replan_reentry.py | 23 +++++++++++++++---- tests/test_loopx_turn_codex_cli.py | 8 ++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/loopx/control_plane/turn_driver/codex_sessions.py b/loopx/control_plane/turn_driver/codex_sessions.py index 0bcd54594b..7848e75727 100644 --- a/loopx/control_plane/turn_driver/codex_sessions.py +++ b/loopx/control_plane/turn_driver/codex_sessions.py @@ -14,6 +14,7 @@ from ...boundary_authority import normalize_checkpointed_boundary_authority_entries from ...file_lock import exclusive_file_lock from ..runtime.private_permissions import protect_private_path +from ..runtime.file_paths import windows_extended_path from ...runtime import validate_goal_id_path_segment from ..content_digest import BARE_SHA256_PATTERN from ..projects.registry_codec import ( @@ -78,7 +79,7 @@ def _session_path( ).encode("utf-8") ).hexdigest() goal_id: str = validate_goal_id_path_segment(lineage["goal_id"]) - return runtime_root / "goals" / goal_id / "turn-sessions" / f"{digest}.json" + return windows_extended_path(runtime_root / "goals" / goal_id / "turn-sessions" / f"{digest}.json") def _valid_session_id(value: Any) -> str | None: diff --git a/tests/control_plane/test_selection_replan_reentry.py b/tests/control_plane/test_selection_replan_reentry.py index b89a794c69..89590d8994 100644 --- a/tests/control_plane/test_selection_replan_reentry.py +++ b/tests/control_plane/test_selection_replan_reentry.py @@ -102,12 +102,11 @@ def test_replan_selection_recovers_inline_or_refuses_ineligible_choice_and_settl if binding == "todo": rc, context = _run_cli(registry, runtime, "checkpoint-context", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", selected_id, "--turn-instance-id", turn, - "--purpose", "first_delivery") + "--purpose", "delivery_result") assert rc == 0, context assert context["basis"]["todo"]["todo_id"] == selected_id - checkpoint_args = ("--first-delivery", "--checkpoint-read-context", context["read_context_id"]) decision = tmp_path / "selection-replan-vision.json" - decision.write_text( + vision_text = ( json.dumps( { "schema_version": "goal_vision_replan_contract_v0", @@ -137,8 +136,7 @@ def test_replan_selection_recovers_inline_or_refuses_ineligible_choice_and_settl }, } ) - + "\n", - encoding="utf-8", + + "\n" ) refresh = refresh.replace( "", @@ -148,6 +146,21 @@ def test_replan_selection_recovers_inline_or_refuses_ineligible_choice_and_settl "": "accepted-artifact", "": "adoption", "": "acceptance", "": "evidence:readback"}.items(): refresh = refresh.replace(key, value) + if binding == "todo": + # A replan result precedes its fresh direction; it must not complete + # the retained business Todo just to satisfy the checkpoint gate. + result_args = list(_projected_cli_args(refresh, turn_instance_id=turn)) + vision_index = result_args.index("--agent-vision-json") + del result_args[vision_index:vision_index + 2] + rc, pending = _run_cli(registry, runtime, *result_args, cwd=project) + assert rc == 0 and pending["vision_checkpoint"]["replan_stage"] == "awaiting_direction", pending + rc, context = _run_cli(registry, runtime, "checkpoint-context", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", selected_id, "--turn-instance-id", turn, + "--purpose", "supplement_checkpoint") + assert rc == 0, context + assert context["basis"]["todo"]["status"] == "open" + decision.write_text(vision_text, encoding="utf-8") + checkpoint_args = ("--checkpoint-read-context", context["read_context_id"]) rc, result = _run_cli(registry, runtime, *_projected_cli_args(refresh, turn_instance_id=turn), *checkpoint_args, cwd=project) assert rc == 0, result assert result["settlement_result"]["ok"] diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 4846eb346c..99032dd845 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -446,14 +446,20 @@ def test_codex_cli_prompt_isolates_subagent_instructions_to_enabled_request() -> @pytest.mark.parametrize("sandbox", ["read-only", "workspace-write", "danger-full-access"]) +@pytest.mark.parametrize("long_runtime", [False, True], ids=["ordinary-runtime", "long-runtime"]) def test_codex_cli_host_starts_then_resumes_opaque_session( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sandbox: str, + long_runtime: bool, ) -> None: + from loopx.control_plane.runtime.file_paths import windows_extended_path executable, log_path = _fake_codex(tmp_path) monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) runtime_root = tmp_path / "runtime" + if long_runtime: + while len(str(runtime_root)) < 220: + runtime_root /= "runtime-" + "x" * 60 project = tmp_path / "project" project.mkdir() first_request = _request() @@ -535,7 +541,7 @@ def test_codex_cli_host_starts_then_resumes_opaque_session( "host", "session_id", } - session_paths = _session_files(runtime_root) + session_paths = _session_files(windows_extended_path(runtime_root)) assert len(session_paths) == 1 if os.name != "nt": # POSIX mode bits do not express Windows ACLs. assert stat.S_IMODE(session_paths[0].stat().st_mode) == 0o600 From 51ed703775a01e0e1971e49f7cb20618937b45f6 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 11:23:35 +0800 Subject: [PATCH 59/80] fix(turn): preserve journal recovery on Windows long paths Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 22 +++++++++++++++++++ examples/loopx-turn-codex-cli-e2e-smoke.py | 6 ++++- .../turn_driver/journal_store.py | 2 ++ tests/test_loopx_turn_codex_cli.py | 20 +++++++++++------ tests/test_loopx_turn_journal_inspection.py | 19 ++++++++++++++++ 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 4ca8dc2833..01013c691e 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1350,6 +1350,28 @@ This is no output reduction or model-compliance qualification. 校准为 3,300,留 38 字符余量;不删指令,不改生产正文、4,000 宿主硬边界、 长路径检查、执行权限或冻结资格结果,也不据此声称输出节省或模型遵从。 +The deterministic three-Turn Codex CLI smoke exceeded its 30-second outer test +watchdog on Windows even at target main `544aa0fee`: 35.0 seconds versus 73.7 +seconds with the integrated freshness recovery. Both runs used the same Python +interpreter and Node dependencies, isolated File fixtures, three committed +transactions, one opaque session and an effect-free final replay. Preserve the +head's additional cost and the timeout as evidence; this is not a performance +improvement or latency qualification. The Windows-only outer watchdog is 120 +seconds; the POSIX 30-second limit, production Host timeouts, execution quota, +three independent validations/debits and all session/replay assertions remain. +An additional pair measured 16.8/74.4 seconds, but overlapped other validation; +retain it as noisy evidence, not a controlled performance comparison. The +repeat variation and the remaining head cost need separate performance work. + +同机 Windows 的确定性三 Turn Codex CLI smoke,在目标 main `544aa0fee` 已耗时 +35.0 秒,整合 freshness 恢复后的 head 为 73.7 秒,均使用同一 Python 解释器与 +Node 依赖、隔离 File fixture、三次提交、同一会话及无新增副作用的最终重放。 +保留 head 新增成本和原 30 秒超时,不宣称性能改善或延迟达标。仅将 Windows +外层测试看门狗校准为 120 秒;POSIX 的 30 秒、生产 Host 超时、执行配额、三次 +独立验证与扣额以及全部会话和重放断言保持不变。 +另一次测得 16.8/74.4 秒,但与其他验证重叠,仅保留为带干扰的记录,不作为受控 +性能比较;重复测量波动与 head 剩余成本需要单独的性能验证。 + The following qualification belongs to the named historical revisions. It does not lower the newer target branch's existing 2,026-line baseline to 2,025. 以下保留指定版本的历史资格,不将较新目标分支已有的 2,026 行基线降为 2,025。 diff --git a/examples/loopx-turn-codex-cli-e2e-smoke.py b/examples/loopx-turn-codex-cli-e2e-smoke.py index c90f0d7702..49896e9866 100755 --- a/examples/loopx-turn-codex-cli-e2e-smoke.py +++ b/examples/loopx-turn-codex-cli-e2e-smoke.py @@ -25,6 +25,7 @@ initialize_canonical_source_fixture, ) from tests.control_plane.host_process_fixture import write_python_host_fixture # noqa: E402 +from loopx.control_plane.runtime.file_paths import windows_extended_path # noqa: E402 GOAL_ID = "loopx-turn-real-cli-e2e" @@ -367,7 +368,10 @@ def main() -> int: ) args = parser.parse_args() - with tempfile.TemporaryDirectory(prefix="loopx-turn-real-cli-e2e-") as directory: + with tempfile.TemporaryDirectory( + prefix="loopx-turn-real-cli-e2e-", + dir=windows_extended_path(Path(tempfile.gettempdir())), + ) as directory: root = Path(directory) project, runtime, workspace, registry = _write_fixture( root, diff --git a/loopx/control_plane/turn_driver/journal_store.py b/loopx/control_plane/turn_driver/journal_store.py index e5be325ffa..b69324e717 100644 --- a/loopx/control_plane/turn_driver/journal_store.py +++ b/loopx/control_plane/turn_driver/journal_store.py @@ -14,6 +14,7 @@ shadow_maintenance_lock_target, ) from ..effect_runtime import EffectRuntimeConflict, effect_runtime_result +from ..runtime.file_paths import windows_extended_path from .turn_journal_runtime import ( write_turn_journal, ) @@ -31,6 +32,7 @@ def turn_journal_path(runtime_root: Path, *, goal_id: str, turn_key: str) -> Pat def load_turn_journal(path: Path) -> dict[str, Any] | None: + path = windows_extended_path(path) if not path.exists(): return None value = json.loads(path.read_text(encoding="utf-8")) diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 99032dd845..114fe11e14 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -1554,7 +1554,9 @@ def test_public_e2e_smoke_runs_n_transactions_on_one_session() -> None: check=False, capture_output=True, text=True, - timeout=30, + # Measured Windows base/head costs and retained failures are recorded + # in the budget guide; this is a test watchdog, not a latency SLO. + timeout=120 if os.name == "nt" else 30, ) assert result.returncode == 0, result.stderr or result.stdout @@ -1648,13 +1650,17 @@ def test_default_turn_cli_rejects_stopped_source_behind_stale_global_mirror( original_mirror = mirror.read_bytes() executable, native_log = _fake_codex(tmp_path) native_launch = tmp_path / "native-launch.txt" - native_source = executable.read_text() + source_path = executable.with_suffix(".py") if os.name == "nt" else executable + native_source = source_path.read_text(encoding="utf-8") assert "args = sys.argv[1:]" in native_source - executable.write_text(native_source.replace( - "args = sys.argv[1:]", - f"pathlib.Path({str(native_launch)!r}).write_text('started\\n')\nargs = sys.argv[1:]", - 1, - )) + executable = write_python_host_fixture( + source_path.with_suffix("") if os.name == "nt" else executable, + native_source.replace( + "args = sys.argv[1:]", + f"pathlib.Path({str(native_launch)!r}).write_text('started\\n')\nargs = sys.argv[1:]", + 1, + ), + ) monkeypatch.setenv("FAKE_CODEX_LOG", str(native_log)) original_prompt = codex_cli._prompt stopped = [] diff --git a/tests/test_loopx_turn_journal_inspection.py b/tests/test_loopx_turn_journal_inspection.py index e1e0faea7e..701d56df15 100644 --- a/tests/test_loopx_turn_journal_inspection.py +++ b/tests/test_loopx_turn_journal_inspection.py @@ -75,6 +75,25 @@ def _write_journal(runtime_root: Path, journal: dict[str, Any]) -> Path: return path +@pytest.mark.parametrize("mismatched_lineage", [False, True]) +def test_long_path_resume_reads_original_journal_and_checks_lineage(tmp_path, mismatched_lineage): + from loopx.control_plane.turn_driver.journal_store import load_loopx_turn_plan_from_journal, turn_journal_path + from loopx.file_lock import write_json_atomic + + runtime = tmp_path + while len(str(runtime)) < 220: + runtime /= "runtime-" + "x" * 60 + journal = _journal() + if mismatched_lineage: + journal["plan"]["transaction"]["turn_key"] = "sha256:" + "b" * 64 + write_json_atomic(turn_journal_path(runtime, goal_id="fixture-goal", turn_key=TURN_KEY), journal) + if mismatched_lineage: + with pytest.raises(ValueError, match="mismatched turn lineage"): + load_loopx_turn_plan_from_journal(runtime, goal_id="fixture-goal", turn_key=TURN_KEY) + else: + assert load_loopx_turn_plan_from_journal(runtime, goal_id="fixture-goal", turn_key=TURN_KEY) == journal["plan"] + + def _run_inspection_cli( tmp_path: Path, *, From 2c9003301a3603739deed51d0af2848c464077c6 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 21:59:39 +0800 Subject: [PATCH 60/80] fix(host): bind Windows launches and release failed cleanup resources Signed-off-by: Tartar (cherry picked from commit 65233ee731bdc3d051816ff5160b28df31eefa8b) Signed-off-by: Tartar --- .../control_plane/turn_driver/host_process.ts | 18 ++- .../turn_driver/host_process_bridge.ts | 17 ++- .../turn_driver/host_process_transport.py | 36 +++-- .../turn_driver/windows_host_command.py | 56 ++++++++ tests/control_plane/test_host_process.py | 98 +++++++++++++- .../test_windows_host_process.py | 125 ++++++++++++++++++ tests/control_plane_ts/host_process.test.ts | 51 ++++++- 7 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 loopx/control_plane/turn_driver/windows_host_command.py diff --git a/loopx/control_plane/turn_driver/host_process.ts b/loopx/control_plane/turn_driver/host_process.ts index 87c1098488..2a363a8023 100644 --- a/loopx/control_plane/turn_driver/host_process.ts +++ b/loopx/control_plane/turn_driver/host_process.ts @@ -64,8 +64,13 @@ export async function runHostProcess(request: HostProcessRequest, output_complete: true, cleanup_scope: process.platform === "win32" ? "process_tree_best_effort" : "process_group", group_signal_sent: false}; if (signal?.aborted) return {...base, outcome: "cancelled"}; - const child = spawn(request.argv[0], request.argv.slice(1), {cwd: request.cwd, - stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true}); + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(request.argv[0], request.argv.slice(1), {cwd: request.cwd, + stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true}); + } catch { + return base; + } let outcome: HostProcessResult["outcome"] = "exited"; let complete = true, forcedDrain = false, stdoutBytes = 0; let cleanup: Promise | undefined; @@ -156,6 +161,8 @@ export async function runHostProcess(request: HostProcessRequest, try { await exited; await reads; + // Execution and output observation have ended; cleanup still must finish. + if (deadline) clearTimeout(deadline); if (drainTimer) clearTimeout(drainTimer); await clean(); return {...base, outcome, output_complete: complete}; @@ -166,3 +173,10 @@ export async function runHostProcess(request: HostProcessRequest, child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); } } + +/** Interpret a private launch fact without replacing timeout/cancellation. */ +export function observeHostLaunch(result: HostProcessResult, created: unknown): HostProcessResult { + if (typeof created !== "boolean") throw new TypeError("invalid Host launch observation"); + return !created && result.outcome === "exited" + ? {...result, outcome: "spawn_failed", returncode: null, signal: null} : result; +} diff --git a/loopx/control_plane/turn_driver/host_process_bridge.ts b/loopx/control_plane/turn_driver/host_process_bridge.ts index ef1f97b78e..531d0e1316 100644 --- a/loopx/control_plane/turn_driver/host_process_bridge.ts +++ b/loopx/control_plane/turn_driver/host_process_bridge.ts @@ -1,10 +1,15 @@ /** Private parent/child transport. EOF means the owning Python process left. */ import {once} from "node:events"; -import {decodeHostProcessRequest, runHostProcess} from "./host_process.ts"; +import {readFile} from "node:fs/promises"; +import {decodeHostProcessRequest, observeHostLaunch, runHostProcess} from "./host_process.ts"; const owner = new AbortController(); // Private transport observations; none grants Goal or execution authority. type LaunchObservation = {kind: "ready"} | {kind: "not_launched"}; const launchDeadline = process.argv[2] === "--guarded-launch" ? Number(process.argv[3]) : null; +const launchReportIndex = process.argv.indexOf("--windows-launch-status"); +const launchReport = launchReportIndex < 0 ? null : process.argv[launchReportIndex + 1]; +if (launchReportIndex >= 0 && (!launchReport || process.platform !== "win32")) + throw new TypeError("invalid Windows Host launch report"); if (launchDeadline !== null && (!Number.isFinite(launchDeadline) || launchDeadline <= 0)) throw new TypeError("invalid guarded Host bridge deadline"); const abort = () => { @@ -45,12 +50,18 @@ process.stdin.on("data", (chunk: Buffer) => { delete raw.delegated_lease; const request = decodeHostProcessRequest(raw); // The owned process group is reported before either path can run unaccounted. - if (lease === undefined) await emit(await runHostProcess(request, emit, owner.signal, undefined, {spawned: emit})); + let result; + if (lease === undefined) result = await runHostProcess(request, emit, owner.signal, undefined, {spawned: emit}); else { // Ordinary Hosts do not load canonical lease/provider modules. const {decodeDelegatedHostLease, runLeasedHostProcess} = await import("./leased_host_process.ts"); - await emit(await runLeasedHostProcess(request, decodeDelegatedHostLease(lease), emit, owner.signal, emit)); + result = await runLeasedHostProcess(request, decodeDelegatedHostLease(lease), emit, owner.signal, emit); } + // Read only after supervision has completed. Missing/corrupt evidence + // fails the transport; business stdout cannot supply this observation. + if (launchReport && result.outcome === "exited") + result = observeHostLaunch(result, JSON.parse(await readFile(launchReport, "utf8"))); + await emit(result); } catch { process.exitCode = 1; } finally { process.stdin.destroy(); } diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index 91374306c5..86981b1695 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -17,11 +17,6 @@ from ..effect_runtime import _node_executable from .host_bridge_transport import GuardedHostBridge -# Keep Python's Windows executable/batch launcher compatibility. No timeout, -# buffering or lifecycle decision lives in this transport-only child. -_WINDOWS_COMMAND_RELAY = "import subprocess,sys;sys.exit(subprocess.call(sys.argv[1:]))" - - # A launching owner that must later prove its Host drained names a record path # here. The transport consumes it: the Host never inherits it, so a nested # LoopX run inside the Host cannot overwrite its parent's record. @@ -283,8 +278,6 @@ def run_host_process( then releases before Host output; it must never lock the entire Host run. """ command = list(argv) - if sys.platform == "win32": - command = [sys.executable, "-c", _WINDOWS_COMMAND_RELAY, *command] request: dict[str, Any] = { "argv": command, "cwd": str(project), @@ -303,9 +296,17 @@ def run_host_process( if launch_guard is not None else []) with ExitStack() as containment: windows_job = None + launch_report_arguments: list[str] = [] if sys.platform == "win32": from .windows_host_job import WindowsHostJob windows_job = containment.enter_context(WindowsHostJob()) + report_directory = containment.enter_context(tempfile.TemporaryDirectory(prefix="loopx-host-launch-")) + from ..runtime.private_permissions import protect_private_path + protect_private_path(Path(report_directory), directory=True) + report_path = str(Path(report_directory) / "created.json") + relay = Path(__file__).with_name("windows_host_command.py") + request["argv"] = [sys.executable, str(relay), report_path, *command] + launch_report_arguments = ["--windows-launch-status", report_path] proc = subprocess.Popen( [ _node_executable(), @@ -313,6 +314,7 @@ def run_host_process( "--experimental-strip-types", str(bridge), *launch_arguments, + *launch_report_arguments, ], stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -402,13 +404,19 @@ def run_host_process( proc.kill() proc.wait() finally: - if windows_job is not None: - windows_job.finish() - launch_scope.close() - if guarded is not None: - guarded.close_output() - else: - proc.stdout.close() + try: + if windows_job is not None: + windows_job.finish() + finally: + try: + # Preserve the active setup/Job failure if releasing + # the admission guard also fails. + launch_scope.__exit__(*sys.exc_info()) + finally: + if guarded is not None: + guarded.close_output() + else: + proc.stdout.close() if record is not None and result is not None and proc.returncode == 0: assert record_path is not None # The supervisor returned only after cleaning its group; the group is still re-read. diff --git a/loopx/control_plane/turn_driver/windows_host_command.py b/loopx/control_plane/turn_driver/windows_host_command.py new file mode 100644 index 0000000000..e0d4f93bc2 --- /dev/null +++ b/loopx/control_plane/turn_driver/windows_host_command.py @@ -0,0 +1,56 @@ +"""Windows executable selection and creation IO for the TS Host supervisor. + +Run in the requested Host cwd/environment. The private report describes OS +process creation, not successful script execution, acceptance or authority. +No timeout, retry or descendant supervision decision belongs in this relay. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +def resolve_executable(name: str) -> str: + if not name: + raise FileNotFoundError("empty Host command") + # CreateProcess's extensionless compatibility is .exe, independent of + # PATHEXT. Explicit extensions are exact, including .cmd/.bat/.com. + filename = name if os.name != "nt" or os.path.splitext(name)[1] else name + ".exe" + candidates = ([filename] if os.path.dirname(filename) + else [os.path.join(directory, filename) for directory in [os.curdir, *os.get_exec_path()]]) + for candidate in candidates: + if os.path.isfile(candidate): + return os.path.abspath(candidate) + raise FileNotFoundError(name) + + +def main() -> int: + report, command = Path(sys.argv[1]), sys.argv[2:] + try: + executable = resolve_executable(command[0]) + # lpApplicationName binds the exact chosen file. A second implicit + # CreateProcess search must not substitute x.exe.exe after removal. + arguments: str | list[str] = [executable, *command[1:]] + if os.name == "nt" and os.path.splitext(executable)[1].lower() in {".cmd", ".bat"}: + # CreateProcess starts batch files through cmd. Its /c parser needs + # the enclosing quotes as well as list2cmdline's per-argument quotes + # when the batch pathname contains spaces. + arguments = '"' + subprocess.list2cmdline(arguments) + '"' + process = subprocess.Popen(arguments, executable=executable) + except OSError: + report.write_text(json.dumps(False), encoding="utf-8") + return 1 + try: + report.write_text(json.dumps(True), encoding="utf-8") + return process.wait() + finally: + if process.poll() is None: + process.kill() + process.wait() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/control_plane/test_host_process.py b/tests/control_plane/test_host_process.py index 6e44644e77..b67e3b853d 100644 --- a/tests/control_plane/test_host_process.py +++ b/tests/control_plane/test_host_process.py @@ -10,6 +10,7 @@ import time from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace import pytest @@ -73,6 +74,91 @@ def stopped(): assert not marker.exists() and record.read_bytes() == before +@pytest.mark.parametrize("guarded,guard_failure", [(False, False), (True, False), (True, True)]) +def test_setup_and_job_failure_still_release_guard_and_output(tmp_path: Path, monkeypatch, guarded, guard_failure): + """Real transport/Node bridge; synthetic Job IO is not native Windows qualification.""" + from loopx.control_plane.turn_driver import host_process_transport as transport + + setup_error = ValueError("synthetic setup failure") + job_error = OSError("synthetic Job observation failure") + guard_error = RuntimeError("synthetic guard release failure") + held, released, processes, scopes = [], [], [], [] + + class FailingJob: + def __enter__(self): + return self + + def assign(self, process): + processes.append(process) + + def finish(self): + raise job_error + + def __exit__(self, *_exc): + pass + + @contextmanager + def admission(): + held.append(True) + try: + yield + finally: + held.pop() + released.append(True) + if guard_failure: + raise guard_error + + def fail_setup(*_args, **_kwargs): + assert bool(held) is guarded + raise setup_error + + def launch_guard(): + scope = admission() + scopes.append(scope) + return scope + + # Select only the transport's Windows branch, without changing the real + # platform used by subprocess, pathlib, pytest or the Node bridge. + monkeypatch.setattr(transport, "sys", SimpleNamespace( + platform="win32", executable=sys.executable, exc_info=sys.exc_info)) + monkeypatch.setitem(sys.modules, "loopx.control_plane.turn_driver.windows_host_job", + SimpleNamespace(WindowsHostJob=FailingJob)) + monkeypatch.setattr(transport, "_begin_host_process_record", fail_setup) + try: + with pytest.raises((OSError, RuntimeError)) as caught: + transport.run_host_process( + [sys.executable, "-c", "from pathlib import Path;Path('unexpected-host').touch()"], + project=tmp_path, input_text="", timeout_seconds=5, + launch_guard=launch_guard if guarded else None, + ) + assert not held and released == ([True] if guarded else []) + assert len(processes) == 1 + assert processes[0].poll() is not None + assert processes[0].stdout.closed + assert not (tmp_path / "unexpected-host").exists() + assert caught.value is (guard_error if guard_failure else job_error) + errors = [] + error = caught.value + while error is not None: + errors.append(error) + error = error.__context__ + assert job_error in errors and setup_error in errors + finally: + # Also clean the intentionally failing pre-fix run's real resources. + for scope in scopes: + try: + scope.__exit__(None, None, None) + except RuntimeError as error: + if error is not guard_error: + raise + for process in processes: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + process.stdin.close() + process.stdout.close() + + def test_host_output_lines_bound_storage_and_use_lf() -> None: rows: list[str] = [] lines = HostOutputLines(rows.append, max_chars=20) @@ -177,6 +263,7 @@ def test_disappearing_python_owner_cancels_real_host(tmp_path: Path) -> None: ] launcher = f""" from pathlib import Path +from types import SimpleNamespace from loopx.control_plane.turn_driver.host_process_transport import run_host_process run_host_process({host_argv!r}, project=Path({str(tmp_path)!r}), input_text='', timeout_seconds=30) """ @@ -238,17 +325,17 @@ def test_generic_host_timeout_stops_real_descendant(tmp_path: Path) -> None: def test_windows_transport_relay_preserves_argv_and_stdin(tmp_path: Path) -> None: - from loopx.control_plane.turn_driver.host_process_transport import _WINDOWS_COMMAND_RELAY + from loopx.control_plane.turn_driver import windows_host_command - # The relay is tested here on any OS; real .cmd resolution remains a Windows - # integration obligation. All args stay argv entries, not interpolated code. + # Exercise the real relay entry; .cmd interpretation is separately native + # Windows coverage. All arguments remain argv entries, not generated code. values = ["two words", "a&b", "%NAME%", 'one"quote', "界"] result = _run_host( {}, argv=[ sys.executable, - "-c", - _WINDOWS_COMMAND_RELAY, + str(Path(windows_host_command.__file__)), + str(tmp_path / "created.json"), sys.executable, "-c", "import json,sys;print(json.dumps({'args':sys.argv[1:],'input':json.load(sys.stdin)}))", @@ -259,6 +346,7 @@ def test_windows_transport_relay_preserves_argv_and_stdin(tmp_path: Path) -> Non ) assert result["ok"] is True assert result["value"] == {"args": values, "input": {}} + assert json.loads((tmp_path / "created.json").read_text()) is True @pytest.mark.skipif(os.name == "nt", reason="POSIX process-group drain readback") diff --git a/tests/control_plane/test_windows_host_process.py b/tests/control_plane/test_windows_host_process.py index 0ea0d92225..007f571f5b 100644 --- a/tests/control_plane/test_windows_host_process.py +++ b/tests/control_plane/test_windows_host_process.py @@ -3,6 +3,7 @@ import json import os +import shutil import subprocess import sys import time @@ -15,6 +16,130 @@ pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Job Object transport") +@pytest.mark.parametrize("suffix", ["", ".cmd", ".exe", ".bat", ".com"]) +def test_missing_host_is_spawn_failure(tmp_path: Path, suffix: str) -> None: + result = transport.run_host_process([str(tmp_path / ("absent" + suffix))], + project=tmp_path, input_text="", timeout_seconds=5) + assert result["outcome"] == "spawn_failed" and result["returncode"] is None + + +def test_empty_command_cannot_resolve_a_dot_exe(tmp_path: Path) -> None: + shutil.copyfile(Path(os.environ["SYSTEMROOT"]) / "System32" / "cmd.exe", tmp_path / ".exe") + result = transport.run_host_process(["", "/d", "/c", "echo wrong>marker"], + project=tmp_path, input_text="", timeout_seconds=5) + assert result["outcome"] == "spawn_failed" and not (tmp_path / "marker").exists() + + +@pytest.mark.parametrize("suffix", [".cmd", ".exe", ".com"]) +def test_explicit_exe_never_executes_an_added_extension(tmp_path: Path, suffix: str) -> None: + if suffix == ".cmd": + (tmp_path / "absent.exe.cmd").write_text("@echo alternate>marker\n") + else: + shutil.copyfile(Path(os.environ["SYSTEMROOT"]) / "System32" / "cmd.exe", tmp_path / ("absent.exe" + suffix)) + result = transport.run_host_process([str(tmp_path / "absent.exe"), "/d", "/c", "echo alternate>marker"], + project=tmp_path, input_text="", timeout_seconds=5) + assert result["outcome"] == "spawn_failed" + assert not (tmp_path / "marker").exists() + + +def test_bare_command_keeps_exe_priority_independent_of_parent_pathext(tmp_path: Path, monkeypatch) -> None: + shutil.copyfile(Path(os.environ["SYSTEMROOT"]) / "System32" / "cmd.exe", tmp_path / "runner.exe") + shutil.copyfile(Path(os.environ["SYSTEMROOT"]) / "System32" / "whoami.exe", tmp_path / "runner.com") + (tmp_path / "runner.cmd").write_text("@echo wrong-batch\n") + monkeypatch.setenv("PATHEXT", ".CMD;.COM;.EXE") + output = [] + environment = {**os.environ, "PATHEXT": ".EXE"} + before = environment.copy() + result = transport.run_host_process(["runner", "/d", "/c", "echo expected-exe"], + project=tmp_path, input_text="", timeout_seconds=5, environment=environment, on_stdout=output.append) + assert result["outcome"] == "exited" and result["returncode"] == 0 + assert "".join(output).strip() == "expected-exe" + assert environment == before and os.environ["PATHEXT"] == ".CMD;.COM;.EXE" + + +@pytest.mark.parametrize("suffix", [".cmd", ".bat"]) +def test_batch_uses_host_relative_path_preserving_input_and_arguments(tmp_path: Path, suffix: str) -> None: + scripts = tmp_path / "bin with spaces" + scripts.mkdir() + echo = scripts / "echo.py" + echo.write_text("import json,sys;print(json.dumps({'input':json.load(sys.stdin),'args':sys.argv[1:]}))") + (scripts / ("host" + suffix)).write_text(f'@echo off\n"{sys.executable}" "{echo}" %*\n') + output = [] + result = transport.run_host_process(["host" + suffix, "two words", "", "汉字"], + project=tmp_path, input_text='{"message":"hello"}', timeout_seconds=5, + environment={**os.environ, "PATH": "bin with spaces" + os.pathsep + os.environ["PATH"]}, on_stdout=output.append) + assert result["outcome"] == "exited" and result["returncode"] == 0 + assert json.loads("".join(output)) == {"input": {"message": "hello"}, "args": ["two words", "", "汉字"]} + + +def test_batch_selection_uses_host_cwd_not_owner(tmp_path: Path, monkeypatch) -> None: + owner, project = tmp_path / "owner", tmp_path / "project" + owner.mkdir() + project.mkdir() + (owner / "host.cmd").write_text("@echo wrong-owner\n") + (project / "host.cmd").write_text("@echo correct-host\n") + monkeypatch.chdir(owner) + output = [] + result = transport.run_host_process(["host.cmd"], project=project, + input_text="", timeout_seconds=5, on_stdout=output.append) + assert result["returncode"] == 0 and "".join(output).strip() == "correct-host" + + +def test_selection_then_removal_does_not_restart_search(tmp_path: Path) -> None: + from loopx.control_plane.turn_driver import windows_host_command + target = tmp_path / "removed.exe" + shutil.copyfile(Path(os.environ["SYSTEMROOT"]) / "System32" / "cmd.exe", target) + shutil.copyfile(target, tmp_path / "removed.exe.exe") + report = tmp_path / "created.json" + script = ( + "import sys;from pathlib import Path;" + f"sys.path.insert(0,{str(Path(windows_host_command.__file__).parents[3])!r});" + "from loopx.control_plane.turn_driver import windows_host_command as relay;" + "resolve=relay.resolve_executable;" + "\ndef remove(name):\n path=resolve(name);Path(path).unlink();return path\n" + "relay.resolve_executable=remove;sys.argv=['relay',*sys.argv[1:]];sys.exit(relay.main())" + ) + result = subprocess.run([sys.executable, "-c", script, str(report), str(target), "/d", "/c", "echo wrong>marker"], + cwd=tmp_path, timeout=10, capture_output=True) + assert report.exists(), result.stderr.decode(errors="replace") + assert result.returncode == 1 and json.loads(report.read_text()) is False + assert not (tmp_path / "marker").exists() + + +def test_running_host_exit_and_caller_failure_are_distinct(tmp_path: Path) -> None: + from loopx.control_plane.turn_driver.executor import _run_host + result = transport.run_host_process([sys.executable, "-c", "import sys;sys.exit(7)"], + project=tmp_path, input_text="", timeout_seconds=5) + assert result["outcome"] == "exited" and result["returncode"] == 7 + failure = _run_host({}, argv=[str(tmp_path / "absent.exe")], project=tmp_path, timeout_seconds=5) + assert failure["reason"] == "host process spawn_failed" + assert failure["failure_kind"] == "transport_lost" and failure["returncode"] is None + + +@pytest.mark.parametrize("report_content", [None, '"true"']) +def test_missing_or_invalid_launch_report_cannot_use_business_stdout(tmp_path: Path, monkeypatch, report_content) -> None: + popen = subprocess.Popen + observed = [] + invalid = tmp_path / "invalid.json" + if report_content is not None: + invalid.write_text(report_content) + + def launch(argv, **kwargs): + argv = list(argv) + if "--windows-launch-status" not in argv: + return popen(argv, **kwargs) + argv[argv.index("--windows-launch-status") + 1] = str(invalid) + process = popen(argv, **kwargs) + observed.append(process) + return process + + monkeypatch.setattr(transport.subprocess, "Popen", launch) + with pytest.raises(RuntimeError, match="supervision returned no result"): + transport.run_host_process([sys.executable, "-c", "print('true')"], + project=tmp_path, input_text="", timeout_seconds=5) + assert observed[0].poll() is not None and observed[0].stdout.closed + + def host_source(marker: Path, pid: Path, *, exit_leader: bool = False) -> str: child = ( "import os,time;from pathlib import Path;" diff --git a/tests/control_plane_ts/host_process.test.ts b/tests/control_plane_ts/host_process.test.ts index 7f64275b7f..cdc69adfce 100644 --- a/tests/control_plane_ts/host_process.test.ts +++ b/tests/control_plane_ts/host_process.test.ts @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; +import childProcess from "node:child_process"; +import {syncBuiltinESMExports} from "node:module"; import {existsSync} from "node:fs"; import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; import {join} from "node:path"; import {tmpdir} from "node:os"; import {setTimeout as delay} from "node:timers/promises"; import test from "node:test"; -import {decodeHostProcessRequest, runHostProcess, type HostProcessRequest} from "../../loopx/control_plane/turn_driver/host_process.ts"; +import {decodeHostProcessRequest, observeHostLaunch, runHostProcess, type HostProcessRequest} from "../../loopx/control_plane/turn_driver/host_process.ts"; import {decodeDelegatedHostLease, runLeasedHostProcess} from "../../loopx/control_plane/turn_driver/leased_host_process.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {executeCoordinationTodoClaim} from "../../loopx/control_plane/coordination/todo_claim.ts"; @@ -19,6 +21,17 @@ const request = (script: string, overrides: Partial = {}): H timeout_ms: 3000, drain_timeout_ms: 50, stdout_limit_bytes: 12000, ...overrides, }); +test("private target launch facts cannot fake success or replace cancellation", async () => { + const result = await runHostProcess(request("process.exit(7)"), async () => {}); + assert.equal(result.outcome, "exited"); + assert.deepEqual(observeHostLaunch(result, true), result); + assert.equal(observeHostLaunch(result, false).outcome, "spawn_failed"); + assert.equal(observeHostLaunch(result, false).returncode, null); + assert.throws(() => observeHostLaunch(result, "false"), /invalid Host launch observation/); + const cancelled = {...result, outcome: "cancelled" as const}; + assert.deepEqual(observeHostLaunch(cancelled, false), cancelled); +}); + test("Host output is streamed with UTF-8 boundaries and stdin EOF", async () => { let stdout = "", stderr = ""; const result = await runHostProcess(request(` @@ -40,6 +53,42 @@ test("null execution deadline waits for natural completion", async () => { assert.equal(result.returncode, 0); }); +test("completed Host is not reclassified as timeout during Windows cleanup", {skip: process.platform !== "win32"}, async t => { + const spawn = childProcess.spawn, timer = globalThis.setTimeout, clear = globalThis.clearTimeout; + let executionTimer: ReturnType | undefined; + let expire: (() => void) | undefined; + let cleanupStarted = false; + t.mock.method(globalThis, "setTimeout", (callback: () => void, ms: number) => { + const handle = timer(callback, ms); + if (ms === 5000) { executionTimer = handle; expire = callback; } + return handle; + }); + t.mock.method(globalThis, "clearTimeout", (handle: ReturnType) => { + if (handle === executionTimer) expire = undefined; + return clear(handle); + }); + t.mock.method(childProcess, "spawn", (...args: Parameters) => { + if (args[0] === "taskkill") { + cleanupStarted = true; + // Advance only the execution clock at the known cleanup boundary. + // The real Host, streams and Windows cleanup command are unchanged. + const pending = expire; + expire = undefined; + if (pending) queueMicrotask(pending); + } + return spawn(...args); + }); + syncBuiltinESMExports(); + t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); }); + let stdout = ""; + const result = await runHostProcess(request("process.stdout.write('{}')", {timeout_ms: 5000}), + async item => { if (item.kind === "stdout") stdout += item.text; }); + assert.equal(cleanupStarted, true); + assert.equal(stdout, "{}"); + assert.equal(result.returncode, 0); + assert.equal(result.outcome, "exited"); +}); + test("invalid requests and absent executables cannot be mistaken for success", async () => { for (const change of [{argv: []}, {argv: [""]}, {timeout_ms: Infinity}, {timeout_ms: 0}, {stdout_limit_bytes: -1}, {drain_timeout_ms: -1}, {unexpected: true}]) { assert.throws(() => decodeHostProcessRequest({...request(""), ...change}), /invalid/); From 7e2a18e990cffe1b95cd9fefef94e5cc97a1fd34 Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 23:54:17 +0800 Subject: [PATCH 61/80] fix(freshness): preserve recovery owners across main integration Retain batched observation, typed direction receipt transitions and original Turn selection after rebasing. Preserve current request authority and keep public scanner behavior identical to main. Signed-off-by: Tartar --- .../smoke/checkpoint-recovery-http-fixture.py | 432 +++++++++++++----- docs/reference/glossary.md | 2 +- loopx/cli_commands/quota.py | 4 +- loopx/contract.py | 88 +--- .../control_plane/effect_runtime_handlers.ts | 1 + .../control_plane/goals/checkpoint_commit.ts | 42 +- .../goals/checkpoint_context_io.py | 86 +--- .../goals/checkpoint_read_context.ts | 27 +- .../control_plane/host_adapter_settlement.py | 6 +- .../turn_driver/checkpoint_continuation.py | 18 +- .../turn_driver/first_delivery.ts | 7 +- loopx/goal_mode_mcp.py | 11 +- loopx/semantics/vocabulary_v0.json | 2 +- .../test_checkpoint_history_isolation.py | 10 + .../test_default_result_freshness.py | 27 ++ ...host_adapter_settlement_step_vocabulary.py | 8 +- .../checkpoint_read_context.test.ts | 27 ++ ...test_contract_public_fixture_allowances.py | 169 ------- 18 files changed, 472 insertions(+), 495 deletions(-) delete mode 100644 tests/test_contract_public_fixture_allowances.py diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py index 1873c29554..d9876aff1b 100644 --- a/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-http-fixture.py @@ -3,6 +3,9 @@ Invoked from the repository root by the packaged browser smoke. No paid model, real Lark, active operator Goal or user browser profile is used. """ +# Source-checkout script bootstraps its own imports before loading the owners. +# ruff: noqa: E402 + from __future__ import annotations import hashlib @@ -13,152 +16,337 @@ from types import SimpleNamespace ROOT = Path.cwd() -sys.path[:0] = [str(ROOT), str(ROOT/'tests'), str(ROOT/'tests/control_plane')] +sys.path[:0] = [str(ROOT), str(ROOT / "tests"), str(ROOT / "tests/control_plane")] import pytest from loopx.chat_runtime import ChatRuntimeController from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.chat_store import ChatSessionStore -from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted -from loopx.todos import add_goal_todo +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) from tests.control_plane import test_default_checkpoint_continuation as native from tests import test_goal_mode_mcp_settlement as mcp def digest(value): - return hashlib.sha256(json.dumps(value,sort_keys=True).encode()).hexdigest() + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() def main(): - out=Path(sys.argv[1]).resolve();out.mkdir(parents=True,exist_ok=False) - isolated=out/'native';isolated.mkdir() - patches=pytest.MonkeyPatch() - goal_id,agent_id='product-release','lead' - for module in (native,mcp): - patches.setattr(module,'GOAL_ID',goal_id) - patches.setattr(module,'AGENT_ID',agent_id) - registry,state,todo=native._fixture('file',isolated,patches) - payload=json.loads(registry.read_text());goal=payload['goals'][0] - goal['domain']='Checkpoint recovery demonstration' - goal['coordination']['registered_agents']=[agent_id,'reviewer','codex'] - goal['spawn_policy']={'mode':'multi_subagent','allowed':True,'max_children':2, - 'execution_config':'.loopx/config/delegations.json'} - registry.write_text(json.dumps(payload),encoding='utf-8') - project=state.parent - config=project/'.loopx/config/delegations.json';config.parent.mkdir(parents=True,exist_ok=True) - config.write_text(json.dumps({'schema_version':'loopx_local_delegation_v0','bindings':[{ - 'id':'review','agent_id':'reviewer','todo_id':'review-task','requesters':[agent_id], - 'workspace':str(project),'host_args':['--host','dsh'],'timeout_seconds':60, - 'output_refs':['output.json']}]}),encoding='utf-8') - control=mcp._control(registry) - original_guard=json.loads(control.should_run()) - first=json.loads(control.complete_task(todo,agent_id,'Candidate verified.',no_follow_up=True)) - assert first['stage']=='direction_pending' and first['completed'] is True - original=first['settlement_identity'] - runtime_root=isolated/'runtime' - index=runtime_root/'goals'/goal_id/'runs/index.jsonl' - store=ChatSessionStore(runtime_root) - sid='session-goal-product-release-codex' - store.create_session(goal_id=goal_id,agent_id='codex',channel_id='goal.product-release', - upstream_thread_id='synthetic-original-sdk',session_id=sid, - adapter_kind='codex_app_server',upstream_mode='chat') - settings={'agent_id':agent_id,'token_budget':10000,'execution_config_ref':'.loopx/config/delegations.json', - 'config_digest':hashlib.sha256(config.read_bytes()).hexdigest()} - store.update_session(sid,loopx_tools=True,native_goal={'status':'paused','tokensUsed':0}, - loopx_mode={'enabled':True,'paused':True,'settings':settings}) - patches.setenv('LOOPX_CHAT_CODEX_HOME',str(isolated/'codex-home')) - controller=ChatRuntimeController(store=store,codex_bin='missing-codex',registry_path=registry) - adapter=SimpleNamespace(goal_driver=SimpleNamespace(stopped=threading.Event()), - session=SimpleNamespace(),upstream_thread_id='synthetic-original-sdk', - close_session=lambda:None,healthcheck=lambda:True) - patches.setattr(controller,'_ensure_adapter_locked',lambda *a,**k:adapter) - controller.adapters[sid]=adapter - dispatches=[]; failures=[]; decisions=[] + out = Path(sys.argv[1]).resolve() + out.mkdir(parents=True, exist_ok=False) + isolated = out / "native" + isolated.mkdir() + patches = pytest.MonkeyPatch() + goal_id, agent_id = "product-release", "lead" + for module in (native, mcp): + patches.setattr(module, "GOAL_ID", goal_id) + patches.setattr(module, "AGENT_ID", agent_id) + registry, state, todo = native._fixture("file", isolated, patches) + payload = json.loads(registry.read_text()) + goal = payload["goals"][0] + goal["domain"] = "Checkpoint recovery demonstration" + goal["coordination"]["registered_agents"] = [agent_id, "reviewer", "codex"] + goal["spawn_policy"] = { + "mode": "multi_subagent", + "allowed": True, + "max_children": 2, + "execution_config": ".loopx/config/delegations.json", + } + registry.write_text(json.dumps(payload), encoding="utf-8") + project = state.parent + config = project / ".loopx/config/delegations.json" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text( + json.dumps( + { + "schema_version": "loopx_local_delegation_v0", + "bindings": [ + { + "id": "review", + "agent_id": "reviewer", + "todo_id": "review-task", + "requesters": [agent_id], + "workspace": str(project), + "host_args": ["--host", "dsh"], + "timeout_seconds": 60, + "output_refs": ["output.json"], + } + ], + } + ), + encoding="utf-8", + ) + control = mcp._control(registry) + assert json.loads(control.should_run())["ok"] is True + first = json.loads( + control.complete_task(todo, agent_id, "Candidate verified.", no_follow_up=True) + ) + assert ( + first.get("stage") == "direction_pending" and first.get("completed") is True + ), first + original = first["settlement_identity"] + runtime_root = isolated / "runtime" + index = runtime_root / "goals" / goal_id / "runs/index.jsonl" + store = ChatSessionStore(runtime_root) + sid = "session-goal-product-release-codex" + store.create_session( + goal_id=goal_id, + agent_id="codex", + channel_id="goal.product-release", + upstream_thread_id="synthetic-original-sdk", + session_id=sid, + adapter_kind="codex_app_server", + upstream_mode="chat", + ) + settings = { + "agent_id": agent_id, + "token_budget": 10000, + "execution_config_ref": ".loopx/config/delegations.json", + "config_digest": hashlib.sha256(config.read_bytes()).hexdigest(), + } + store.update_session( + sid, + loopx_tools=True, + native_goal={"status": "paused", "tokensUsed": 0}, + loopx_mode={"enabled": True, "paused": True, "settings": settings}, + ) + patches.setenv("LOOPX_CHAT_CODEX_HOME", str(isolated / "codex-home")) + controller = ChatRuntimeController( + store=store, codex_bin="missing-codex", registry_path=registry + ) + adapter = SimpleNamespace( + goal_driver=SimpleNamespace(stopped=threading.Event()), + session=SimpleNamespace(), + upstream_thread_id="synthetic-original-sdk", + close_session=lambda: None, + healthcheck=lambda: True, + ) + patches.setattr(controller, "_ensure_adapter_locked", lambda *a, **k: adapter) + controller.adapters[sid] = adapter + dispatches = [] + failures = [] + decisions = [] def native_snapshot(): - items=read_canonical_todos_if_promoted(runtime_root=runtime_root,goal_id=goal_id)['todos'] - row=next(item for item in items if item['todo_id']==todo) - facts={key:row.get(key) for key in ('todo_id','status','evidence','completed_at','completion_turn_key')} - events=[json.loads(line) for line in index.read_text(encoding='utf-8').splitlines()] if index.exists() else [] - return {'facts':facts,'result_sha256':digest(facts), - 'counts':{'result':int(row['status']=='done' and row['evidence']=='Candidate verified.'), - 'debit':sum(e.get('classification')=='quota_slot_spent' for e in events)}, - 'session_id':store.load_session(sid)['session_id'], - 'upstream_thread_id':store.load_session(sid)['upstream_thread_id'], - 'native_events':events,'dispatches':list(dispatches),'decisions':list(decisions)} + items = read_canonical_todos_if_promoted( + runtime_root=runtime_root, goal_id=goal_id + )["todos"] + row = next(item for item in items if item["todo_id"] == todo) + facts = { + key: row.get(key) + for key in ( + "todo_id", + "status", + "evidence", + "completed_at", + "completion_turn_key", + ) + } + events = ( + [ + json.loads(line) + for line in index.read_text(encoding="utf-8").splitlines() + ] + if index.exists() + else [] + ) + return { + "facts": facts, + "result_sha256": digest(facts), + "counts": { + "result": int( + row["status"] == "done" and row["evidence"] == "Candidate verified." + ), + "debit": sum( + e.get("classification") == "quota_slot_spent" for e in events + ), + }, + "session_id": store.load_session(sid)["session_id"], + "upstream_thread_id": store.load_session(sid)["upstream_thread_id"], + "native_events": events, + "dispatches": list(dispatches), + "decisions": list(decisions), + } def provider(**kwargs): - dispatches.append({'session_id':kwargs['session_id'],'turn_id':kwargs['turn_id'], - 'upstream_thread_id':adapter.upstream_thread_id,'phase':'checkpoint_direction'}) - lock=None + dispatches.append( + { + "session_id": kwargs["session_id"], + "turn_id": kwargs["turn_id"], + "upstream_thread_id": adapter.upstream_thread_id, + "phase": "checkpoint_direction", + } + ) + lock = None try: - assert kwargs['session_id']==sid and kwargs['loopx_execution'] is True - lock=controller.loopx_mode.prepare(sid,kwargs['turn_id'],adapter,lambda *a:{},lambda *a:None) - read=adapter.session.read_tool_handler('loopx_collaboration',{ - 'action':'read','operation_id':todo,'brief':{'purpose':'first_delivery'}}) - assert read['ok'] and read['settlement_identity']==original,read - decision=adapter.session.read_tool_handler('loopx_collaboration',{ - 'action':'resume','operation_id':todo,'brief':{'purpose':'first_delivery', - 'read_context_id':read['read_context_id'], - 'vision_unchanged_reason':'The retained candidate and freshly observed remaining obligations preserve the route.'}}) - assert decision['ok'] and decision['settlement_complete'] and decision['settlement_identity']==original,decision - decisions.append({'read':read,'settlement':decision}) - store.update_turn(sid,kwargs['turn_id'],status='completed',response={'text':'Original checkpoint settled.'}) - store.release_active_turn(sid,kwargs['turn_id'],last_activity_at='2026-10-19T00:00:00Z',last_error_code=None) - store.update_session(sid,loopx_mode={'enabled':True,'paused':True,'settings':settings}) + assert kwargs["session_id"] == sid and kwargs["loopx_execution"] is True + lock = controller.loopx_mode.prepare( + sid, kwargs["turn_id"], adapter, lambda *a: {}, lambda *a: None + ) + read = adapter.session.read_tool_handler( + "loopx_collaboration", + { + "action": "read", + "operation_id": todo, + "brief": {"purpose": "first_delivery"}, + }, + ) + assert read["ok"] and read["settlement_identity"] == original, read + decision = adapter.session.read_tool_handler( + "loopx_collaboration", + { + "action": "resume", + "operation_id": todo, + "brief": { + "purpose": "first_delivery", + "read_context_id": read["read_context_id"], + "vision_unchanged_reason": "The retained candidate and freshly observed remaining obligations preserve the route.", + }, + }, + ) + assert ( + decision["ok"] + and decision["settlement_complete"] + and decision["settlement_identity"] == original + ), decision + decisions.append({"read": read, "settlement": decision}) + store.update_turn( + sid, + kwargs["turn_id"], + status="completed", + response={"text": "Original checkpoint settled."}, + ) + store.release_active_turn( + sid, + kwargs["turn_id"], + last_activity_at="2026-10-19T00:00:00Z", + last_error_code=None, + ) + store.update_session( + sid, loopx_mode={"enabled": True, "paused": True, "settings": settings} + ) except Exception as exc: - failures.append(repr(exc));raise + failures.append(repr(exc)) + raise finally: - if lock:lock.__exit__(None,None,None) - - patches.setattr(controller,'_run_started_turn',provider) - server=ChatHTTPServer(('127.0.0.1',0),ChatRequestHandler) - server.verbose=False;server.registry_path=registry;server.runtime_root=runtime_root - server.chat_store=store;server.runtime_controller=controller - http=[] - send=ChatRequestHandler._send_json - def observed_send(handler,body,*,status=200): - if handler.path.startswith('/api/chat/sessions/') and handler.path.endswith('/loopx'): - http.append({'method':handler.command,'path':handler.path,'status':status,'response':body}) - return send(handler,body,status=status) - patches.setattr(ChatRequestHandler,'_send_json',observed_send) - thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() - baseline=native_snapshot(); assert baseline['counts']=={'result':1,'debit':0} - (out/'before.json').write_text(json.dumps(baseline,indent=2),encoding='utf-8') - (out/'first-result.json').write_text(json.dumps(first,indent=2),encoding='utf-8') - print(json.dumps({'origin':f'http://127.0.0.1:{server.server_port}','session':store.load_session(sid), - 'original_identity':original}),flush=True) + if lock: + lock.__exit__(None, None, None) + + patches.setattr(controller, "_run_started_turn", provider) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.registry_path = registry + server.runtime_root = runtime_root + server.chat_store = store + server.runtime_controller = controller + http = [] + send = ChatRequestHandler._send_json + + def observed_send(handler, body, *, status=200): + if handler.path.startswith("/api/chat/sessions/") and handler.path.endswith( + "/loopx" + ): + http.append( + { + "method": handler.command, + "path": handler.path, + "status": status, + "response": body, + } + ) + return send(handler, body, status=status) + + patches.setattr(ChatRequestHandler, "_send_json", observed_send) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + baseline = native_snapshot() + assert baseline["counts"] == {"result": 1, "debit": 0} + (out / "before.json").write_text(json.dumps(baseline, indent=2), encoding="utf-8") + (out / "first-result.json").write_text( + json.dumps(first, indent=2), encoding="utf-8" + ) + print( + json.dumps( + { + "origin": f"http://127.0.0.1:{server.server_port}", + "session": store.load_session(sid), + "original_identity": original, + } + ), + flush=True, + ) try: for line in sys.stdin: - command=line.strip() - if command=='inspect': - value=native_snapshot();value['failures']=list(failures) - elif command=='replay': - assert len(decisions)==1 and not failures - from loopx.chat_coordinator_checkpoint import CoordinatorCheckpoints - checkpoint=CoordinatorCheckpoints(registry,runtime_root,goal_id,agent_id) + command = line.strip() + if command == "inspect": + value = native_snapshot() + value["failures"] = list(failures) + elif command == "replay": + assert len(decisions) == 1 and not failures + from loopx.hosts.chat_coordinator_checkpoint import ( + CoordinatorCheckpoints, + ) + + checkpoint = CoordinatorCheckpoints( + registry, runtime_root, goal_id, agent_id + ) # Replay the exact successful decision, without another SDK dispatch. - decision=decisions[0] - repeated=checkpoint.resume(todo,{'read_context_id':decision['read']['read_context_id'], - 'vision_unchanged_reason':'The retained candidate and freshly observed remaining obligations preserve the route.'}) - assert repeated['ok'] and repeated['settlement_identity']==original,repeated - value={**native_snapshot(),'replayed_identity':repeated['settlement_identity'],'replay':repeated} - elif command=='save': - assert not failures,failures - value={**native_snapshot(),'http':http,'original_identity':original, - 'settled_identity':decisions[0]['settlement']['settlement_identity']} - elif command in {'unavailable', 'restore_observation'}: - damaged=runtime_root/'goals'/goal_id/'checkpoint-contexts'/('f'*64+'.json') - if command=='unavailable': - damaged.write_text('{',encoding='utf-8') + decision = decisions[0] + repeated = checkpoint.resume( + todo, + { + "read_context_id": decision["read"]["read_context_id"], + "vision_unchanged_reason": "The retained candidate and freshly observed remaining obligations preserve the route.", + }, + ) + assert repeated["ok"] and repeated["settlement_identity"] == original, ( + repeated + ) + value = { + **native_snapshot(), + "replayed_identity": repeated["settlement_identity"], + "replay": repeated, + } + elif command == "save": + assert not failures, failures + value = { + **native_snapshot(), + "http": http, + "original_identity": original, + "settled_identity": decisions[0]["settlement"][ + "settlement_identity" + ], + } + elif command in {"unavailable", "restore_observation"}: + damaged = ( + runtime_root + / "goals" + / goal_id + / "checkpoint-contexts" + / ("f" * 64 + ".json") + ) + if command == "unavailable": + damaged.write_text("{", encoding="utf-8") else: damaged.unlink() - value={'snapshot':controller.loopx_mode.snapshot(sid),'native':native_snapshot()} - else:raise ValueError('Unknown fixture command') - (out/(command+'.json')).write_text(json.dumps(value,indent=2),encoding='utf-8') - print(json.dumps(value),flush=True) + value = { + "snapshot": controller.loopx_mode.snapshot(sid), + "native": native_snapshot(), + } + else: + raise ValueError("Unknown fixture command") + (out / (command + ".json")).write_text( + json.dumps(value, indent=2), encoding="utf-8" + ) + print(json.dumps(value), flush=True) finally: - server.shutdown();server.server_close();thread.join(timeout=5) - controller.close();patches.undo() + server.shutdown() + server.server_close() + thread.join(timeout=5) + controller.close() + patches.undo() -if __name__=='__main__':main() +if __name__ == "__main__": + main() diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index f42c286e4f..f9fe4d4cbf 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -126,7 +126,7 @@ Authority handoff mode between agents. Provider step kinds used by host-adapter settlement plans. TypeScript authors each phase's ordered plan and Python validates that phase's sequence before execution. Ordinary settlement uses guard, lifecycle_completion, durable_writeback, quota_spend and optional terminal_closeout; first-delivery preparation uses delivery_context or direction_context in its phase-specific sequence. Distinct from settlement_step_kind, which names the turn settlement phases including seeded validation. - Tier / 层级: `cross_runtime`; status / 状态: `canonical`. -- python: [`HOST_ADAPTER_SETTLEMENT_STEP_KINDS`](../../loopx/control_plane/host_adapter_settlement.py). +- python: [`HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE`](../../loopx/control_plane/host_adapter_settlement.py). - typescript: [`HOST_ADAPTER_SETTLEMENT_STEP_KINDS`](../../loopx/control_plane/turn_driver/host_todo_completion.ts). - Values / 值: `guard`, `lifecycle_completion`, `durable_writeback`, `quota_spend`, `terminal_closeout`, `delivery_context`, `direction_context`. diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 74c49426fd..052824c9f1 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -543,7 +543,8 @@ def handle_quota_command( with fence: return _handle_quota_command(args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, print_payload=print_payload, append_cli_rollout_event=append_cli_rollout_event, - capture_directory=capture_directory) + capture_directory=capture_directory, + retained_action_selection_todo_id=retained_action_selection_todo_id) except (ValueError, OSError, RuntimeError) as error: payload = quota_failure_payload(args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, error=error) print_payload(payload, args.format, _quota_renderer(args)) @@ -558,6 +559,7 @@ def _handle_quota_command( print_payload: PrintPayload, append_cli_rollout_event: RolloutEventAppender, capture_directory: Path | None = None, + retained_action_selection_todo_id: str | None = None, ) -> int: usage_quota_started = time.time_ns() // 1_000_000 heartbeat_turn_id: str | None = None diff --git a/loopx/contract.py b/loopx/contract.py index d1dea9a90b..f3f01f1fd2 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import os import re @@ -75,7 +74,7 @@ class LeakRule: blocking: bool = True def is_candidate(self, folded_text: str) -> bool: - return any(map(folded_text.__contains__, self.required_literals)) + return any(literal in folded_text for literal in self.required_literals) # Python's Unicode re.IGNORECASE matches ASCII I/i against both dotted and @@ -251,30 +250,6 @@ def _credential_hits_are_all_references(line: str) -> bool: return all(_credential_match_is_reference(line, match) for match in matches) -def _credential_hits_are_reviewed_placeholders(line: str, placeholders: list[str]) -> bool: - def reviewed(match: re.Match[str]) -> bool: - if _credential_match_is_reference(line, match): - return True - # Only complete assignment values qualify. In particular, matching the - # keyword alone or a prefix of a different secret must grant nothing. - if match.group(0).lower() not in {"tok" + "en=", "pass" + "word="}: - return False - value = line[match.end():].lstrip(" \t") - if value[:1] in {"\"", "'", "`"}: - quote = value[0] - end = value.find(quote, 1) - remainder = value[end + 1:].lstrip() - return ( - end > 0 and value[1:end] in placeholders - and (not remainder or remainder[0] in ",)]};&") - ) - literal = re.match(r"[^\s&\"'`]+", value) - return literal is not None and literal.group(0) in placeholders - - matches = list(LEAK_RULES["credential"].pattern.finditer(line)) - return bool(matches) and all(reviewed(match) for match in matches) - - DEFAULT_SCAN_SUFFIXES = {".md", ".py", ".toml", ".json", ".yaml", ".yml", ".sh"} DEFAULT_SKIP_DIRS = { ".git", @@ -367,7 +342,7 @@ def _is_local_private_state_path(path: Path, scan_root: Path) -> bool: return bool(parts & LOCAL_PRIVATE_STATE_PARTS) or path.name in LOCAL_PRIVATE_STATE_FILE_NAMES -def _public_boundary_policy(registry: dict[str, Any]) -> dict[str, Any]: +def _public_boundary_policy(registry: dict[str, Any]) -> dict[str, str]: policy = registry.get("public_boundary") if isinstance(registry, dict) else None if not isinstance(policy, dict): policy = registry.get("privacy") if isinstance(registry, dict) else None @@ -382,21 +357,10 @@ def _public_boundary_policy(registry: dict[str, Any]) -> dict[str, Any]: if str(raw_mode).lower() == "allow" or policy.get("allow_tracked_private_doc_urls") is True: tracked_private_doc_urls = "allow" - result: dict[str, Any] = { + return { "schema_version": "loopx_public_boundary_policy_v0", "tracked_private_doc_urls": tracked_private_doc_urls, } - # Explicit owner-reviewed public fixtures remain scoped to the exact - # scanned content and rule. A path or test-directory name grants nothing. - if isinstance(policy.get("public_fixture_allowances"), dict) and policy["public_fixture_allowances"]: - result["public_fixture_allowances"] = policy["public_fixture_allowances"] - placeholders = policy.get("public_credential_placeholders") - if isinstance(placeholders, list) and placeholders and all( - isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9._-]{1,64}", value) - for value in placeholders - ): - result["public_credential_placeholders"] = placeholders - return result def _hit_allowed_by_policy(name: str, git: dict[str, Any], policy: dict[str, str]) -> bool: @@ -1110,11 +1074,6 @@ def scan_public_boundary( ``hits`` retains rule locations; ``blocking_hits`` excludes ambiguous IP advisories. ``ok`` requires no blocker or unread input. It never certifies an unrequested file, generated text or future publication. - - Explicit ``public_fixture_allowances`` entries bind a relative path and - ``rule_ids`` to either a whole-file ``content_sha256`` or exact UTF-8 line - digests in ``line_sha256s`` (excluding line terminators). Line allowances - permit edits elsewhere, but never additions to the reviewed line itself. """ hits: list[str] = [] blocking_hits: list[str] = [] @@ -1238,37 +1197,8 @@ def public_files() -> Iterator[Path]: ] if not candidate_rules: continue - allowance = policy.get("public_fixture_allowances", {}).get( - path.relative_to(root).as_posix() if path.is_relative_to(root) else "" - ) - fixture_rules: set[str] = set() - fixture_line_hashes: set[str] = set() - if isinstance(allowance, dict): - reviewed_lines = allowance.get("line_sha256s") - if isinstance(reviewed_lines, list) and reviewed_lines and all( - isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) - for value in reviewed_lines - ): - fixture_line_hashes = set(reviewed_lines) - if ( - isinstance(allowance, dict) - and allowance.get("content_sha256") == hashlib.sha256(text.encode("utf-8")).hexdigest() - and isinstance(allowance.get("rule_ids"), list) - ): - fixture_rules = { - name for name in allowance["rule_ids"] if isinstance(name, str) and name in LEAK_RULES - } for line_no, line in enumerate(text.splitlines(), start=1): folded_line = _prefilter_fold(line) - # Exact reviewed lines survive unrelated edits; changed or extended - # lines still require review. Paths alone never exempt content. - line_fixture_rules = ( - allowance.get("rule_ids", []) - if fixture_line_hashes - and hashlib.sha256(line.encode("utf-8")).hexdigest() in fixture_line_hashes - and isinstance(allowance.get("rule_ids"), list) - else [] - ) for name, rule in candidate_rules: if not rule.is_candidate(folded_line): continue @@ -1277,21 +1207,9 @@ def public_files() -> Iterator[Path]: scan_line = _PUBLIC_LARK_DEVELOPER_CONSOLE_HOST.sub("", scan_line) if rule.pattern.search(scan_line): hit = f"{rel_or_abs(path, root)}:{line_no}: {name}" - if name in fixture_rules or name in line_fixture_rules: - allowed_hits.append(hit) - continue if name == "credential" and _credential_hits_are_all_references(line): credential_reference_hits.append(hit) continue - if ( - name == "credential" - and policy.get("public_credential_placeholders") - and _credential_hits_are_reviewed_placeholders( - line, policy["public_credential_placeholders"] - ) - ): - allowed_hits.append(hit) - continue if ( name == "private_doc_url" and policy.get("tracked_private_doc_urls") == "allow" diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index edce5bfb73..d576786b03 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -454,6 +454,7 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.handoff_mode_migrate", lazyHandler(() => import("./coordination/handoff_mode_runtime.ts"), ({migrateLocalHandoffMode}) => migrateLocalHandoffMode)], ["coordination.local_authority.todo_terminal", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({terminalLifecycleLocalCoordinationTodo}) => terminalLifecycleLocalCoordinationTodo)], ["turn.first_delivery.evaluate", lazyHandler(() => import("./turn_driver/first_delivery.ts"), ({evaluateFirstDelivery}) => evaluateFirstDelivery)], + ["turn.first_delivery.observe", lazyHandler(() => import("./turn_driver/first_delivery.ts"), ({observeFirstDelivery}) => observeFirstDelivery)], ["goal.checkpoint_read_context.work_binding", lazyHandler(() => import("./goals/checkpoint_read_context.ts"), ({executeResultWorkBinding}) => executeResultWorkBinding)], ["coordination.local_authority.todo_archive", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({archiveLocalCoordinationTodos}) => archiveLocalCoordinationTodos)], ["coordination.local_authority.todo_archive_ack", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({acknowledgeLocalCoordinationTodoArchive}) => acknowledgeLocalCoordinationTodoArchive)], diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 9eb7554c58..7964447288 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -42,21 +42,23 @@ function unknown(message: string): never { throw new EffectRuntimeRequestError(`${message}; read back the original Turn before retrying`, "checkpoint_commit_unknown"); } -function indexBytes(path: string): Buffer { +function checkpointIndex(path: string): {bytes: Buffer; rows: JsonObject[]} { let bytes: Buffer; try { bytes = readFileSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - return Buffer.alloc(0); + return {bytes: Buffer.alloc(0), rows: []}; } // The ordinary history reader tolerates damaged rows. A commit cannot infer // absence from that reader or append behind a torn (even JSON-valid) tail. if (bytes.length && bytes[bytes.length - 1] !== 10) unknown("checkpoint index has an incomplete tail"); const checkpoints = new Map(); + const rows: JsonObject[] = []; for (const line of bytes.toString("utf8").split(/\r?\n/)) { if (!line.trim()) continue; try { const row = requireJsonObject(JSON.parse(line), "run index row"); + rows.push(row); const checkpoint = jsonObject(row.vision_checkpoint); const effect = jsonObject(row.settlement_identity)?.effect_id; if ((row.result_work_context || checkpoint?.satisfied === true && checkpoint.read_context) && typeof effect === "string") { @@ -71,7 +73,14 @@ function indexBytes(path: string): Buffer { } catch { unknown("checkpoint index is malformed"); } } - return bytes; + return {bytes, rows}; +} + +function indexBytes(path: string): Buffer { return checkpointIndex(path).bytes; } + +/** One strict capture per observation; never a cache or an admission witness. */ +export function readCheckpointHistoryRows(root: string, goal: string): JsonObject[] { + return checkpointIndex(join(root, "goals", goalPathSegment(goal), "runs", "index.jsonl")).rows; } function committedArtifacts(prior: JsonObject, runsDir: string): JsonObject { @@ -159,11 +168,15 @@ function indexedReadProof(rows: JsonObject[], goal: string, name: string): {row: /** Observation only: recover bindings from verified indexed effects, never from * unreadable bytes. Pending accounting remains visible under the recovered identity. */ export function inspectCheckpointReadHistory(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint read history inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + return inspectCheckpointReadHistoryFromRows(request, readCheckpointHistoryRows(root, goalPathSegment(request.goal_id))); +} + +export function inspectCheckpointReadHistoryFromRows(value: unknown, rows: JsonObject[]): JsonObject { const request = requireJsonObject(value, "checkpoint read history inspection"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); const goal = goalPathSegment(request.goal_id); - const rows = indexBytes(join(root, "goals", goal, "runs", "index.jsonl")) - .toString("utf8").split(/\r?\n/).filter(line => line.trim()).map(line => JSON.parse(line) as JsonObject); if (!Array.isArray(request.receipt_names)) throw new EffectRuntimeRequestError("receipt_names must be an array"); const names = new Set(request.receipt_names); const contexts = join(root, "goals", goal, "checkpoint-contexts"); @@ -189,8 +202,8 @@ export function inspectCheckpointReadHistory(value: unknown): JsonObject { const proof = indexedReadProof(rows, goal, name); if (!proof) continue; try { - const result = inspectCheckpointAttempt({runtime_root: root, identity: proof.row.settlement_identity, - purpose: proof.purpose}); + const result = inspectCheckpointAttemptFromRows({runtime_root: root, identity: proof.row.settlement_identity, + purpose: proof.purpose}, rows); if (result.status === "committed") verified.push({receipt_name: name, identity: proof.row.settlement_identity, purpose: proof.purpose}); } catch (error) { @@ -203,12 +216,17 @@ export function inspectCheckpointReadHistory(value: unknown): JsonObject { /** Caller holds the index lock. Unindexed artifacts never prove a committed * direction. A completely absent attempt may be retried; torn writes stay held. */ export function inspectCheckpointAttempt(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint attempt inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const identity = requireJsonObject(request.identity, "settlement identity"); + return inspectCheckpointAttemptFromRows(request, readCheckpointHistoryRows(root, goalPathSegment(identity.goal_id))); +} + +export function inspectCheckpointAttemptFromRows(value: unknown, rows: JsonObject[]): JsonObject { const request = requireJsonObject(value, "checkpoint attempt inspection"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); const identity = requireJsonObject(request.identity, "settlement identity"); const runsDir = join(root, "goals", goalPathSegment(identity.goal_id), "runs"); - const rows = indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) - .filter(line => line.trim()).map(line => requireJsonObject(JSON.parse(line), "run index row")); const work = request.purpose === "delivery_result"; const matching = rows.filter(row => jsonObject(row.settlement_identity)?.effect_id === identity.effect_id && (work ? row.result_work_context != null : jsonObject(row.vision_checkpoint)?.satisfied === true && @@ -251,8 +269,8 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { if (proof) { // Reuse receipt-first artifact verification without scanning other reads. // A work proof alone cannot establish that a direction append committed. - const verified = inspectCheckpointAttempt({runtime_root: root, identity: proof.row.settlement_identity, - purpose: proof.purpose}); + const verified = inspectCheckpointAttemptFromRows({runtime_root: root, identity: proof.row.settlement_identity, + purpose: proof.purpose}, rows); if (verified.status === "committed") continue; } unknown("checkpoint read receipt is unavailable; preserve original Turn artifacts before retrying"); @@ -262,7 +280,7 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id) { const proof = indexedReadProof(rows, goalPathSegment(identity.goal_id), name); if (!proof || proof.purpose !== "first_delivery" || - inspectCheckpointAttempt({runtime_root: root, identity: owner, purpose: "first_delivery"}).status !== "committed") { + inspectCheckpointAttemptFromRows({runtime_root: root, identity: owner, purpose: "first_delivery"}, rows).status !== "committed") { unknown("another original Turn has an unresolved direction append for this Todo"); } } diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 480483c0fc..d5af603783 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -39,7 +39,8 @@ def _checkpoint_effect(method: str, request: dict[str, Any]) -> Any: # digest-bound same-UID snapshot transport; default effects stay bounded. return effect_runtime_result(method, request, large_local_snapshot=method not in ("goal.checkpoint_read_context.inspect_attempt", - "goal.checkpoint_read_context.inspect_history")) + "goal.checkpoint_read_context.inspect_history", + "turn.first_delivery.observe")) except EffectRuntimeRejected as error: raise CheckpointReadContextRejected({"ok": False, "error": str(error), "error_code": error.diagnostic_code, "reread_required": False}) from error @@ -525,79 +526,24 @@ def _delivery_observation_todos(root: Path, goal_id: str, registry_path: Path | def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | None = None, *, registry_path: Path | None = None) -> dict[str, Any] | None: - """Isolate historical observation faults; admission still uses strict reads. + """Read provider facts once; the typed owners observe all historical Turns. - An unreadable digest without verified indexed proof cannot be assigned to - an Agent. Keep that Goal-scoped uncertainty visible without replacing - another lane's normal next action; proved bindings retain pending accounting. + This remains an alias observation, as before. A Todo source path does not + grant exact GoalRef admission or authorize historical receipt repair. """ - identities = {} - errors = [] - verified_directions = set() - for path in (root / "goals" / goal_id / "checkpoint-contexts").glob("*.json"): - try: - receipt = _read_context_receipt(path) - except (ValueError, OSError): - errors.append({"scope": "goal", "code": "checkpoint_receipt_unavailable", "receipt": path.stem}) - continue - binding = receipt.get("identity", {}) - if receipt.get("purpose") not in ("first_delivery", "delivery_result"): - continue - observed_agent = binding.get("agent_id") if isinstance(binding, dict) else None - if isinstance(observed_agent, str) and observed_agent and agent_id is not None and observed_agent != agent_id: - continue - try: - identity = SettlementIdentity.from_runtime_payload(binding) - if (receipt.get("schema_version") != "checkpoint_read_context_v2" or identity.goal_id != goal_id - or path != _receipt_path(root, identity, receipt["purpose"])): - raise ValueError("checkpoint receipt binding mismatch") - except (ValueError, RuntimeError): - errors.append({"scope": "goal", "code": "checkpoint_receipt_invalid", "receipt": path.stem}) - continue - identities[identity.effect_id] = identity.as_dict() try: - recovered = _checkpoint_effect("goal.checkpoint_read_context.inspect_history", { - "runtime_root": str(root.resolve()), "goal_id": goal_id, "agent_id": agent_id, - "receipt_names": [error["receipt"] + ".json" for error in errors]}) - for item in recovered["verified"]: - identity = SettlementIdentity.from_runtime_payload(item["identity"]) - errors = [error for error in errors if error["receipt"] + ".json" != item["receipt_name"]] - if agent_id is None or identity.agent_id == agent_id: - identities[identity.effect_id] = identity.as_dict() - if item["purpose"] == "first_delivery": - verified_directions.add(identity.effect_id) + todos = [{key: todo.get(key) for key in ( + "todo_id", "status", "no_followup", "completion_continuation", + "completion_requested_continuation", "completion_turn_key")} + for todo in _delivery_observation_todos(root, goal_id, registry_path)] except (ValueError, OSError): - errors.append({"scope": "goal", "code": "checkpoint_history_unavailable"}) - # Share the complete current authority snapshot across observed identities. - # Do not cap the historical scan or silently lose older pending work. - canonical_todos = None - if identities: - try: - canonical_todos = _delivery_observation_todos(root, goal_id, registry_path) - except (ValueError, OSError): - errors.append({"scope": "goal", "code": "checkpoint_authority_unavailable"}) - for binding in reversed(list(identities.values())): - try: - identity = SettlementIdentity.from_runtime_payload(binding) - observation_unknown = any(error.get("receipt") in { - _receipt_path(root, identity, purpose).stem for purpose in ("first_delivery", "delivery_result") - } for error in errors) - readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=binding["agent_id"], - todo_id=binding.get("todo_id"), replan_obligation_id=binding.get("replan_obligation_id"), - turn_instance_id=binding["turn_instance_id"]) - if readback is None or canonical_todos is None: - raise ValueError("enrolled checkpoint readback unavailable") - progress = first_delivery_progress(root, readback, canonical_todos=canonical_todos, - observation_unknown=observation_unknown, verified_direction=identity.effect_id in verified_directions) - except (ValueError, OSError): - progress = effect_runtime_result("turn.first_delivery.evaluate", {"phase": "project", "unknown": True}) - if progress is not None and progress["stage"] != "settled": - return {**progress, "settlement_identity": binding, **({"observation_errors": errors} if errors else {})} - if errors: - return {**effect_runtime_result("turn.first_delivery.evaluate", { - "phase": "project", "observation_unavailable": True}), - "goal_id": goal_id, "agent_id": agent_id, "observation_errors": errors} - return None + todos = None + return _checkpoint_effect("turn.first_delivery.observe", { + "runtime_root": str(root.resolve()), "goal_id": goal_id, + "agent_id": agent_id, "todos": todos, + # Keep the caller's historical scan order across Python/Node platforms. + "receipt_names": [path.name for path in (root / "goals" / goal_id / "checkpoint-contexts").glob("*.json")], + }) def delivery_result_context_input( diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index b6f39629ca..5676f81080 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -314,6 +314,9 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { } /** Work input and validation share the existing read-receipt identity owner. */ +// Existing Host result vocabulary, local to direction transition/reentry rules. +const temporaryDirectionResults = new Set(["wait", "user_action_required", "iteration_failed"]); + export function evaluateResultWorkBinding(value: unknown): JsonObject { const request = requireJsonObject(value, "result work binding"); if (request.phase === "completion_identity") { @@ -387,7 +390,7 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { const stop = jsonObject(value); return !stop || !Number.isInteger(stop.attempt) || Number(stop.attempt) < 1 || Number(stop.attempt) > Number(receipt.direction_host_attempt_count) || - !["wait", "user_action_required", "iteration_failed"].includes(String(stop.result_kind)) || + !temporaryDirectionResults.has(String(stop.result_kind)) || Object.hasOwn(stop, "stop_reason"); })) return rejected("checkpoint_direction_stopped"); } @@ -416,6 +419,28 @@ export function evaluateResultWorkBinding(value: unknown): JsonObject { jsonObject(jsonObject(current.interaction_contract)?.agent_channel)?.delivery_allowed === true; return eligible && (sameRecovery || sameWork) ? {ok: true} : rejected("checkpoint_direction_admission_rejected"); } + if (request.phase === "direction_return") { + const receipt = {...requireJsonObject(request.receipt, "direction receipt")}; + const decision = requireJsonObject(request.decision, "direction decision"); + const attempt = receipt.direction_host_attempt_count; + if (!Number.isInteger(attempt) || Number(attempt) < 1) return rejected("checkpoint_direction_attempt_invalid"); + if (temporaryDirectionResults.has(String(decision.result_kind))) { + delete receipt.direction_host_failure; + receipt.direction_stopped = true; + receipt.direction_stops = [...(Array.isArray(receipt.direction_stops) ? receipt.direction_stops : []), { + attempt, result_kind: decision.result_kind, + ...(decision.stop_reason ? {stop_reason: decision.stop_reason} : {}), + }]; + return {ok: true, stopped: true, receipt}; + } + const bound = evaluateResultWorkBinding({...request, phase: "direction_response"}); + if (bound.ok !== true) return bound; + receipt.current_agent_decision = decision; + receipt.current_agent_binding = bound.binding; + delete receipt.direction_host_failure; + delete receipt.direction_stopped; + return {ok: true, stopped: false, receipt}; + } if (request.phase === "direction_response") { const receipt = requireJsonObject(request.receipt, "direction receipt"); const decision = requireJsonObject(request.decision, "direction decision"); diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index 615f920a74..26bb321b58 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -24,7 +24,9 @@ "loopx_host_todo_completion_reduction_v0" ) _RUNTIME_METHOD = "turn.host_todo_completion.evaluate" -HOST_ADAPTER_SETTLEMENT_STEP_KINDS = ( +# Preserve the registered Python owner and ordinary step prefix. Context-read +# plans use their own checked sequences in _decode_provider_steps. +HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE = ( "guard", "lifecycle_completion", "durable_writeback", @@ -348,7 +350,7 @@ def _decode_provider_steps(value: Any) -> tuple[_ProviderStep, ...]: if len(raw_steps) == 2 else ("guard", "lifecycle_completion", "direction_context") if len(raw_steps) == 3 - else HOST_ADAPTER_SETTLEMENT_STEP_KINDS[: len(raw_steps)] + else HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE[: len(raw_steps)] ) steps: list[_ProviderStep] = [] for index, raw in enumerate(raw_steps): diff --git a/loopx/control_plane/turn_driver/checkpoint_continuation.py b/loopx/control_plane/turn_driver/checkpoint_continuation.py index bb32811ff8..944b53ed5a 100644 --- a/loopx/control_plane/turn_driver/checkpoint_continuation.py +++ b/loopx/control_plane/turn_driver/checkpoint_continuation.py @@ -94,24 +94,14 @@ def continue_direction(self, result: Mapping[str, Any], *, purpose: str = 'first attempt=dispatch['attempt']) self._save(path, receipt) raise ValueError(error.reason) from error - if decision.get('result_kind') in {'wait', 'user_action_required', 'iteration_failed'}: - # A valid Host response supersedes the prior transport - # failure; retain the total attempt count for future failures. - receipt.pop('direction_host_failure', None) - receipt['direction_stopped'] = True - receipt['direction_stops'] = [*receipt.get('direction_stops', []), { - 'attempt': dispatch['attempt'], 'result_kind': decision['result_kind'], - **({'stop_reason': decision['stop_reason']} if decision.get('stop_reason') else {})}] - self._save(path, receipt) - raise ValueError('Current Agent stopped direction continuation; result retained') bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { - 'phase': 'direction_response', 'identity': self.identity.as_dict(), 'receipt': receipt, 'decision': decision}) + 'phase': 'direction_return', 'identity': self.identity.as_dict(), 'receipt': receipt, 'decision': decision}) if bound.get('ok') is not True: raise ValueError(bound.get('error', 'Direction response binding rejected')) - receipt = {**receipt, 'current_agent_decision': decision, 'current_agent_binding': bound['binding']} - receipt.pop('direction_host_failure', None) - receipt.pop('direction_stopped', None) + receipt = bound['receipt'] self._save(path, receipt) + if bound['stopped']: + raise ValueError('Current Agent stopped direction continuation; result retained') else: bound = _checkpoint_effect('goal.checkpoint_read_context.work_binding', { 'phase': 'direction_response', 'identity': self.identity.as_dict(), 'receipt': receipt, 'decision': decision}) diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts index 4df9c6efb6..f85158f56b 100644 --- a/loopx/control_plane/turn_driver/first_delivery.ts +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -131,7 +131,10 @@ export function evaluateFirstDelivery(value: unknown): JsonObject { } const checkpoint = jsonObject(jsonObject(request.writeback_run)?.vision_checkpoint); const completion = jsonObject(request.completion_event); - const resultCommitted = request.result_committed === true || + const completionTodo = jsonObject(request.completion_todo); + const identity = jsonObject(request.identity); + const resultCommitted = request.result_committed === true || (completionTodo?.status === "done" && identity !== null && + [identity.effect_id, identity.turn_instance_id].includes(completionTodo.completion_turn_key)) || ["completed", "terminal_no_followup", "receipt_repaired"].includes(String(completion?.status)); const outcomeRetained = checkpoint?.required === true && checkpoint.satisfied === false; const direction = jsonObject(request.direction_receipt); @@ -139,8 +142,6 @@ export function evaluateFirstDelivery(value: unknown): JsonObject { const directionCommitted = checkpoint?.satisfied === true && (jsonObject(checkpoint.read_context)?.purpose === "first_delivery" || (request.checkpoint_continuation === true && checkpoint.read_context != null)); - const completionTodo = jsonObject(request.completion_todo); - const identity = jsonObject(request.identity); const terminalRequired = completionTodo !== null && identity !== null && originalTurnRequestsNoFollowup(completionTodo, identity); const settlementComplete = request.settlement_complete === true && diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 0b02ff8afe..3799cb125b 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -11,16 +11,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Any - -try: # pydantic ships with the FastMCP extra; base installs have no pydantic. - from pydantic import Strict -except ImportError: # pragma: no cover - exercised on base installs only - - class Strict: # type: ignore[no-redef] - """Inert stand-in so the shared annotation still evaluates without - pydantic; FastMCP cannot run on such an install anyway.""" - +from typing import Any from .control_plane.host_adapter_settlement import ( HostTodoSettlementRequest, diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index dce10ee120..521d6504cb 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -540,7 +540,7 @@ "tier": "cross_runtime", "status": "canonical", "owners": { - "python": "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_KINDS", + "python": "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE", "typescript": "loopx/control_plane/turn_driver/host_todo_completion.ts::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" }, "values": [ diff --git a/tests/control_plane/test_checkpoint_history_isolation.py b/tests/control_plane/test_checkpoint_history_isolation.py index f18dd510fa..dedbe3c411 100644 --- a/tests/control_plane/test_checkpoint_history_isolation.py +++ b/tests/control_plane/test_checkpoint_history_isolation.py @@ -174,7 +174,17 @@ def stop_before_spend(args, **kwargs): path.unlink() else: path.write_text("{", encoding="utf-8") + from loopx.control_plane.goals import checkpoint_context_io as io + calls = [] + effect = io._checkpoint_effect + + def observe(method, request): + calls.append(method) + return effect(method, request) + + monkeypatch.setattr(io, "_checkpoint_effect", observe) progress = pending_first_delivery_progress(runtime, GOAL_ID, AGENT_ID, registry_path=registry) + assert calls == ["turn.first_delivery.observe"] # No per-Turn bridge or repair. assert progress["stage"] == "settlement_pending", progress assert progress["settlement_identity"] == identity.as_dict() assert progress["result_committed"] is True and progress["quota_spent"] is False diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index d01a07aca7..b3a139ae3c 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -140,6 +140,33 @@ def test_successful_result_replay_precedes_later_goal_source_loss(provider, sour assert _spend_run_count(runtime) == 0 +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_historical_admission_before_first_read_survives_cold_upgrade(provider, tmp_path, monkeypatch): + from loopx.control_plane.quota.heartbeat_receipt import ensure_turn_heartbeat_settlement_receipt + from loopx.control_plane.quota.settlement import SettlementIdentity + + project, runtime, registry, _ = _default_fixture(provider, tmp_path, monkeypatch) + # Seed an actual pre-default admission before any work/read receipt exists. + # No current admission is rewritten or downgraded to construct this case. + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + ensure_turn_heartbeat_settlement_receipt(runtime, identity, + semantic_replan_guard_scoped=True, semantic_replan_obligation_id=None) + contexts = runtime / "goals" / GOAL_ID / "checkpoint-contexts" + assert not list(contexts.glob("*.json")) + log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + original = log.read_bytes() + for _ in range(2): # Each CLI invocation starts a fresh Python caller. + rc, replay = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", TURN_ID, cwd=project) + assert rc == 0, replay + assert replay["heartbeat_receipt"]["settlement_identity"] == identity.as_dict() + assert "delivery_result_context" not in replay + assert not list(contexts.glob("*.json")) + assert log.read_bytes() == original + assert _spend_run_count(runtime) == 0 + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_already_bound_historical_receipt_retains_original_policy(provider, tmp_path, monkeypatch): from tests.control_plane.test_quota_settlement_cli import _historical_turn diff --git a/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py b/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py index bebc181794..50a2897c11 100644 --- a/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py +++ b/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py @@ -9,7 +9,7 @@ import pytest from loopx.control_plane.host_adapter_settlement import ( - HOST_ADAPTER_SETTLEMENT_STEP_KINDS, + HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE, _decode_provider_steps, ) @@ -32,7 +32,7 @@ def _typescript_step_kinds() -> tuple[str, ...]: def test_both_runtimes_carry_the_same_step_kinds() -> None: """The plan the TypeScript owner writes must satisfy the Python transport.""" - assert _typescript_step_kinds() == tuple(HOST_ADAPTER_SETTLEMENT_STEP_KINDS) + assert _typescript_step_kinds() == tuple(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) def test_the_registry_entry_names_both_owners_and_the_same_values() -> None: @@ -40,12 +40,12 @@ def test_the_registry_entry_names_both_owners_and_the_same_values() -> None: registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) entry = registry["vocabularies"][VOCABULARY] assert entry["owners"]["python"] == ( - "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" + "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE" ) assert entry["owners"]["typescript"] == ( "loopx/control_plane/turn_driver/host_todo_completion.ts::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" ) - assert entry["values"] == list(HOST_ADAPTER_SETTLEMENT_STEP_KINDS) + assert entry["values"] == list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) def _plan(step_kinds: list[str]) -> dict: diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index ab5780a9a3..ccb47cd47f 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -203,6 +203,15 @@ test("default direction binds the post-result input and response without relabel const decision = {read_context_id: "read-A", vision_unchanged_reason: "Current obligations preserve the route."}; const request = {phase: "direction_response", identity, receipt: direction.receipt, decision}; assert.equal(work(request).ok, true); + const returned = work({...request, phase: "direction_return", receipt: {...direction.receipt as JsonObject, + direction_host_attempt_count: 4, direction_stopped: true, direction_host_failure: {kind: "executor_timeout"}}}); + assert.equal(returned.stopped, false); + assert.deepEqual((returned.receipt as JsonObject).current_agent_decision, decision); + assert.equal((returned.receipt as JsonObject).direction_host_attempt_count, 4); + assert.equal((returned.receipt as JsonObject).direction_stopped, undefined); + assert.equal((returned.receipt as JsonObject).direction_host_failure, undefined); + assert.equal(work({...request, phase: "direction_return", receipt: {...direction.receipt as JsonObject, + direction_host_attempt_count: 4}, decision: {...decision, read_context_id: "foreign"}}).ok, false); assert.equal(work({...request, decision: {...decision, read_context_id: "read-B"}}).error_code, "checkpoint_direction_binding_mismatch"); assert.equal(work({...request, identity: {...identity, agent_id: "another"}}).error_code, @@ -238,6 +247,24 @@ test("only explicit recovery rearms a known temporary direction stop without res assert.equal(work({...request, receipt: {...receipt, direction_host_failure: exhausted}}).ok, false); }); +test("valid Host stops supersede transport failure while keeping original attempts and stop reasons", () => { + for (const result_kind of ["wait", "user_action_required", "iteration_failed"]) { + const prior = {direction_host_attempt_count: 3, direction_host_failure: {kind: "executor_timeout"}, + direction_stops: [{attempt: 1, result_kind: "wait"}]}; + for (const decision of [{result_kind}, {result_kind, stop_reason: "host_stop"}]) { + const returned = work({phase: "direction_return", receipt: prior, decision}); + assert.equal(returned.stopped, true); + const receipt = returned.receipt as JsonObject; + assert.equal(receipt.direction_stopped, true); + assert.equal(receipt.direction_host_failure, undefined); + assert.equal(receipt.direction_host_attempt_count, 3); + assert.deepEqual((receipt.direction_stops as JsonObject[])[1], {attempt: 3, ...decision}); + assert.equal(work({phase: "direction_dispatch", receipt, explicit_resume: true}).ok, !("stop_reason" in decision)); + assert.equal(prior.direction_stops.length, 1, "the raw prior receipt is not mutated"); + } + } +}); + test("direction launch reuses current quota gates and exact original recovery identity", () => { const current = {ok: true, should_run: true, status_health_ok: true, goal_id: identity.goal_id, requires_user_action: false, blocked_action_scope: null, quota: {state: "eligible"}, diff --git a/tests/test_contract_public_fixture_allowances.py b/tests/test_contract_public_fixture_allowances.py deleted file mode 100644 index eb242d2cb5..0000000000 --- a/tests/test_contract_public_fixture_allowances.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -import hashlib -from pathlib import Path - -import pytest - -from loopx.contract import scan_public_boundary - -PASSWORD_ASSIGN = "pass" + "word=" -PRIVATE_ADDRESS = "192." + "168.3.4" - - -def test_reviewed_public_fixture_is_bound_to_content_path_and_rule( - tmp_path: Path, -) -> None: - fixture = tmp_path / "fixture.py" - original = ( - 'header = "Author' - + 'ization: Basic QWxhZGRpbjpvcGVu"\naddress = "' - + PRIVATE_ADDRESS - + '"\n' - ) - fixture.write_text(original, encoding="utf-8") - allowance = { - "content_sha256": hashlib.sha256(original.encode()).hexdigest(), - "rule_ids": ["credential", "private_ip"], - } - registry = { - "public_boundary": {"public_fixture_allowances": {"fixture.py": allowance}} - } - - # Existing private content stays blocked unless the owner explicitly binds it. - assert len(scan_public_boundary([tmp_path])["hits"]) == 2 - reviewed = scan_public_boundary([tmp_path], registry=registry) - assert reviewed["ok"] is True - assert reviewed["files"] == 1 - assert len(reviewed["allowed_hits"]) == 2 - - allowance["rule_ids"] = ["private_ip"] - scoped = scan_public_boundary([tmp_path], registry=registry) - assert scoped["hits"] == ["fixture.py:1: credential"] - - allowance["rule_ids"] = ["credential", "private_ip"] - copy = tmp_path / "other.py" - copy.write_text(original, encoding="utf-8") - assert len(scan_public_boundary([tmp_path], registry=registry)["hits"]) == 2 - copy.unlink() - - fixture.write_text(original + PASSWORD_ASSIGN + '"hunter2"\n', encoding="utf-8") - changed = scan_public_boundary([tmp_path], registry=registry) - assert len(changed["hits"]) == 3 - assert changed["allowed_hits"] == [] - - -@pytest.mark.parametrize( - "allowance", - [ - None, - "fixture.py", - {}, - {"content_sha256": "bad"}, - {"content_sha256": "bad", "rule_ids": "credential"}, - ], -) -def test_malformed_fixture_allowance_grants_nothing( - tmp_path: Path, allowance: object -) -> None: - (tmp_path / "fixture.py").write_text( - PASSWORD_ASSIGN + '"hunter2"\n', encoding="utf-8" - ) - registry = { - "public_boundary": {"public_fixture_allowances": {"fixture.py": allowance}} - } - - result = scan_public_boundary([tmp_path], registry=registry) - - assert result["hits"] == ["fixture.py:1: credential"] - assert result["allowed_hits"] == [] - - -TOKEN_ASSIGN = "tok" + "en=" - - -@pytest.mark.parametrize("value", ["secret", '"secret"', "'secret'", "secret&other=value"]) -def test_explicit_placeholder_survives_new_and_edited_public_files(tmp_path: Path, value: str) -> None: - registry = {"public_boundary": {"public_credential_placeholders": ["secret"]}} - fixture = tmp_path / "new.py" - fixture.write_text(TOKEN_ASSIGN + value + "\n", encoding="utf-8") - assert scan_public_boundary([tmp_path])["hits"] == ["new.py:1: credential"] - result = scan_public_boundary([tmp_path], registry=registry) - assert result["ok"] and result["allowed_hits"] == ["new.py:1: credential"] - # Editing a file requires no growing list of content-hash exemptions. - fixture.write_text("# edited\n" + TOKEN_ASSIGN + value + "\n", encoding="utf-8") - assert scan_public_boundary([tmp_path], registry=registry)["ok"] - - -@pytest.mark.parametrize("value", [ - "secret-real", "secret123", "Secret", "secret/real", "secret%32", "secret+real", - '"secret real"', '"secret&other"', '"secret\\nreal"', '"secret" + payload', - '"secret".suffix', '"secret"payload', "hunter2", - "secret&" + TOKEN_ASSIGN + "real-value", - "secret " + PASSWORD_ASSIGN + "hunter2", - "secret&key=AK" + "IA1234567890ABCDEF", -]) -def test_placeholder_never_grants_prefix_or_mixed_credential_permission(tmp_path: Path, value: str) -> None: - registry = {"public_boundary": {"public_credential_placeholders": ["secret"]}} - (tmp_path / "new.py").write_text(TOKEN_ASSIGN + value + "\n", encoding="utf-8") - result = scan_public_boundary([tmp_path], registry=registry) - assert result["hits"] == ["new.py:1: credential"] - assert result["allowed_hits"] == [] - - -@pytest.mark.parametrize("policy", [None, "secret", {}, [], [None], ["secret", None], ["*"]]) -def test_missing_or_malformed_placeholder_policy_preserves_default(tmp_path: Path, policy: object) -> None: - (tmp_path / "fixture.py").write_text(TOKEN_ASSIGN + "secret\n", encoding="utf-8") - baseline = scan_public_boundary([tmp_path]) - result = scan_public_boundary([tmp_path], registry={ - "public_boundary": {"public_credential_placeholders": policy}, - }) - assert result == baseline - - -def test_placeholder_does_not_exempt_unrelated_findings_or_files(tmp_path: Path) -> None: - registry = {"public_boundary": {"public_credential_placeholders": ["secret"]}} - (tmp_path / "new.py").write_text(TOKEN_ASSIGN + "secret\n" + PRIVATE_ADDRESS + "\n", encoding="utf-8") - (tmp_path / "other.md").write_text(PASSWORD_ASSIGN + "hunter2\n", encoding="utf-8") - result = scan_public_boundary([tmp_path], registry=registry) - assert result["files"] == result["scanned_files"] == 2 - assert result["hits"] == ["new.py:2: private_ip", "other.md:1: credential"] - - -def test_reviewed_line_survives_unrelated_edits_without_exempting_new_values(tmp_path: Path) -> None: - line = 'address = "' + PRIVATE_ADDRESS + '"' - fixture = tmp_path / "fixture.py" - fixture.write_text(line + "\n", encoding="utf-8") - allowance = { - "line_sha256s": [hashlib.sha256(line.encode()).hexdigest()], - "rule_ids": ["private_ip"], - } - registry = {"public_boundary": {"public_fixture_allowances": {"fixture.py": allowance}}} - assert scan_public_boundary([tmp_path])["hits"] == ["fixture.py:1: private_ip"] - fixture.write_text("# unrelated edit\n" + line + "\nassert True\n", encoding="utf-8") - result = scan_public_boundary([tmp_path], registry=registry) - assert result["ok"] and result["allowed_hits"] == ["fixture.py:2: private_ip"] - # A line copied to an unreviewed path has no allowance. - (tmp_path / "other.py").write_text(line + "\n", encoding="utf-8") - assert scan_public_boundary([tmp_path], registry=registry)["hits"] == ["other.py:1: private_ip"] - (tmp_path / "other.py").unlink() - # New lines, changed values and appended credentials stay blocked. - fixture.write_text(line + "\n" + PRIVATE_ADDRESS.replace("3.4", "3.5") + "\n", encoding="utf-8") - assert scan_public_boundary([tmp_path], registry=registry)["hits"] == ["fixture.py:2: private_ip"] - fixture.write_text(line + " " + PASSWORD_ASSIGN + '"hunter2"\n', encoding="utf-8") - result = scan_public_boundary([tmp_path], registry=registry) - assert len(result["hits"]) == 2 and result["allowed_hits"] == [] - allowance["rule_ids"] = ["credential"] - fixture.write_text(line + "\n", encoding="utf-8") - assert scan_public_boundary([tmp_path], registry=registry)["hits"] == ["fixture.py:1: private_ip"] - - -@pytest.mark.parametrize("hashes", [None, "*", [], [None], ["bad"], ["0" * 64, None]]) -def test_invalid_reviewed_line_digests_preserve_default(tmp_path: Path, hashes: object) -> None: - (tmp_path / "fixture.py").write_text(PRIVATE_ADDRESS + "\n", encoding="utf-8") - registry = {"public_boundary": {"public_fixture_allowances": {"fixture.py": { - "line_sha256s": hashes, "rule_ids": ["private_ip"], - }}}} - result = scan_public_boundary([tmp_path], registry=registry) - assert result["hits"] == ["fixture.py:1: private_ip"] - assert result["allowed_hits"] == [] From 77f597e902821ef9e76a2c649ee5c2a2704c304f Mon Sep 17 00:00:00 2001 From: Tartar Date: Thu, 29 Oct 2026 23:54:21 +0800 Subject: [PATCH 62/80] docs(freshness): retain recovery contracts and synthetic evidence Signed-off-by: Tartar --- .../checkpoint-recovery-settled-narrow.png | Bin 0 -> 37432 bytes ...heckpoint-recovery-unavailable-desktop.png | Bin 0 -> 82447 bytes ...checkpoint-recovery-unavailable-narrow.png | Bin 0 -> 40345 bytes .../goal-vision-replan-contract-v0.md | 47 ++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 docs/assets/goal-direction/checkpoint-recovery-settled-narrow.png create mode 100644 docs/assets/goal-direction/checkpoint-recovery-unavailable-desktop.png create mode 100644 docs/assets/goal-direction/checkpoint-recovery-unavailable-narrow.png diff --git a/docs/assets/goal-direction/checkpoint-recovery-settled-narrow.png b/docs/assets/goal-direction/checkpoint-recovery-settled-narrow.png new file mode 100644 index 0000000000000000000000000000000000000000..f13ad4f9f481eb9e3b2516716060038632bb1a52 GIT binary patch literal 37432 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

DeA_tXlv5yUG~(abbWh63!3BA|K-PsI<|;A_v=4 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

{v+ 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 F{1-xZsM7!d literal 0 HcmV?d00001 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 9c4e3b8001..f5ef3c5d64 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -2421,3 +2421,50 @@ Todo 验证和全 Goal 终态检查仍各自执行。 MCP 显式重读保留候选时,先恢复已准入的原 Turn,再执行 guard;缺失的原准入 不能通过新建另一工作身份补齐。 + +### Original facts, derived progress and recovery owners + +```text +Host return / read basis / original intent / attempt / effect receipts + | + existing typed domain owners + checkpoint Todo completion settlement + | + derived progress observation + / \ + status / Chat current recovery admission + | + Host IO + original Turn settlement + | + subsequent work selection +``` + +Observation is read-only. It reuses one Todo fact projection, one settlement +snapshot and one strictly checked checkpoint index per batch; it neither repairs +read receipts nor authorizes a direction retry. The strict checkpoint parser +still rejects incomplete tails and competing proofs. A disagreement between its +index capture and the settlement capture stays uncertain. Historical scans are +complete, including damaged/deleted read receipts with matching indexed artifacts; +the output remains one pending original Turn or scoped unavailable evidence. + +| Existing contract | Producer and consumer | Retained responsibility | +| --- | --- | --- | +| Host transaction v0/v1 | `host_adapter_settlement.py` → `host_todo_completion.ts` | Ordinary completion and Vision/context supplements remain active callers. | +| Host transaction v2 | Explicit MCP first delivery **and** default `current_agent_checkpoint` → the same typed owner | Wire `first_delivery=true` does not imply an opt-in-only feature. Preserve their different terminal rules and original admission policy. | +| `delivery_result` / `first_delivery` / supplement reads | Checkpoint capture/commit and historical inspectors | Original work evidence, post-result direction evidence and supplemental authoring have separate identities/purposes. | +| Completion identity and requested no-followup | Todo completion → `completion_state.ts` and settlement | Current display state cannot reconstruct or replace the original terminal intent. | +| Direction returns, stops and attempts | Python Host transport → `checkpoint_read_context.ts` → locked Python receipt IO | One typed owner classifies returns and produces the receipt transition. Actual Stop reasons, response bindings and accumulated attempts survive retries. | +| Progress stage and next action | `first_delivery.ts` → status/quota/Chat | Derive from receipts and Todo facts; do not persist another pending-state authority. | + +Decision capture, work read evidence and direction read evidence refer to the +same original work without becoming interchangeable. Reuse the existing capture +lineage; proposals for unified capture or receipt-to-continuation composition +must not be assumed to be shipped APIs. Original-Turn recovery remains separate +from choosing subsequent work. No v0/v1/v2 producer is retired merely because its +historical reader remains available. + +中文:Host 与持久化层提供原身份、读取依据、完成意图、attempt 和实际回执;既有 +typed owner 解释规则,展示层派生进度,恢复入口重新核验当前权限、Stop 和预算后 +执行。批量观察只减少重复读源与跨语言调用,不新增持久化状态或执行授权。保留不同 +purpose、旧准入政策和真实协议消费者;不能把结果已保留、方向已提交、扣额已完成 +或 Goal 已验收互相替代。 From 515c64bde60b2f77bae22cf9a360fd3b24775773 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 00:30:02 +0800 Subject: [PATCH 63/80] test: calibrate native admission output budgets Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 38 +++++++++++++++++++ .../testing/cli_output_budget.py | 11 ++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 01013c691e..cdbd47be78 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1332,6 +1332,44 @@ model compliance, runtime recovery or lower cost. 4,000 字符宿主硬边界、既有 JSON/正文夹具上限及执行权限不变;不据此声称节省 输出、恢复运行或改善模型行为。 +The native first-admission qualification at `ff1c8a947` uses the same public +fixture populations, interpreter, Node version, physical path layout and shared +probe on target main `8caaf678e` and head. The three quota JSON outputs measure +23,287→28,057 / 35,553→42,997 / 25,562→27,694 characters (small / crowded / +multi-agent); a separate focused pytest small case measured 28,343. Preserve +the original 25,000 / 42,000 / 26,500 ceiling failures. The added carriers bind +the original Turn and work read, expose replan choices when selection is owed, +and report the existing admission recall result. For the small case, compact +field values add 857 characters for the heartbeat receipt, 2,052 for the work +context and 1,745 for recall; the observed whole-output delta is 4,770, including +field names, punctuation and other changed projections. These field costs use +compact JSON, while the shared probe's standalone carrier metrics include +normal JSON separators; do not mix their sums. + +Retain the complete decision clauses and identity/source facts. Calibrate only +these synthetic JSON ceilings to 29,000 / 44,000 / 28,500, leaving 657 / 1,003 / +806 characters above the largest observed case for each scenario. Markdown, +line, per-Todo/fixed-growth, differential migration and production transport +limits stay unchanged. The measurement-only 96-row comparison has no growth +failures and 12 review signals; it does not certify absolute budgets or replace +the enforced candidate tests. Both revisions also exceed the thin heartbeat +fixture's existing 4,600-character ceiling by seven characters in this layout; +that separate baseline gap remains open. One sequential full-probe pair took +130.9/291.5 seconds, and the focused quota pair took 2.45/6.57, 1.67/4.98 and +2.08/4.92 seconds. These are observed added costs, not a sustained latency or +model-token qualification; no output saving is claimed. + +原生首次准入在同一公开负载与路径布局上的 quota JSON 为 small +23,287→28,057、crowded 35,553→42,997、multi-agent 25,562→27,694 字符; +另一次聚焦 pytest 的 small 为28,343。保留原25,000 / 42,000 / 26,500超限 +失败,仅将这三个合成 JSON 回归预算校准为29,000 / 44,000 / 28,500。 +新增内容承载原 Turn、原工作读依据、必要的重规划选择与已有 recall 结果; +不删除决策条款,不改变 Markdown、行数、增长门、生产传输或执行权限。 +96行测量差分无增长失败、12项待复审,但不等于绝对预算已通过。两个版本在此 +布局的 thin heartbeat 均为4,607>4,600,该独立基线缺口继续保留。 +整批130.9/291.5秒及三个聚焦场景的新增耗时均保留,不据此宣称节省 token、 +持续性能达标或模型行为改善。 + The fixed visible-Goal host fixture's 3,200-character regression guard predates `1d5d2d0f6`, which adds the known Todo choice to the first current guard. On the same four host inputs at `b201da8ba`, substituting only the pre-change dispatch diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 9866a68332..4568bf8a07 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -143,9 +143,14 @@ class CliOutputCommandClassification: semantic_json_keys=("interaction_contract", "scheduler_hint", "selected_todo"), markdown_anchor="# LoopX Quota Should Run", max_chars={ + # Native first admission at ff1c8a947 preserves the original Turn, + # read basis and recall result. Matched main/head measurements are + # 23,287/28,057, 35,553/42,997 and 25,562/27,694 chars; the focused + # pytest small case also measured 28,343. Only these synthetic JSON + # ceilings change; original failures and line/growth guards remain. # The same explicitly imported fixture on base/head emits 24,099 # JSON chars. Keep canonical provenance and complete User context. - "small": {"json": 25_000, "markdown": 10_000}, + "small": {"json": 29_000, "markdown": 10_000}, # Same 36-Todo / 12-run source: 38,432 -> 39,101 chars with # objective/evidence guidance; small and multi-agent paths unchanged. # Per-Todo and fixed semantic growth are accounted for separately. @@ -154,14 +159,14 @@ class CliOutputCommandClassification: # Native Windows paired base/head both emit 40,701 JSON chars. # Retain complete routes/context and bounded host-path headroom; # line, per-Todo/fixed growth and all other ceilings stay unchanged. - "crowded": {"json": 42_000, "markdown": 10_500}, + "crowded": {"json": 44_000, "markdown": 10_500}, # The unchanged 18-Todo / 3-Agent fixture on main ffc90182b and # this candidate both emit 25,836 JSON characters (96 matched # surface/mode measurements, no semantic or route differences). # Preserve complete decision facts; calibrate only this synthetic # presentation ceiling. The historical 25,500 failure remains; # line/growth guards and production transport limits are unchanged. - "multi_agent": {"json": 26_500, "markdown": 7_000}, + "multi_agent": {"json": 28_500, "markdown": 7_000}, }, max_lines={ "small": {"json": 600, "markdown": 190}, From f05f75702aba51e4cae530e8a79e12e0533e23d9 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 01:10:46 +0800 Subject: [PATCH 64/80] fix(heartbeat): retain admission failure and peer guidance Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 51 +++++++++++++++++++ loopx/control_plane/heartbeat/agent.py | 8 ++- loopx/control_plane/heartbeat/builder.py | 4 +- loopx/control_plane/heartbeat/task_body.py | 1 + .../testing/cli_output_budget.py | 14 +++-- .../test_heartbeat_agent_input.py | 2 +- 6 files changed, 67 insertions(+), 13 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index cdbd47be78..4c29c2f3c3 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1332,6 +1332,57 @@ model compliance, runtime recovery or lower cost. 4,000 字符宿主硬边界、既有 JSON/正文夹具上限及执行权限不变;不据此声称节省 输出、恢复运行或改善模型行为。 +Earlier pre-integration PR measurements (the source revisions below retain their own limits and failures): + +The thin heartbeat JSON failure carried by `8caaf678e` also reproduces after +integrating main `c38b3a1a6`: 4,607 characters against the 4,600 fixture ceiling. +At rebased head `9bea38cd4`, the ordinary thin dispatch rule matches that main. On the same public +fixture, substituting only `HOST_LOOP_QUOTA_DISPATCH_RULE` from the immutable +parent of `1d5d2d0f6` emits 4,544 characters. The complete current rule emits +4,607, and replacing that rule in the current body produces the entire old +body exactly: the sole 63-character change teaches a known Todo choice before +the first current guard. This resolves the earlier unproven layout attribution; +it is authored guidance, not unexplained path growth. The original pytest +isolation namespace adds 51 emitted path characters to the default output +(4,658); its supported 320-character scope adds another 289 (4,947). Capture +those unmodified outputs rather than truncating scope or normalizing raw stdout. +The initial 4,650 attempt failed both original tests and remains a failure. +The broader agent-input suite exposed missing failed-preflight/guard guidance +in ordinary thin and incomplete compact/brief peer guidance. Restore main's +complete peer rule and explicit claim command in compact/brief. For ordinary +thin, state no work/spend after failure, repair within authority and retry the +original Turn. This adds 89 body characters, 90 in JSON: the complete supported +fixture emits 5,037. Set its three JSON ceilings to 5,100 (63 headroom), retaining +all earlier failures. A second CLI fixture with the same 320-character scope +and three observed capabilities measures 4,485 -> 4,574 normalized characters; +raise the generic thin presentation budget 4,500 -> 4,600 (26 headroom) rather +than deleting those obligations. Align both exact metadata assertions with the +owner, including the full-scope test's stale 4,200 expectation from before +main's `7a2421ec89`. Keep full scope, within-budget and all independent authority, +outcome-update and root-continuation assertions. Markdown also measures +6,136 -> 6,225 with this 89-character clause: retain the 6,200 failure and set +the three thin Markdown ceilings to 6,300 (75 headroom). Line, growth and +native transport checks remain independent; the native Goal host limit stays +4,000. These measured adjustments do not prove model adoption or premerge. + +thin heartbeat 的4,607>4,600在整合 main `c38b3a1a6` 后仍复现;其生产源码 +与该main相同。同一公开夹具仅换成 `1d5d2d0f6` 父版本的 dispatch 指引, +输出为4,544;当前为4,607,正文除这63字符外逐字符一致。新增内容用于首次 +guard前传入已知Todo选择。pytest隔离目录另增加51个原始JSON路径字符,默认为 +4,658;已支持的320字符scope再增289,为4,947。初次4,650校准被两个原测试 +拒绝,失败保留;不缩短scope或归一化原始stdout。以上与main相同指重放后 +`9bea38cd4`的普通thin dispatch规则,后续检查发现普通thin缺少失败禁执行指引,compact/brief的peer +简写也缺少完整规则。compact/brief恢复main的完整peer规则和claim命令;普通thin +补充失败后不执行/扣费、权限内修复并重试原Turn。正文增加89字符,JSON增加90, +完整夹具实测5,037,三个thin JSON预算设为5,100,留63字符。另一CLI夹具在同样 +320字符scope及三个已观察能力下,归一化正文4,485→4,574;通用thin展示预算 +4,500→4,600,留26字符,保留全部义务和历史失败。两处精确元数据断言随owner +对齐,包括完整scope用例遗留的4,200预期(main的`7a2421ec89`已改为4,500)。 +完整scope、within-budget、权限、方向和根继续语义断言保留;Markdown实测 +6,136→6,225,正好增加该89字符条款,保留6,200失败,三个thin Markdown预算 +设为6,300,留75字符。行数、增长及原生传输检查独立,原生Goal宿主限制仍为4,000。这些调整不证明模型采纳 +或整体premerge通过。 + The native first-admission qualification at `ff1c8a947` uses the same public fixture populations, interpreter, Node version, physical path layout and shared probe on target main `8caaf678e` and head. The three quota JSON outputs measure diff --git a/loopx/control_plane/heartbeat/agent.py b/loopx/control_plane/heartbeat/agent.py index caa7ca40df..db5361e29c 100644 --- a/loopx/control_plane/heartbeat/agent.py +++ b/loopx/control_plane/heartbeat/agent.py @@ -156,11 +156,9 @@ def render_peer_agent_scope_instruction( ) if compact: return ( - f"Equal peer `{identity}` (peer_v1); scope: {scope_text}. " - "Obey quota claim/lease, workspace/repository and Todo continuation. " - "Quota-claim open in-scope work; peer-owned->another/no-work. " - "PR review=user_action+runnable successor; gate exact merge/release/launch only. " - "No peer authority from coordination. Scope=prompt, not Todo metadata." + f"Agent identity and scope: agent_id `{identity}`; model: peer_v1; " + f"scope: {scope_text}. {peer_rule} Claim: `{claim_command}`. " + "Do not write scope into todo metadata." ) return f"""Agent identity and scope: diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index b41abb1bab..40bb2aea0b 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -517,9 +517,7 @@ def build_heartbeat_prompt( task_body = task_body_renderer( goal_id=goal_id, active_state=active_state_text, - # Thin remains an installed-CLI worker, as before. Bootstrap/full - # generator surfaces retain preflight; brief carries it in its body. - cli_preflight=render_cli_preflight(cli_bin=cli_bin, thin=thin), + cli_preflight=cli_preflight, pr_review_pre_quota_command=( "" if traex_visible_goal else commands["pr_review_pre_quota_command"] or "" ), diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index acf7f7d924..a7bad206bd 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -718,6 +718,7 @@ def render_thin_heartbeat_task_body( {render_turn_bootstrap()} {pr_review_pre_quota_instruction}{quota_guard_command} ``` +Failed preflight/guard: no work/spend. Repair within authority; retry the original Turn. {HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} {RUNTIME_CAPABILITY_PROJECTION_THIN_RULE} diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 4568bf8a07..46344da6a7 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -322,10 +322,16 @@ class CliOutputCommandClassification: # chars, retaining the original Turn, due time and fresh admission. # All three scenarios now emit 4,853 / 6,969 chars and 63 Markdown # lines. Preserve historical misses; retain complete instructions, - # the current 4,500 body allowance and unchanged line/growth guards. - "small": {"json": 5_000, "markdown": 7_100}, - "crowded": {"json": 5_000, "markdown": 7_100}, - "multi_agent": {"json": 5_000, "markdown": 7_100}, + # the target's Markdown and unchanged line/growth guards. At c38, + # the real full-scope pytest input plus restored failed-guard + # guidance measured 5,037 JSON / 6,225 Markdown chars; retain its + # 4,650 JSON and 6,200 Markdown failures. Keep that JSON ceiling + # and the target's larger Markdown ceiling for the combined rule. + # Body/transport limits remain independently owned by heartbeat + # budget and native host; qualify the complete merged fixture. + "small": {"json": 5_100, "markdown": 7_100}, + "crowded": {"json": 5_100, "markdown": 7_100}, + "multi_agent": {"json": 5_100, "markdown": 7_100}, }, max_lines={ "small": {"json": 18, "markdown": 72}, diff --git a/tests/control_plane/test_heartbeat_agent_input.py b/tests/control_plane/test_heartbeat_agent_input.py index e1ebec20ef..201ddf1b2b 100644 --- a/tests/control_plane/test_heartbeat_agent_input.py +++ b/tests/control_plane/test_heartbeat_agent_input.py @@ -65,7 +65,7 @@ def test_thin_cli_preserves_readable_admission_and_authority_with_host_scope(tmp packet = json.loads(subprocess.check_output(command, text=True)) assert packet["ok"] is True budget = packet["interface_budget"] - assert budget["max_chars"] == 4500 + assert budget["max_chars"] == 4600 assert budget["budget_char_count"] <= budget["max_chars"] assert budget["within_budget"] is True body = packet["task_body"] From 2ea6902f211ce93fada8be586f4886075cc9dfb4 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 01:22:25 +0800 Subject: [PATCH 65/80] refactor(host): isolate Windows launch resource preparation Signed-off-by: Tartar --- .../turn_driver/host_process_transport.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index 86981b1695..5ba9240eb7 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -256,6 +256,21 @@ def _begin_host_process_record(record_path: Path | None, *, record_value: str, return record +def _prepare_windows_launch( + containment: ExitStack, command: list[str], +) -> tuple[Any, list[str], list[str]]: + """Own Windows IO resources; the TS bridge still owns launch observation.""" + from .windows_host_job import WindowsHostJob + from ..runtime.private_permissions import protect_private_path + + windows_job = containment.enter_context(WindowsHostJob()) + report_directory = containment.enter_context(tempfile.TemporaryDirectory(prefix="loopx-host-launch-")) + protect_private_path(Path(report_directory), directory=True) + report_path = str(Path(report_directory) / "created.json") + relay = Path(__file__).with_name("windows_host_command.py") + return windows_job, [sys.executable, str(relay), report_path, *command], ["--windows-launch-status", report_path] + + def run_host_process( argv: Sequence[str], *, @@ -298,15 +313,7 @@ def run_host_process( windows_job = None launch_report_arguments: list[str] = [] if sys.platform == "win32": - from .windows_host_job import WindowsHostJob - windows_job = containment.enter_context(WindowsHostJob()) - report_directory = containment.enter_context(tempfile.TemporaryDirectory(prefix="loopx-host-launch-")) - from ..runtime.private_permissions import protect_private_path - protect_private_path(Path(report_directory), directory=True) - report_path = str(Path(report_directory) / "created.json") - relay = Path(__file__).with_name("windows_host_command.py") - request["argv"] = [sys.executable, str(relay), report_path, *command] - launch_report_arguments = ["--windows-launch-status", report_path] + windows_job, request["argv"], launch_report_arguments = _prepare_windows_launch(containment, command) proc = subprocess.Popen( [ _node_executable(), From 80096311606e903d4c6ba9923a41b14187594d0c Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 01:23:22 +0800 Subject: [PATCH 66/80] test(canary): isolate fixture Git namespaces from parent checkout Signed-off-by: Tartar --- examples/canary/catalog-run-e2e-smoke.py | 21 ++++++++++++------- .../heartbeat-quota-flow-smoke.py | 4 ++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/examples/canary/catalog-run-e2e-smoke.py b/examples/canary/catalog-run-e2e-smoke.py index 349d32b688..eb5647ea4d 100644 --- a/examples/canary/catalog-run-e2e-smoke.py +++ b/examples/canary/catalog-run-e2e-smoke.py @@ -4,10 +4,12 @@ from __future__ import annotations import json +import os import subprocess import sys import tempfile from pathlib import Path +from unittest.mock import patch REPO_ROOT = Path(__file__).resolve().parents[2] @@ -113,14 +115,17 @@ def assert_git_required_smoke_skips_without_git_worktree() -> None: (temp_root / "examples" / "control_plane").mkdir(parents=True) runner_module.REPO_ROOT = temp_root try: - payload = build_catalog_canary_run( - catalog_path=REPO_ROOT / "docs/concepts/interaction-pattern-catalog.md", - profiles=["repo-architecture-budget"], - max_checks_per_profile=1, - check_limit=1, - execute=True, - timeout_seconds=10, - ) + # TEMP may be inside the source checkout. Exercise real Git's + # non-worktree result without discovering an unrelated ancestor. + with patch.dict(os.environ, {"GIT_CEILING_DIRECTORIES": str(temp_root.parent)}): + payload = build_catalog_canary_run( + catalog_path=REPO_ROOT / "docs/concepts/interaction-pattern-catalog.md", + profiles=["repo-architecture-budget"], + max_checks_per_profile=1, + check_limit=1, + execute=True, + timeout_seconds=10, + ) finally: runner_module.REPO_ROOT = original_root diff --git a/examples/control_plane/heartbeat-quota-flow-smoke.py b/examples/control_plane/heartbeat-quota-flow-smoke.py index 4f74c83c55..783ed96d6d 100644 --- a/examples/control_plane/heartbeat-quota-flow-smoke.py +++ b/examples/control_plane/heartbeat-quota-flow-smoke.py @@ -106,6 +106,10 @@ def write_fixture(root: Path, *, initialize: bool = True) -> tuple[Path, Path, P registry_path = project / ".loopx" / "registry.json" state_path.parent.mkdir(parents=True, exist_ok=True) + # Own the public scan namespace before admission, even when TEMP lives + # below another checkout's ignored private directory. + subprocess.run(["git", "init", "--quiet", str(project)], check=True) + (project / ".gitignore").write_text(".codex/\n.loopx/\n.local/\n", encoding="utf-8") state_path.write_text( "---\n" "status: active-read-only\n" From 43c63c192aa9317de1d789e977c6538aab92dc8e Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 02:05:09 +0800 Subject: [PATCH 67/80] test(heartbeat): qualify complete failed-guard guidance growth once Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 21 +++++++++ .../testing/cli_output_differential.py | 4 ++ .../testing/cli_output_semantics.py | 18 +++++++ .../test_cli_output_differential.py | 47 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 4c29c2f3c3..62b95fb0d4 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1383,6 +1383,27 @@ guard前传入已知Todo选择。pytest隔离目录另增加51个原始JSON路 设为6,300,留75字符。行数、增长及原生传输检查独立,原生Goal宿主限制仍为4,000。这些调整不证明模型采纳 或整体premerge通过。 +The next matched `c38b3a1a6` / `7d9bb6fe1` probe has 96 rows on each side; +candidate absolute and semantic assertions pass. Its six thin differential +growth failures are exactly the failed-guard clause: +90 JSON / +89 Markdown +characters versus the existing 64 / 32 growth limits. Preserve those failures. +Qualify only the first complete clause between the actual thin worker's guard +code block and notification, with a +96-character allowance (6 / 7 headroom); do not change absolute, line or +later-growth limits. Missing no-work/spend, authority or original-Turn text, +diagnostic/scope quotes, ambiguous old/new blocks, other surfaces and +already-present clauses do not qualify. +These observations extend the local output-test receipt, not runtime state or +authority. The sequential samples took 139.9 / 290.2 seconds; this is neither +a sustained performance result nor evidence of model-token savings. + +后续同负载 `c38b3a1a6` / `7d9bb6fe1` 各96行,候选绝对预算和语义断言通过; +六个thin差分增长失败均为新增失败处理句:JSON +90、Markdown +89,原限制 +为64 / 32,失败保留。仅在实际thin worker的guard代码块与通知之间首次出现完整条款时允许96字符 +(余6 / 7);不改绝对、行数或后续增长门。缺少禁工作/扣费、权限或原Turn任一 +含义、诊断区/scope引用、新旧块歧义、其他表面及已存在条款均不适用。这只扩展本地输出验证回执, +不改变运行时状态或权限。顺序单次139.9 / 290.2秒不证明持续性能或模型token收益。 + The native first-admission qualification at `ff1c8a947` uses the same public fixture populations, interpreter, Node version, physical path layout and shared probe on target main `8caaf678e` and head. The three quota JSON outputs measure diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 4b1d49996c..7d9d2502b5 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -879,6 +879,10 @@ def migrated(clause: str) -> bool: # Measured warning appearance: +417/+419 chars and four lines. Preserve # the warning and its target; paired JSON proves the reviewed cause. chars, lines = max(chars, 448), 4 + if row_id.startswith("surface/heartbeat_prompt_thin/") and migrated("failed_guard_recovery"): + # Same 96-row fixture: complete failed-guard guidance adds 90 JSON + # chars / 89 Markdown chars. No line, absolute or later-growth waiver. + chars += 96 if not chars: return {}, [] return { diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index b84c5d36a5..d7dc210e5b 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -218,6 +218,7 @@ def continuation_instruction_observations(text: str) -> dict[str, bool]: ) # Decode JSON string escaping before observing exact clauses. For Markdown # the emitted instruction is already readable. No runtime state is inferred. + payload = None try: payload = json.loads(text) except json.JSONDecodeError: @@ -279,7 +280,24 @@ def contains_folded(clause: str) -> bool: "Reuse inline facts; inspect missing context only, without polling or a per-Turn revision/check receipt.", "Retain root commitments; a proposal or adoption grants no permissions.", ) + # Only the actual heartbeat worker receives this cost qualification. A + # matching quote in another JSON field or Markdown diagnostics is not it. + worker = payload.get("task_body") if isinstance(payload, dict) and payload.get( + "schema_version") == "heartbeat_agent_input_v1" else None + if payload is None and text.startswith("# Heartbeat Automation Prompt\n"): + _, opening, remaining = text.partition("````text\n") + body, closing, _ = remaining.partition("\n````") + worker = body if opening and closing else None + failed_guard_recovery = ( + "Failed preflight/guard: no work/spend. Repair within authority; retry the original Turn." + ) return { + # Bind to the renderer's guard -> notification boundary. Scope quotes + # cannot qualify; observing both old/new blocks is ambiguous and fails. + "failed_guard_recovery_before": isinstance(worker, str) and "\n```\nLang=user;" in worker, + "failed_guard_recovery_after": isinstance(worker, str) and ( + "\n```\n" + failed_guard_recovery + "\nLang=user;" + ) in worker, "objective_replan_before": contains(objective_replan_prior) and not any( contains(clause) for clause in objective_replan_clauses), "objective_replan_after": contains(objective_replan_prior + " " + " ".join(objective_replan_clauses)), diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index b927b1663d..f037ff0f82 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -2046,6 +2046,53 @@ def test_objective_replan_cost_is_complete_once_and_carrier_scoped(): assert not observe(prior + " " + " ".join(reversed(clauses)))["objective_replan_after"] assert not observe(json.dumps({"prior": prior, "clauses": clauses}))["objective_replan_after"] +@pytest.mark.parametrize("output_format", ["json", "markdown"]) +def test_failed_guard_guidance_cost_requires_complete_worker_clause_once(output_format): + from loopx.control_plane.testing.cli_output_differential import _compare_row + from loopx.control_plane.testing.cli_output_semantics import continuation_instruction_observations as observe + + clause = "Failed preflight/guard: no work/spend. Repair within authority; retry the original Turn." + + def rendered(body="", note="", scope="registered peer"): + worker = f"Scope: {scope}\n```sh\nloopx quota should-run\n```\n{body}Lang=user; default=en." + if output_format == "json": + return json.dumps({"schema_version": "heartbeat_agent_input_v1", "task_body": worker, "note": note}) + return f"# Heartbeat Automation Prompt\n\n````text\n{worker}\n````\n{note}" + + base = _row(row_id=f"surface/heartbeat_prompt_thin/small/{output_format}", + surface_id="heartbeat_prompt_thin", format=output_format, chars=1000, + utf8_bytes=1000, compact_payload_chars=800 if output_format == "json" else None, lines=20, + continuation_instructions=observe(rendered())) + growth = 90 if output_format == "json" else 89 + complete = observe(rendered(clause + "\n")) + head = {**base, "chars": 1000 + growth, "utf8_bytes": 1000 + growth, + "compact_payload_chars": 800 + growth if output_format == "json" else None, + "continuation_instructions": complete} + assert not _compare_row(base, head)["failures"] + assert _compare_row(base, head)["allowances"]["chars"] == 96 + # Already-shipped guidance, partial obligations, metadata quotes and other + # surfaces cannot inherit this first complete transition's allowance. + assert _compare_row({**base, "continuation_instructions": complete}, head)["failures"] + for omitted in ("no work/spend. ", "within authority", "original Turn"): + partial = observe(rendered(clause.replace(omitted, "") + "\n")) + assert not partial["failed_guard_recovery_after"] + assert _compare_row(base, {**head, "continuation_instructions": partial})["failures"] + quoted = observe(rendered(note=clause)) + assert not quoted["failed_guard_recovery_after"] + assert _compare_row(base, {**head, "continuation_instructions": quoted})["failures"] + scoped = observe(rendered(scope="\n" + clause + "\n")) + assert not scoped["failed_guard_recovery_after"] + assert _compare_row(base, {**head, "continuation_instructions": scoped})["failures"] + ambiguous = observe(rendered(scope="\n```\n" + clause + "\nLang=user;\n")) + assert ambiguous["failed_guard_recovery_before"] and ambiguous["failed_guard_recovery_after"] + assert _compare_row(base, {**head, "continuation_instructions": ambiguous})["failures"] + assert _compare_row(base, {**head, "chars": 1097})["failures"] + assert _compare_row(base, {**head, "lines": 23})["failures"] + for surface in ("status", "quota_should_run", "heartbeat_prompt_compact"): + wrong = f"surface/{surface}/small/{output_format}" + assert _compare_row({**base, "row_id": wrong}, {**head, "row_id": wrong})["failures"] + + def _admission_packet(): identity = {"schema_version": "quota_settlement_identity_v0", "goal_id": "fixture-goal", "agent_id": "fixture-agent", "todo_id": "todo_fixture", "turn_instance_id": "work:original"} From 0aae26ecc1cbc6d2c341dd9823a8b453de125759 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 02:57:08 +0800 Subject: [PATCH 68/80] test(freshness): preserve the complete original work-basis contract Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 16 ++++++++++++++ .../test_quota_cli_projection.py | 22 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 62b95fb0d4..0e09ccc78d 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1404,6 +1404,22 @@ a sustained performance result nor evidence of model-token savings. 含义、诊断区/scope引用、新旧块歧义、其他表面及已存在条款均不适用。这只扩展本地输出验证回执, 不改变运行时状态或权限。顺序单次139.9 / 290.2秒不证明持续性能或模型token收益。 +Default freshness also adds the original read basis as a self-contained Host +input. Its Todo text/title can equal the current work view while retaining a +different provenance and integrity contract. The older whole-packet string +count must not delete or alias that basis into a current snapshot. Keep the +current work view's one-body assertion; independently compare the original +context with its stored token's complete readback, including both text and +title. Receipt-off packets retain the whole-packet assertion. This separates +required snapshot cost from accidental display copies without a blanket +three-copy allowance or a new projection protocol. + +默认freshness还交付独立、自包含的原始读依据;其Todo正文/标题可与当前工作视图 +相同,但来源与完整性契约不同。旧“整包字符串只出现一次”断言不能据此删除原依据 +或引用当前快照替代。保留当前工作视图正文一次的断言,另以原token的完整存储读回 +核对原依据(含正文与标题);无receipt时仍检查整包。该调整区分必要快照成本与 +意外显示副本,不是统一允许三份,也不新增投影协议。 + The native first-admission qualification at `ff1c8a947` uses the same public fixture populations, interpreter, Node version, physical path layout and shared probe on target main `8caaf678e` and head. The three quota JSON outputs measure diff --git a/tests/control_plane/test_quota_cli_projection.py b/tests/control_plane/test_quota_cli_projection.py index 0301185f4a..9369558c8f 100644 --- a/tests/control_plane/test_quota_cli_projection.py +++ b/tests/control_plane/test_quota_cli_projection.py @@ -145,7 +145,27 @@ def test_real_guard_keeps_multiline_task_once_without_differential_opt_in(tmp_pa lane = full["agent_lane_next_action"] assert lane["todo_id"] == TODO_ID and "title" not in lane assert lane["title_ref"] == f"$.interaction_contract.agent_channel.work_context.sources[{index}].content.todo.text" - assert json.dumps(full, ensure_ascii=False).count(json.dumps(body, ensure_ascii=False)) == 1 + work_view = dict(full) + if (full.get("heartbeat_receipt") or {}).get("result_freshness") == "turn_result_freshness_required_v1": + from loopx.control_plane.goals.checkpoint_context_io import result_work_context + from loopx.control_plane.quota.settlement import SettlementIdentity + + # The original read basis is a separate, self-contained Host input. + # Restore its existing token, never mint a new read or substitute the + # current work view merely because their text happens to match. + original = full["delivery_result_context"] + identity = SettlementIdentity.from_runtime_payload(original["settlement_identity"]) + assert identity.todo_id == TODO_ID and identity.agent_id == AGENT_ID + assert identity.goal_id == GOAL_ID + stored = result_work_context(registry_path=registry, runtime_root=runtime, identity=identity) + assert json.dumps(original, sort_keys=True, allow_nan=False) == json.dumps( + stored, sort_keys=True, allow_nan=False) + assert original["basis"]["todo"]["text"] == body + assert original["basis"]["todo"]["title"] == body + work_view.pop("delivery_result_context") + # Preserve the old current-view oracle, including receipt-off behavior. + # This is not permission for an arbitrary number of duplicate carriers. + assert json.dumps(work_view, ensure_ascii=False).count(json.dumps(body, ensure_ascii=False)) == 1 assert "context_delivery" not in full assert not list(project.rglob("presentation.json")) assert state.read_bytes() == original_state From 32c27b526d84c315b577278d213f5ddc95368a80 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 04:52:30 +0800 Subject: [PATCH 69/80] fix(turn): retain the admitted native replan guard source Signed-off-by: Tartar --- loopx/cli_commands/turn_run_once.py | 6 ++- .../control_plane/quota/heartbeat_receipt.py | 31 +++++++++++++++ loopx/control_plane/quota/settlement_cli.py | 13 +++---- ...t_host_adapter_settlement_partial_steps.py | 38 ++++++++++++++---- .../test_turn_launch_identity.py | 39 +++++++++++++++++-- 5 files changed, 109 insertions(+), 18 deletions(-) diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index ab880261fd..98a38b1931 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -18,6 +18,7 @@ ) from ..control_plane.quota.heartbeat_receipt import ( ensure_turn_heartbeat_settlement_receipt, + guard_source_from_decision, ) from ..control_plane.quota.live_decision import build_live_quota_should_run_decision from ..control_plane.quota.settlement import ( @@ -1027,13 +1028,16 @@ def on_managed_start_admitted() -> None: ) from ..control_plane.goals.checkpoint_context_io import default_result_freshness_policy - policy = default_result_freshness_policy(runtime_root, args.goal_id, settlement_identity.todo_id, goal_ref) + policy = default_result_freshness_policy(runtime_root, args.goal_id, settlement_identity.todo_id or None, goal_ref) ensure_turn_heartbeat_settlement_receipt( runtime_root, settlement_identity, semantic_replan_guard_scoped=replan_guard_scoped, semantic_replan_obligation_id=replan_obligation_id, semantic_replan_capability_guard=capability_guard, + semantic_replan_source=guard_source_from_decision(decision, + obligation_id=replan_obligation_id, registry_path=registry_path, + runtime_root=runtime_root, goal_id=args.goal_id), goal_ref=goal_ref, result_freshness=policy, checkpoint_continuation="current_agent_v1" if policy is not None else None, diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index e81b0c0c82..493eeeeb77 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -204,6 +204,7 @@ def ensure_turn_heartbeat_settlement_receipt( semantic_replan_guard_scoped: bool, semantic_replan_obligation_id: str | None, semantic_replan_capability_guard: Mapping[str, object] | None = None, + semantic_replan_source: dict[str, object] | None = None, goal_ref: Mapping[str, object] | None = None, result_freshness: str | None = None, checkpoint_continuation: str | None = None, @@ -307,6 +308,13 @@ def ensure_turn_heartbeat_settlement_receipt( details["semantic_replan_obligation_id"] = ( normalized_semantic_replan_obligation_id or "" ) + if semantic_replan_source is not None: + source = validate_guard_source( + semantic_replan_source, normalized_semantic_replan_obligation_id + ) + details["semantic_replan_source_ref"] = store_guard_source_blob( + runtime_root, identity.goal_id, source + ) if semantic_replan_capability_guard is not None: details["semantic_replan_capability_id"] = semantic_replan_capability_guard["capability_id"] details["semantic_replan_gap_id"] = semantic_replan_capability_guard["gap_id"] @@ -765,6 +773,29 @@ def bounded_guard_source(obligation, cadence): "sha256": _guard_source_digest(value), "cadence": dict(cadence)} +def guard_source_from_decision( + payload: Mapping[str, object] | None, + *, + obligation_id: str | None, + registry_path: Path, + runtime_root: Path, + goal_id: str, +) -> dict[str, object] | None: + """Capture only the original admitted obligation, never a settlement replay.""" + if not obligation_id or payload is None: + return None + obligation = payload.get("autonomous_replan_obligation") + packet = payload.get("replan_action_packet") or {} + if (not isinstance(obligation, dict) + or obligation.get("obligation_id") != obligation_id + or obligation.get("settlement_action_packet") + or (isinstance(packet, Mapping) and packet.get("settlement_only"))): + return None + return bounded_guard_source( + obligation, guard_cadence(registry_path, runtime_root, goal_id) + ) + + def validate_guard_source(source, obligation_id): from ..work_items.autonomous_replan_obligation import ensure_replan_novelty_policy if not isinstance(source, dict) or source.get("schema_version") != "semantic_replan_source_v1": diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index acb6d3df82..256b85f3b2 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -457,13 +457,12 @@ def quota_rollout_details( details["semantic_replan_gap_id"] = capability_guard["gap_id"] details["semantic_replan_frontier_revision"] = capability_guard["frontier_revision"] if semantic_replan_obligation_id: - from .heartbeat_receipt import bounded_guard_source, guard_cadence, store_guard_source_blob - source_obligation = payload.get("autonomous_replan_obligation") - if (isinstance(source_obligation, dict) and source_obligation.get("obligation_id") == semantic_replan_obligation_id - and not source_obligation.get("settlement_action_packet") - and not (payload.get("replan_action_packet") or {}).get("settlement_only")): - source = bounded_guard_source(source_obligation, - guard_cadence(payload["registry"], payload["runtime_root"], args.goal_id)) + from .heartbeat_receipt import guard_source_from_decision, store_guard_source_blob + source = guard_source_from_decision(payload, + obligation_id=semantic_replan_obligation_id, + registry_path=Path(payload["registry"]), runtime_root=Path(payload["runtime_root"]), + goal_id=args.goal_id) + if source is not None: details["semantic_replan_source_ref"] = store_guard_source_blob(payload["runtime_root"],args.goal_id,source) cli_channel = interaction.get("cli_channel") if isinstance(cli_channel, Mapping) and cli_channel.get("quota_spend_source"): diff --git a/tests/control_plane/test_host_adapter_settlement_partial_steps.py b/tests/control_plane/test_host_adapter_settlement_partial_steps.py index cb82f26208..88c57b5ca4 100644 --- a/tests/control_plane/test_host_adapter_settlement_partial_steps.py +++ b/tests/control_plane/test_host_adapter_settlement_partial_steps.py @@ -29,6 +29,7 @@ from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane from loopx.status import parse_active_state_todos from loopx.todos import add_goal_todo +from tests.control_plane.host_checkpoint_fixture import checkpoint_decision GOAL_ID = "adapter-partial-steps-fixture" @@ -205,11 +206,26 @@ def _control(registry: Path) -> GoalModeMCPControlPlane: return control +def _complete_default_task(control, *args, **options): + """The fixture Agent judges the actual post-result basis before settlement.""" + output = control.complete_task(*args, **options) + first = json.loads(output) + if first.get("stage") != "direction_pending": + return output + assert first["completed"] is True and first["settlement_complete"] is False + decision = checkpoint_decision(first["context"]) + return control.complete_task(*args, **options, read_context_id=decision["read_context_id"], + agent_vision=decision.get("agent_vision"), + vision_unchanged_reason=decision.get("vision_unchanged_reason") or None) + + def test_a_raising_step_after_a_durable_commit_recovers_through_the_cli_receipt( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """The recovery boundary: the CLI owns the receipt, not the adapter.""" - registry, _ = _write_fixture(tmp_path) + registry, state_file = _write_fixture(tmp_path) + monkeypatch.chdir(state_file.parent) added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -220,6 +236,7 @@ def test_a_raising_step_after_a_durable_commit_recovers_through_the_cli_receipt( ) todo_id = str(added["todo_id"]) control = _control(registry) + assert json.loads(control.should_run())["delivery_result_context"] original = control.run_cli injected = False bound_identity: dict[str, object] = {} @@ -244,7 +261,7 @@ def raise_after_commit(args: list[str], **kwargs: object) -> str: control.run_cli = raise_after_commit # type: ignore[assignment] with pytest.raises(RuntimeError, match="synthetic CLI transport failure"): - control.complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "partial-step fixture check passed", @@ -274,7 +291,7 @@ def raise_after_commit(args: list[str], **kwargs: object) -> str: calls.clear() replay = json.loads( - control.complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "partial-step fixture check passed", @@ -300,9 +317,11 @@ def raise_after_commit(args: list[str], **kwargs: object) -> str: def test_a_raised_step_before_completion_commits_no_settlement( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """A failed transport never becomes a settlement the CLI would replay.""" registry, state_file = _write_fixture(tmp_path) + monkeypatch.chdir(state_file.parent) added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -313,6 +332,7 @@ def test_a_raised_step_before_completion_commits_no_settlement( ) todo_id = str(added["todo_id"]) control = _control(registry) + assert json.loads(control.should_run())["delivery_result_context"] original = control.run_cli turn_instance_id = "" @@ -327,7 +347,7 @@ def raise_on_completion(args: list[str], **kwargs: object) -> str: control.run_cli = raise_on_completion # type: ignore[assignment] with pytest.raises(RuntimeError, match="synthetic CLI transport failure"): - control.complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "raised step fixture check passed", @@ -365,13 +385,15 @@ def raise_on_completion(args: list[str], **kwargs: object) -> str: def _raise_after_step( tmp_path: Path, step_prefix: tuple[str, ...], + monkeypatch: pytest.MonkeyPatch, ) -> tuple[Path, str, str]: """Complete a Todo whose transport fails just after ``step_prefix`` commits. Returns the runtime root, the Todo id and the turn instance id the plan used, so the caller can read back the receipts the CLI already committed. """ - registry, _ = _write_fixture(tmp_path) + registry, state_file = _write_fixture(tmp_path) + monkeypatch.chdir(state_file.parent) added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -382,6 +404,7 @@ def _raise_after_step( ) todo_id = str(added["todo_id"]) control = _control(registry) + assert json.loads(control.should_run())["delivery_result_context"] original = control.run_cli turn_instance_id = "" injected = False @@ -398,7 +421,7 @@ def raise_after_commit(args: list[str], **kwargs: object) -> str: control.run_cli = raise_after_commit # type: ignore[assignment] with pytest.raises(RuntimeError, match="synthetic CLI transport failure"): - control.complete_task( + _complete_default_task(control, todo_id, AGENT_ID, "partial-step fixture check passed", @@ -441,13 +464,14 @@ def _committed_receipt_kinds(readback: object) -> list[str]: ) def test_a_raised_step_after_a_durable_commit_leaves_only_that_stage_pending( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, step_prefix: tuple[str, ...], expected_replay_phase: str, expected_state: str, expected_receipt_kinds: list[str], ) -> None: """Each durable stage leaves exactly its own receipt behind.""" - runtime_root, todo_id, turn_instance_id = _raise_after_step(tmp_path, step_prefix) + runtime_root, todo_id, turn_instance_id = _raise_after_step(tmp_path, step_prefix, monkeypatch) readback = read_heartbeat_settlement( runtime_root, diff --git a/tests/control_plane/test_turn_launch_identity.py b/tests/control_plane/test_turn_launch_identity.py index bba5195961..6ce88e0e61 100644 --- a/tests/control_plane/test_turn_launch_identity.py +++ b/tests/control_plane/test_turn_launch_identity.py @@ -9,15 +9,34 @@ from host_process_fixture import write_python_host_fixture from test_replan_successor_guard_reentry import AGENT, GOAL, _fixture +from tests.control_plane.host_checkpoint_fixture import checkpoint_fixture_script def _host(root: Path, *, material: bool) -> Path: # This is a deterministic process transport, not a model or task evaluator. # The material arm writes a distinct artifact checked by the outer validator; # the replan arm ends the iteration and earns no writeback or debit. - return write_python_host_fixture(root / "native-host", f'''#!/usr/bin/env python3 + return write_python_host_fixture(root / "native-host", checkpoint_fixture_script(f'''#!/usr/bin/env python3 import json, pathlib, sys -request = json.loads(sys.stdin.read().split("Turn request:\\n", 1)[1]) +from tests.control_plane.host_checkpoint_fixture import reply_to_checkpoint +from tests.control_plane.host_checkpoint_fixture import checkpoint_decision +prompt = sys.stdin.read() +direction = prompt.startswith("Continue the current Agent's original Turn") +request = json.loads(prompt.split("\\n", 1)[1] if direction else prompt.split("Turn request:\\n", 1)[1]) +if direction: + assert request["phase"] == "checkpoint_direction" + assert request["session"]["action"] == "resume" + assert "resume" in sys.argv and "fixture-launch-session" in sys.argv + assert pathlib.Path("checked-source.txt").read_text() == "independently checked source\\n" + with pathlib.Path("direction-requests.jsonl").open("a") as stream: + stream.write(json.dumps(request) + "\\n") + result = checkpoint_decision(request["checkpoint_context"]) + if "agent_vision" in result: + result["agent_vision_json"] = json.dumps(result.pop("agent_vision")) + args = sys.argv[1:] + print(json.dumps({{"type": "thread.started", "thread_id": "fixture-launch-session"}}), flush=True) + pathlib.Path(args[args.index("--output-last-message") + 1]).write_text(json.dumps(result)) + sys.exit(0) with pathlib.Path("host-requests.jsonl").open("a") as stream: stream.write(json.dumps(request) + "\\n") material = {material!r} @@ -43,7 +62,7 @@ def _host(root: Path, *, material: bool) -> Path: validation_evidence=["check:independent-source-validator"]) args = sys.argv[1:] pathlib.Path(args[args.index("--output-last-message") + 1]).write_text(json.dumps(result)) -''') +''')) @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -100,11 +119,23 @@ def test_native_cli_uses_original_binding_and_replays_without_new_effects( assert result["status"] == "committed" and result["validation"]["ok"] is True current = call("todo", "list", "--goal-id", GOAL, "--todo-id", todo_id) assert current["todo"]["text"] == declared_text + direction = json.loads((project / "direction-requests.jsonl").read_text()) + assert direction["turn_key"] == result["receipt"]["turn_key"] + context = direction["checkpoint_context"] + assert context["read_context_id"] + assert context["settlement_identity"]["effect_id"] == expected_effect else: assert result["status"] == "stopped" and result["result_kind"] == "iteration_failed" assert not (project / "checked-source.txt").exists() + assert not (project / "direction-requests.jsonl").exists() + from loopx.control_plane.quota.heartbeat_receipt import find_heartbeat_receipt, guard_source_for_turn + source = guard_source_for_turn(runtime, goal_id=GOAL, agent_id=AGENT, turn_instance_id=turn) + assert source["obligation"]["obligation_id"] == obligation_id + receipt = find_heartbeat_receipt(runtime, goal_id=GOAL, agent_id=AGENT, turn_instance_id=turn) + assert "result_freshness" not in receipt["details"] requests = (project / "host-requests.jsonl").read_bytes() + direction_requests = ((project / "direction-requests.jsonl").read_bytes() if todo_id else None) sent = json.loads(requests) assert sent["turn_key"] == result["receipt"]["turn_key"] captures = {path: path.read_bytes() for path in runtime.rglob("decisions/*.json")} @@ -121,4 +152,6 @@ def test_native_cli_uses_original_binding_and_replays_without_new_effects( assert replay["replayed"] is True assert not any(replay["effects"].values()) assert (project / "host-requests.jsonl").read_bytes() == requests + if todo_id: + assert (project / "direction-requests.jsonl").read_bytes() == direction_requests assert all(path.read_bytes() == original for path, original in captures.items()) From e9743f311d36d84a2b267157c824353156d9786f Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 05:51:24 +0800 Subject: [PATCH 70/80] test(turn): distinguish unbound and malformed envelope plans Signed-off-by: Tartar --- tests/test_loopx_turn_driver.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index ba8781ab5d..b3cf6e2362 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -1343,9 +1343,14 @@ def test_signed_governance_can_run_while_normal_delivery_remains_withheld(hold): assert plan["transaction"]["settlement_plan"]["identity"]["replan_obligation_id"] == obligation -@pytest.mark.parametrize("invalid_signature", [False, True]) +@pytest.mark.parametrize( + ("invalid_signature", "invalid_schema"), + [(False, False), (True, False), (False, True)], + ids=["valid_unbound", "invalid_signature", "unsupported_schema"], +) def test_turn_plan_without_todo_requires_signature_and_scoped_binding( invalid_signature: bool, + invalid_schema: bool, ) -> None: envelope = _envelope( effective_action="autonomous_replan_required" if invalid_signature else "normal_run", @@ -1353,14 +1358,15 @@ def test_turn_plan_without_todo_requires_signature_and_scoped_binding( envelope["action"]["selected_todo"] = None if invalid_signature: envelope["action_signature"]["matches"] = False - else: + if invalid_schema: envelope["schema_version"] = "unsupported_turn_envelope" payload = build_loopx_turn_plan( envelope, host="generic-cli", execution_mode="isolated-headless", ) - assert payload["ok"] is (not invalid_signature) - assert payload["route"]["kind"] == ("contract_error" if invalid_signature else "blocked") + invalid = invalid_signature or invalid_schema + assert payload["ok"] is (not invalid) + assert payload["route"]["kind"] == ("contract_error" if invalid else "blocked") assert payload["route"]["would_invoke_host"] is False assert payload["route"]["host_invocation_allowed"] is False assert payload["session"]["action"] == "none" From 265d00c8b1e6694d0574589d53ce793489bf3efd Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 06:26:00 +0800 Subject: [PATCH 71/80] test: align recovery fixtures with ownership and cleanup contracts Signed-off-by: Tartar --- .../test_effect_runtime_integration.py | 6 ++-- .../team_task_revalidation_fixture.py | 2 +- tests/test_windows_install.py | 30 ++++++++++--------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/tests/control_plane/test_effect_runtime_integration.py b/tests/control_plane/test_effect_runtime_integration.py index 549e61d602..e1c3fd1b57 100644 --- a/tests/control_plane/test_effect_runtime_integration.py +++ b/tests/control_plane/test_effect_runtime_integration.py @@ -1799,6 +1799,7 @@ def test_controller_snapshot_retains_private_file_and_method_guards( ) -> None: import tempfile + from loopx.control_plane.runtime.private_permissions import protect_private_path from tests.test_loop_turn_loop_controller import _envelope monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: tmp_path / "runtime") @@ -1808,14 +1809,15 @@ def test_controller_snapshot_retains_private_file_and_method_guards( assert info is not None with tempfile.TemporaryDirectory(prefix="loopx-effect-", dir=tmp_path) as directory: root = Path(directory) + protect_private_path(root, directory=True) params = root / "params.json" sink = root / "response.json" decision = _envelope(should_run=True) decision["work_context"] = {"text": "G" * 2_420_000} params.write_text(json.dumps({"operation": "disposition", "decision": decision, "receipt": None})) sink.touch() - params.chmod(0o600) - sink.chmod(0o600) + protect_private_path(params) + protect_private_path(sink) ref = {"schema_version": "loopx_effect_runtime_snapshot_v0", "path": str(params), "byte_count": params.stat().st_size, "sha256": hashlib.sha256(params.read_bytes()).hexdigest()} diff --git a/tests/presentation/team_task_revalidation_fixture.py b/tests/presentation/team_task_revalidation_fixture.py index d94f2dcece..46e6a0f251 100644 --- a/tests/presentation/team_task_revalidation_fixture.py +++ b/tests/presentation/team_task_revalidation_fixture.py @@ -157,11 +157,11 @@ def lose_response(binding, *args, **kwargs): release_worker.set() if held_worker is not None: held_worker.join(timeout=30) + assert not held_worker.is_alive(), "fixture worker did not exit" server.shutdown() server.server_close() thread.join(timeout=5) controller.close() - assert workers, "fixture did not observe its own launched worker" drain_fixture_workers(workers) finally: for cleanup in reversed(cleanups): diff --git a/tests/test_windows_install.py b/tests/test_windows_install.py index d90690c1b2..a2b57c62db 100644 --- a/tests/test_windows_install.py +++ b/tests/test_windows_install.py @@ -736,6 +736,22 @@ def test_windows_installer_promotes_release_and_runs_doctor(tmp_path: Path) -> N ) assert not (project / ".codex" / "goals" / "windows-probe").exists() + todo_id = json.loads(todo.stdout)["todo_id"] + + # Establish execution ownership before reading the work basis. Acquiring a + # different lease afterwards correctly invalidates that original read. + acquired = _run_loopx( + pwsh=pwsh, + launcher=launcher, + args=[ + *common, "task-lease", "acquire", "--goal-id", "windows-probe", + "--todo-id", todo_id, "--owner", "windows-worker", + "--idempotency-key", "windows-writeback", + ], + env=launch_env, + ) + assert acquired.returncode == 0, acquired.stderr or acquired.stdout + quota = _run_loopx( pwsh=pwsh, launcher=launcher, @@ -755,20 +771,6 @@ def test_windows_installer_promotes_release_and_runs_doctor(tmp_path: Path) -> N quota_payload = json.loads(quota.stdout) assert quota_payload["should_run"] is True - todo_id = json.loads(todo.stdout)["todo_id"] - - acquired = _run_loopx( - pwsh=pwsh, - launcher=launcher, - args=[ - *common, "task-lease", "acquire", "--goal-id", "windows-probe", - "--todo-id", todo_id, "--owner", "windows-worker", - "--idempotency-key", "windows-writeback", - ], - env=launch_env, - ) - assert acquired.returncode == 0, acquired.stderr or acquired.stdout - def todo_command(action: str, *args: str) -> subprocess.CompletedProcess[str]: # Each invocation starts a fresh PowerShell process through the installed # launcher, without the installer's release-pointer override. From 3c764009f91874b482146b1f6cfc7b4a7cc9b8e4 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 07:55:35 +0800 Subject: [PATCH 72/80] fix: stage default replan CLI checkpoints after results Signed-off-by: Tartar --- loopx/cli_commands/quota.py | 10 +- .../goals/checkpoint_context_io.py | 42 ++++++++ .../test_default_result_freshness.py | 96 +++++++++++++++++++ .../test_default_terminal_frontier.py | 17 ++-- 4 files changed, 155 insertions(+), 10 deletions(-) diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 052824c9f1..41def45ccf 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -997,7 +997,10 @@ def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_rec registry_path, runtime_root, runtime_root_arg, goal_ref): """Restore the original captured input after a successful quota guard.""" if args.quota_command == "should-run" and heartbeat_turn_id and payload.get("ok") is True: - from ..control_plane.goals.checkpoint_context_io import TurnResultWork, result_work_required, result_work_context, result_work_context_exists + from ..control_plane.goals.checkpoint_context_io import ( + TurnResultWork, replan_checkpoint_cli_actions, result_work_required, + result_work_context, result_work_context_exists, + ) from ..control_plane.quota.settlement import read_heartbeat_settlement try: @@ -1021,6 +1024,11 @@ def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_rec elif work_readback.writeback_run is None: payload["delivery_result_context"] = result_work_context(registry_path=registry_path, runtime_root=runtime_root, identity=work_readback.identity.value, goal_ref=goal_ref) + interaction = payload.get("interaction_contract", {}) + if interaction.get("mode") == "autonomous_replan": + channel = interaction["cli_channel"] + channel["next_cli_actions"] = replan_checkpoint_cli_actions( + channel["next_cli_actions"], work_readback) except (ValueError, OSError, RuntimeError) as error: admitted_receipt = payload.get("heartbeat_receipt") payload = quota_failure_payload(args, registry_path=registry_path, diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index d5af603783..eeded3877f 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -5,6 +5,7 @@ from contextlib import ExitStack, contextmanager import hashlib import json +import shlex from pathlib import Path from typing import Any, Iterator from uuid import uuid4 @@ -93,6 +94,47 @@ def checkpoint_continuation_required(readback: Any) -> bool: })["required"] +def replan_checkpoint_cli_actions(actions: list[str], readback: Any) -> list[str]: + """Render the admitted checkpoint phases; the typed receipt owns policy. + + The generic replan command describes semantic authoring, but a default Turn + must retain its result before reading and authoring that direction. Historical + receipts and lifecycle reentry commands keep their original projection. + """ + if not actions: + return actions + tokens = shlex.split(actions[0]) + if "refresh-state" not in tokens or not checkpoint_continuation_required(readback): + return actions + command_at = tokens.index("refresh-state") + prefix, arguments = tokens[:command_at], tokens[command_at + 1:] + initial = [] + context = [*prefix, "checkpoint-context", "--purpose", "supplement_checkpoint"] + context_options = {"--goal-id", "--goal-instance-id", "--agent-id", "--todo-id", + "--replan-obligation-id", "--turn-instance-id", "--project", "--state-file"} + decision_options = {"--agent-vision-json", "--vision-unchanged-reason"} + at = 0 + while at < len(arguments): + option = arguments[at] + if option in context_options | decision_options: + if at + 1 == len(arguments): + raise ValueError(f"checkpoint command requires a value for {option}") + pair = arguments[at:at + 2] + if option in context_options: + context.extend(pair) + initial.extend(pair) + at += 2 + else: + initial.append(option) + at += 1 + direction = [*tokens, "--checkpoint-read-context", ""] + if not any(option in arguments for option in decision_options): + direction.extend(["--agent-vision-json", + ""]) + return [shlex.join([*prefix, "refresh-state", *initial]), shlex.join(context), + shlex.join(direction), *actions[1:]] + + def result_work_read_id(root: Path, identity: SettlementIdentity) -> str: """Restore the admitted work token; a writeback must never mint a new read.""" try: diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index b3a139ae3c..c406d4d73c 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import shlex import sys import pytest @@ -27,6 +28,101 @@ def _default_fixture(provider, tmp_path, monkeypatch, *, handoff_mode="soft_clai return project, runtime, registry, state +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_projected_replan_commands_preserve_result_then_fresh_direction(provider, tmp_path, monkeypatch): + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + from tests.control_plane.test_quota_settlement_cli import ( + _bind_selected_replan_guard, _configure_selected_todo_replan_fixture, + _initialize_fixture_authority, + ) + + monkeypatch.delenv("CODEX_THREAD_ID", raising=False) + project, runtime, registry = _write_fixture(tmp_path) + _configure_selected_todo_replan_fixture(project, registry) + _initialize_fixture_authority(project, runtime, provider=provider) + turn = "projected-replan-original-turn" + guard = _bind_selected_replan_guard(registry, runtime, project, turn) + identity = guard["heartbeat_receipt"]["settlement_identity"] + original_basis = guard["delivery_result_context"] + admission = read_heartbeat_settlement(runtime, goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=identity["todo_id"], turn_instance_id=turn, registry_path=registry).heartbeat_receipt + actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] + decision_path = project / "new direction.json" + + def execute(command, **replacements): + for key, value in replacements.items(): + command = command.replace(key, str(value)) + tokens = shlex.split(command) + start = next(i for i, token in enumerate(tokens) + if token in {"refresh-state", "checkpoint-context", "quota"}) + return _run_cli(registry, runtime, *tokens[start:], cwd=project) + + # Run the advertised first action unchanged, before any direction is authored. + rc, result = execute(actions[0]) + assert rc == 0, result + retained = read_heartbeat_settlement(runtime, goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=identity["todo_id"], turn_instance_id=turn, registry_path=registry) + assert retained.writeback_run is not None and retained.spend_run is None + assert retained.heartbeat_receipt == admission + assert retained.writeback_run["vision_checkpoint"]["satisfied"] is False + assert not retained.writeback_run.get("autonomous_replan_ack", {}).get("recorded") + spend = next(c for c in actions if "spend-slot" in c) + assert _spend_run_count(runtime) == 0 + rc, context = execute(actions[1]) + assert rc == 0 and context["settlement_identity"] == identity, context + assert context["basis"]["todo"]["status"] == "open" + assert original_basis["settlement_identity"] == identity + # The Agent authors the direction only after reading this post-result basis. + decision_path.write_text(json.dumps({ + "schema_version": "goal_vision_replan_contract_v0", "state": "vision_patch_proposed", + "vision_patch": {"acceptance_summary": "Verify the retained candidate before closing its selected task."}, + "path_delta": {"outcome": "replan", "prior_assumption": "The oversized serial route could finish directly.", + "observed_reality": "The retained result leaves a long open dependency chain requiring a bounded route.", + "retained": ["The original candidate and its selected task."], + "changed": ["Verify the selected candidate before advancing the dependent tasks."], + "evidence_refs": [f"todo:{identity['todo_id']}"]}, + }), encoding="utf-8") + replacements = { + "": decision_path, + "": context["read_context_id"], + } + rc, direction = execute(actions[2], **replacements) + assert rc == 0 and direction["autonomous_replan_ack"]["recorded"], direction + rc, spent = execute(spend) + assert rc == 0 and spent["appended"], spent + assert execute(spend)[1]["appended"] is False + assert _spend_run_count(runtime) == 1 + final = read_heartbeat_settlement(runtime, goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=identity["todo_id"], turn_instance_id=turn, registry_path=registry) + assert final.heartbeat_receipt == retained.heartbeat_receipt + assert final.writeback_event["event_id"] == retained.writeback_event["event_id"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_historical_replan_projection_does_not_gain_checkpoint_phases(provider, tmp_path, monkeypatch): + from tests.control_plane.test_quota_settlement_cli import ( + _bind_selected_replan_guard, _configure_selected_todo_replan_fixture, + _historical_turn, _initialize_fixture_authority, + ) + + monkeypatch.delenv("CODEX_THREAD_ID", raising=False) + project, runtime, registry = _write_fixture(tmp_path) + _configure_selected_todo_replan_fixture(project, registry) + _initialize_fixture_authority(project, runtime, provider=provider) + turn = "historical-replan-original-turn" + _bind_selected_replan_guard(registry, runtime, project, turn) + _historical_turn(runtime, turn) + events = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + original = events.read_bytes() + guard = _bind_selected_replan_guard(registry, runtime, project, turn) + actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] + assert "--agent-vision-json" in actions[0] + assert not any("checkpoint-context" in action for action in actions) + assert "delivery_result_context" not in guard + assert events.read_bytes() == original + assert _spend_run_count(runtime) == 0 + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_default_guard_restores_actual_basis_and_result_commit(provider, tmp_path, monkeypatch): project, runtime, registry, state = _default_fixture(provider, tmp_path, monkeypatch) diff --git a/tests/control_plane/test_default_terminal_frontier.py b/tests/control_plane/test_default_terminal_frontier.py index 32c3e0a4c2..79d24ab3e2 100644 --- a/tests/control_plane/test_default_terminal_frontier.py +++ b/tests/control_plane/test_default_terminal_frontier.py @@ -1,6 +1,7 @@ """Default terminal/frontier recovery through disposable owning stores.""" import json from pathlib import Path +import shlex import subprocess import sys @@ -70,7 +71,8 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): from tests.control_plane.test_quota_settlement_cli import _projected_cli_args actions = guard['interaction_contract']['cli_channel']['next_cli_actions'] packet = project / 'frontier-replan.json' - refresh = next(c for c in actions if 'refresh-state' in c).replace( + initial_command, context_command, direction_command = actions[:3] + refresh = direction_command.replace( '', str(packet)) for key, value in {'': 'advanced', '': 'delivered-dependency', '': 'dependency-acceptance', @@ -84,9 +86,7 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): if flag in refresh_args: at = refresh_args.index(flag) del refresh_args[at:at + 2] - initial = refresh_args.copy() - index = initial.index('--agent-vision-json') - del initial[index:index + 2] + initial = list(_projected_cli_args(initial_command, turn_instance_id=turn)) pending = json.loads(caller.run_cli([*initial, '--no-global-sync', '--suppress-external-sinks'])) assert pending['ok'] is True, {key: pending.get(key) for key in ('error', 'reason')} retained = json.loads(Path(pending['json_path']).read_text()) @@ -96,10 +96,9 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): denied_spend = json.loads(caller.run_cli(list(spend))) assert denied_spend['ok'] is False, denied_spend assert _business_rows(runtime)['quota_slot_spent'] == 1 - from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context - context = read_checkpoint_context(registry_path=registry, - runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, - todo_id=identity['todo_id'], turn_instance_id=turn, purpose='supplement_checkpoint') + context_args = shlex.split(context_command) + context = json.loads(caller.run_cli(context_args[context_args.index('checkpoint-context'):])) + assert context['ok'] is True, context assert context['basis']['todo']['status'] == 'open' prior = context['basis']['agent_vision'] assert prior @@ -124,8 +123,8 @@ def settle_frontier_replan(caller, guard, registry, project, runtime, new_todo): 'changed': ['Verify the delivered dependency before another terminal decision.'], 'evidence_refs': [f'todo:{new_todo}', 'file:new-dependency.txt']} packet.write_text(json.dumps(vision), encoding='utf-8') + refresh_args = [context['read_context_id'] if arg == '' else arg for arg in refresh_args] refreshed = json.loads(caller.run_cli([*refresh_args, - '--checkpoint-read-context', context['read_context_id'], '--no-global-sync', '--suppress-external-sinks'])) assert refreshed['ok'] is True, {key: refreshed.get(key) for key in ( 'error', 'error_code', 'reason', 'vision_checkpoint', 'autonomous_replan_ack')} From 90490a4d66094db1105def563b64e2052705dc10 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 09:17:11 +0800 Subject: [PATCH 73/80] fix: preserve the full replan CLI command prefix Signed-off-by: Tartar --- loopx/cli_commands/quota.py | 5 +- .../goals/checkpoint_context_io.py | 9 +-- .../test_default_result_freshness.py | 57 +++++++++++++++---- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 41def45ccf..19af8cd95a 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -1002,6 +1002,7 @@ def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_rec result_work_context, result_work_context_exists, ) from ..control_plane.quota.settlement import read_heartbeat_settlement + from ..control_plane.work_items.action_selection_contract import render_cli_command_prefix try: work_readback = read_heartbeat_settlement(runtime_root, goal_id=args.goal_id, @@ -1028,7 +1029,9 @@ def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_rec if interaction.get("mode") == "autonomous_replan": channel = interaction["cli_channel"] channel["next_cli_actions"] = replan_checkpoint_cli_actions( - channel["next_cli_actions"], work_readback) + channel["next_cli_actions"], work_readback, + command_prefix=render_cli_command_prefix( + registry_path=str(registry_path), runtime_root=str(runtime_root))) except (ValueError, OSError, RuntimeError) as error: admitted_receipt = payload.get("heartbeat_receipt") payload = quota_failure_payload(args, registry_path=registry_path, diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index eeded3877f..ac1440dfde 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -94,7 +94,7 @@ def checkpoint_continuation_required(readback: Any) -> bool: })["required"] -def replan_checkpoint_cli_actions(actions: list[str], readback: Any) -> list[str]: +def replan_checkpoint_cli_actions(actions: list[str], readback: Any, *, command_prefix: str) -> list[str]: """Render the admitted checkpoint phases; the typed receipt owns policy. The generic replan command describes semantic authoring, but a default Turn @@ -104,10 +104,11 @@ def replan_checkpoint_cli_actions(actions: list[str], readback: Any) -> list[str if not actions: return actions tokens = shlex.split(actions[0]) - if "refresh-state" not in tokens or not checkpoint_continuation_required(readback): + prefix = [*shlex.split(command_prefix), "--format", "json"] + command_at = len(prefix) + if tokens[:command_at + 1] != [*prefix, "refresh-state"] or not checkpoint_continuation_required(readback): return actions - command_at = tokens.index("refresh-state") - prefix, arguments = tokens[:command_at], tokens[command_at + 1:] + arguments = tokens[command_at + 1:] initial = [] context = [*prefix, "checkpoint-context", "--purpose", "supplement_checkpoint"] context_options = {"--goal-id", "--goal-instance-id", "--agent-id", "--todo-id", diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index c406d4d73c..d9d3be229a 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -2,7 +2,9 @@ from __future__ import annotations import json +import os import shlex +import subprocess import sys import pytest @@ -28,11 +30,12 @@ def _default_fixture(provider, tmp_path, monkeypatch, *, handoff_mode="soft_clai return project, runtime, registry, state +@pytest.mark.parametrize("program", ["candidate-cli", "refresh-state"]) @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_projected_replan_commands_preserve_result_then_fresh_direction(provider, tmp_path, monkeypatch): +def test_projected_replan_commands_preserve_result_then_fresh_direction(provider, program, tmp_path, monkeypatch): from loopx.control_plane.quota.settlement import read_heartbeat_settlement from tests.control_plane.test_quota_settlement_cli import ( - _bind_selected_replan_guard, _configure_selected_todo_replan_fixture, + REPO_ROOT, SELECTED_REPLAN_TODO_ID, _configure_selected_todo_replan_fixture, _initialize_fixture_authority, ) @@ -41,7 +44,47 @@ def test_projected_replan_commands_preserve_result_then_fresh_direction(provider _configure_selected_todo_replan_fixture(project, registry) _initialize_fixture_authority(project, runtime, provider=provider) turn = "projected-replan-original-turn" - guard = _bind_selected_replan_guard(registry, runtime, project, turn) + executables = project / "CLI entrypoints with spaces" + executables.mkdir() + if sys.platform == "win32": + launcher = executables / f"{program}.ps1" + python = sys.executable.replace("'", "''") + launcher.write_text(f"& '{python}' -m loopx.entrypoint @args\nexit $LASTEXITCODE\n", encoding="utf-8") + (executables / "checkpoint-context.ps1").write_text("exit 97\n", encoding="utf-8") + else: + launcher = executables / program + launcher.write_text(f"#!{sys.executable}\nfrom loopx.entrypoint import main\nraise SystemExit(main())\n", encoding="utf-8") + launcher.chmod(0o700) + sentinel = executables / "checkpoint-context" + sentinel.write_text(f"#!{sys.executable}\nraise SystemExit(97)\n", encoding="utf-8") + sentinel.chmod(0o700) + environment = {**os.environ, "PATH": str(executables) + os.pathsep + os.environ["PATH"], + "PYTHONPATH": str(REPO_ROOT)} + + def execute(command, **replacements): + tokens = shlex.split(command) if isinstance(command, str) else command + tokens = [str(replacements.get(token, token)) for token in tokens] + # Execute the emitted program and every global argument without repair. + if sys.platform == "win32": + from loopx.command_invocation import command_argv, resolve_command_path + + executable = resolve_command_path(tokens[0], env=environment) + assert executable is not None, tokens + tokens = command_argv(executable, tokens[1:]) + completed = subprocess.run(tokens, cwd=project, env=environment, capture_output=True, text=True) + assert completed.returncode != 97, "projection invoked the unrelated checkpoint-context executable" + return completed.returncode, json.loads(completed.stdout) + + prefix = [program, "--cli-bin", program, "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json"] + rc, guard = execute([*prefix, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", SELECTED_REPLAN_TODO_ID, + "--turn-instance-id", turn]) + if rc != 0: + assert rc == 1 and guard["action_selection_qualification"]["state"] == "deferred", guard + [selection] = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] + rc, guard = execute(selection) + assert rc == 0, guard identity = guard["heartbeat_receipt"]["settlement_identity"] original_basis = guard["delivery_result_context"] admission = read_heartbeat_settlement(runtime, goal_id=GOAL_ID, agent_id=AGENT_ID, @@ -49,14 +92,6 @@ def test_projected_replan_commands_preserve_result_then_fresh_direction(provider actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] decision_path = project / "new direction.json" - def execute(command, **replacements): - for key, value in replacements.items(): - command = command.replace(key, str(value)) - tokens = shlex.split(command) - start = next(i for i, token in enumerate(tokens) - if token in {"refresh-state", "checkpoint-context", "quota"}) - return _run_cli(registry, runtime, *tokens[start:], cwd=project) - # Run the advertised first action unchanged, before any direction is authored. rc, result = execute(actions[0]) assert rc == 0, result From 344e9fabea9e5e62e7bc2048d0ec8320ef9c1d84 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 11:18:44 +0800 Subject: [PATCH 74/80] fix: preserve pending direction through recovery and debit Signed-off-by: Tartar --- loopx/cli_commands/quota.py | 12 +++- .../goals/checkpoint_context_io.py | 30 +++++++++ loopx/control_plane/quota/blocked_wait.ts | 5 ++ loopx/control_plane/quota/settlement.py | 2 + loopx/control_plane/quota/settlement_phase.ts | 24 +++++++ .../quota/settlement_readback.ts | 24 +++++-- loopx/control_plane/quota/slot_accounting.py | 4 +- .../test_default_checkpoint_continuation.py | 17 ++++- .../test_default_result_freshness.py | 32 +++++++++- .../test_quota_settlement_cli.py | 21 ++++++ .../quota_settlement_readback.test.ts | 64 ++++++++++++++++++- 11 files changed, 223 insertions(+), 12 deletions(-) diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 19af8cd95a..d48a6d3fec 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -998,7 +998,7 @@ def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_rec """Restore the original captured input after a successful quota guard.""" if args.quota_command == "should-run" and heartbeat_turn_id and payload.get("ok") is True: from ..control_plane.goals.checkpoint_context_io import ( - TurnResultWork, replan_checkpoint_cli_actions, result_work_required, + TurnResultWork, pending_checkpoint_cli_actions, replan_checkpoint_cli_actions, result_work_required, result_work_context, result_work_context_exists, ) from ..control_plane.quota.settlement import read_heartbeat_settlement @@ -1014,6 +1014,16 @@ def _restore_quota_work_input(args, payload, *, heartbeat_turn_id, heartbeat_rec payload["work_input_restored"] = result_work_context_exists(runtime_root, work_readback.identity.value) committed = heartbeat_receipt_existing is not None and TurnResultWork( registry_path, runtime_root, work_readback.identity.value, goal_ref).result_committed() + if (work_readback.progress or {}).get("state") == "direction_pending": + interaction = payload["interaction_contract"] + channel = interaction["cli_channel"] + channel["next_cli_actions"] = pending_checkpoint_cli_actions(work_readback, + command_prefix=render_cli_command_prefix( + registry_path=str(registry_path), runtime_root=str(runtime_root)), goal_ref=goal_ref) + channel.update(spend_allowed_now=False, spend_after_validation=True) + interaction["agent_channel"]["primary_action"] = payload["recommended_action"] = ( + "Result retained; read the original Turn's post-result context and complete its direction before spending." + ) if committed: payload["result_committed"] = True progress = payload.get("first_delivery_progress") diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index ac1440dfde..669ebc0b15 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -136,6 +136,36 @@ def replan_checkpoint_cli_actions(actions: list[str], readback: Any, *, command_ shlex.join(direction), *actions[1:]] +def pending_checkpoint_cli_actions(readback: Any, *, command_prefix: str, + goal_ref: Mapping[str, Any] | None = None) -> list[str]: + """Render the typed owner's pending direction through existing CLI phases. + + Omitted mutation/progress options retain the immutable original result. + Only the post-result read and the Agent's fresh decision are new work. + """ + from ..quota.effect_program import ( + SettlementStepKind, build_turn_scoped_cli_settlement_plan, settlement_step_command, + ) + + identity = readback.identity.value + plan = build_turn_scoped_cli_settlement_plan( + goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, + replan_obligation_id=identity.replan_obligation_id, turn_instance_id=identity.turn_instance_id, + command_prefix=command_prefix, scoped_cli_args=f" --agent-id {shlex.quote(identity.agent_id)}", + lifecycle_actor_args="", goal_ref=goal_ref, + quota_spend_source=readback.progress["quota_spend_source"], + ).as_dict() + # A supplement need not repeat the original outcome's optional fields. + # Fill required command fields from that verified run, never new defaults. + run = readback.writeback_run + writeback = settlement_step_command(plan, SettlementStepKind.DURABLE_WRITEBACK) + tokens = [str({"": run["classification"], "": run["delivery_batch_scale"], + "": run["delivery_outcome"]}.get(token, token)) for token in shlex.split(writeback)] + return replan_checkpoint_cli_actions([ + shlex.join(tokens), settlement_step_command(plan, SettlementStepKind.QUOTA_SPEND), + ], readback, command_prefix=command_prefix)[1:] + + def result_work_read_id(root: Path, identity: SettlementIdentity) -> str: """Restore the admitted work token; a writeback must never mint a new read.""" try: diff --git a/loopx/control_plane/quota/blocked_wait.ts b/loopx/control_plane/quota/blocked_wait.ts index bef0c55fba..4df17fc432 100644 --- a/loopx/control_plane/quota/blocked_wait.ts +++ b/loopx/control_plane/quota/blocked_wait.ts @@ -274,6 +274,11 @@ export function projectReceiptBoundWait(value: unknown): JsonObject { return {status: "none"}; } const progress = jsonObject(request.settlement_progress); + if (progress?.schema_version === "quota_settlement_progress_v0" && + progress.state === "direction_pending" && progress.next_step === "durable_writeback") { + return boundRecovery(request, "durable_writeback", + "The original result is retained, but its direction checkpoint is still pending. Read its post-result checkpoint context and supplement only that direction under the original Turn before spending. Do not repeat the result, its mutations or implementation."); + } const writebackRequired = progress?.schema_version === "quota_settlement_progress_v0" && progress.next_step === "durable_writeback" && (progress.state === "writeback_required" || progress.state === "writeback_receipt_required"); diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index 648fdb22f2..4137cd009c 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -149,6 +149,7 @@ class QuotaSettlementReadback: writeback: SettlementResult[dict[str, Any]] spend: SettlementResult[dict[str, Any]] delivery: SettlementResult[dict[str, Any]] + spend_qualification: SettlementResult[dict[str, Any]] settlement: SettlementResult[dict[str, Any]] terminal_closeout: SettlementResult[dict[str, Any]] terminal_settlement: SettlementResult[dict[str, Any]] @@ -442,6 +443,7 @@ def read(admission: Mapping[str, Any] | None) -> Any: writeback=_readback_result(payload.get("writeback")), spend=_readback_result(payload.get("spend")), delivery=_readback_result(payload.get("delivery")), + spend_qualification=_readback_result(payload.get("spend_qualification")), settlement=_readback_result(payload.get("settlement")), terminal_closeout=_readback_result(payload.get("terminal_closeout")), terminal_settlement=_readback_result(payload.get("terminal_settlement")), diff --git a/loopx/control_plane/quota/settlement_phase.ts b/loopx/control_plane/quota/settlement_phase.ts index 62219cee66..01283e7b81 100644 --- a/loopx/control_plane/quota/settlement_phase.ts +++ b/loopx/control_plane/quota/settlement_phase.ts @@ -4,6 +4,30 @@ import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; import { isCanonicalTodoId } from "../todos/identity.ts"; import { isBlockedLifecycleWait, isCausalBlockedWait, isMonitorUnavailableWait } from "./blocked_wait.ts"; import { isTurnScopedSettlementOutcome } from "../work_items/delivery_outcome.ts"; +import { checkpointContinuationRequired } from "./heartbeat_receipt_identity.ts"; + +/** A retained result is readable before its direction is accepted. Only the + * original admission can require that checkpoint; historical receipts keep + * their policy, and a real not-required decision needs no direction append. */ +export function isAcceptedSettlementDirection( + value: unknown, identity: SettlementIdentity, receipt: Record | null, + selectedObligation: unknown, +): boolean { + if (!checkpointContinuationRequired(receipt)) return true; + const run = jsonObject(value), checkpoint = jsonObject(run?.vision_checkpoint); + if (checkpoint?.schema_version !== "vision_checkpoint_v0" || + checkpoint.agent_id !== identity.agent_id || checkpoint.satisfied !== true) return false; + const notRequired = checkpoint.required === false && checkpoint.decision === "not_required"; + const context = jsonObject(checkpoint.read_context); + if (!notRequired && (typeof checkpoint.required !== "boolean" || + !["patched", "unchanged_with_reason"].includes(String(checkpoint.decision)) || + context?.ok !== true || typeof context.read_context_id !== "string" || !context.read_context_id)) return false; + if (selectedObligation === null) return true; + const ack = jsonObject(run?.autonomous_replan_ack), semantic = jsonObject(ack?.semantic_delta); + return ack?.schema_version === "autonomous_replan_ack_v0" && ack.recorded === true && + semantic?.schema_version === "replan_semantic_delta_v0" && semantic.accepted === true && + semantic.obligation_id === selectedObligation; +} function retryDelayIsBounded(seconds: number): boolean { return Number.isFinite(seconds) && seconds >= 60 && seconds <= 30 * 60; diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index f2f38d2500..267c4cd6b6 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -39,6 +39,7 @@ import { isCommittedMonitorPollEffect, isAcceptedProgressWriteback, isAcceptedReplanWriteback, + isAcceptedSettlementDirection, isCapabilityRetirementWriteback, receiptBoundMonitorPhase, receiptBoundReplayPhase, @@ -132,7 +133,7 @@ interface ResultBundle extends JsonObject { } type SettlementProgressState = "identity_required" | "writeback_required" | - "writeback_receipt_required" | "spend_required" | "spend_receipt_required" | "settled"; + "writeback_receipt_required" | "direction_pending" | "spend_required" | "spend_receipt_required" | "settled"; function settlementScope( runtimeRootValue: unknown, @@ -163,6 +164,7 @@ function settlementProgress( writebackRun: JsonObject | null, spendRun: JsonObject | null, spendSource: unknown = "heartbeat", noSpendKind: "typed_blocked_writeback_no_spend" | "capability_duty_retired_no_spend" | null = null, + directionPending = false, ): JsonObject { const source = spendSource ?? "heartbeat"; if (source !== "heartbeat" && source !== "visible-goal") { @@ -171,12 +173,13 @@ function settlementProgress( const state: SettlementProgressState = identity.failure ? "identity_required" : writeback.failure ? (writebackRun ? "writeback_receipt_required" : "writeback_required") : noSpendKind ? "settled" + : directionPending ? "direction_pending" : spend.failure ? (spendRun ? "spend_receipt_required" : "spend_required") : "settled"; return { schema_version: "quota_settlement_progress_v0", state, next_step: identity.failure ? "validation" : writeback.failure ? "durable_writeback" - : noSpendKind ? null : spend.failure ? "quota_spend" : null, + : noSpendKind ? null : directionPending ? "durable_writeback" : spend.failure ? "quota_spend" : null, quota_spend_source: source, ...(noSpendKind ? { closeout_kind: noSpendKind, @@ -1070,6 +1073,7 @@ function failedReadback( writeback: bundle(downstreamFailure), spend: bundle(downstreamFailure), delivery: bundle(downstreamFailure), + spend_qualification: bundle(downstreamFailure), settlement: bundle(downstreamFailure), terminal_closeout: bundle(terminalFailure), terminal_settlement: bundle(downstreamFailure), @@ -1265,7 +1269,15 @@ function readQuotaSettlementFromRequest( : blockedNoSpend ? "typed_blocked_writeback_no_spend" : null; const terminalCloseout = terminalResult(identity, completionEvent); const withWriteback = settlementBindReduce(identityResult, writeback); - const settled = noSpendKind ? withWriteback : settlementBindReduce(withWriteback, spend); + const directionPending = writeback.failure === null && noSpendKind === null && + !isAcceptedSettlementDirection(writebackRun, identity, heartbeatReceipt, semanticReplanGuard.selected_obligation_id); + // Keep writeback readable for a post-result context. Debit, replay closeout + // and terminal settlement must also preserve the original direction duty. + const spendQualification = directionPending ? settlementBindReduce(withWriteback, settlementFailed({ + kind: "writeback_rejected", step_kind: "durable_writeback", + reason: "The original Turn still owes its direction checkpoint. Read checkpoint-context and supplement that same result before spending.", + })) : withWriteback; + const settled = noSpendKind ? spendQualification : settlementBindReduce(spendQualification, spend); const terminalSettlement = settlementBindReduce(settled, terminalCloseout); const monitorPoll = committedMonitorPollFromSnapshot( snapshot, @@ -1312,7 +1324,7 @@ function readQuotaSettlementFromRequest( supersede_receipt_present: supersedeEvent?.status === "done" && optionalString(supersedeEvent.todo_id) === identity.todo_id, durable_writeback_present: writeback.failure === null, - quota_spend_present: spend.failure === null, + quota_spend_present: spend.failure === null && !directionPending, no_spend_closeout_present: noSpendKind !== null, }); @@ -1347,11 +1359,13 @@ function readQuotaSettlementFromRequest( writeback: bundle(writeback), spend: bundle(spend), delivery: bundle(withWriteback), + // Qualification carries receipts/failure, not another copy of the result. + spend_qualification: bundle(spendQualification.failure === null ? {...spendQualification, value: {}} : spendQualification), settlement: bundle(settled), terminal_closeout: bundle(terminalCloseout), terminal_settlement: bundle(terminalSettlement), progress: settlementProgress(identityResult, writeback, spend, writebackRun, spendRun, - receiptDetails.quota_spend_source ?? spendRun?.source, noSpendKind), + receiptDetails.quota_spend_source ?? spendRun?.source, noSpendKind, directionPending), workspace_causality: workspaceCausality, semantic_replan_guard: semanticReplanGuard, writeback_run: writebackRun, diff --git a/loopx/control_plane/quota/slot_accounting.py b/loopx/control_plane/quota/slot_accounting.py index 3bce138857..5168ad9b62 100644 --- a/loopx/control_plane/quota/slot_accounting.py +++ b/loopx/control_plane/quota/slot_accounting.py @@ -209,11 +209,11 @@ def _resolve_preview_settlement( result = readback.identity identity = result.value if result.failure is None else None if identity is not None: - result = readback.delivery + result = readback.spend_qualification return { "identity": identity, "result": result, - "delivery_run": result.value if result.failure is None else None, + "delivery_run": readback.delivery.value if result.failure is None else None, "delivery_workspace_causality": readback.workspace_causality, "reason": result.failure.reason if result.failure is not None else None, "writeback_committed": _receipt_committed( diff --git a/tests/control_plane/test_default_checkpoint_continuation.py b/tests/control_plane/test_default_checkpoint_continuation.py index 76cd731a1f..2efe49727c 100644 --- a/tests/control_plane/test_default_checkpoint_continuation.py +++ b/tests/control_plane/test_default_checkpoint_continuation.py @@ -30,6 +30,16 @@ def _fixture(provider, tmp_path, monkeypatch, *, validation_command_json=None): return registry, state, todo['todo_id'] +def _assert_pending_direction_cannot_spend(registry, runtime, identity): + receipts = runtime / 'goals' / identity.goal_id + before = {name: (receipts / name).read_bytes() for name in ('runs/index.jsonl', 'rollout-event-log.jsonl')} + rc, denied = _run_cli(registry, 'quota', 'spend-slot', '--goal-id', identity.goal_id, + '--agent-id', identity.agent_id, '--todo-id', identity.todo_id, + '--turn-instance-id', identity.turn_instance_id, '--source', 'heartbeat', '--execute') + assert rc == 1 and denied['appended'] is False, denied + assert {name: (receipts / name).read_bytes() for name in before} == before + + @pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('terminal', [False, True, 'stored', 'cold_explicit']) def test_default_mcp_uses_scoped_post_result_basis_and_original_turn(provider, terminal, tmp_path, monkeypatch): @@ -204,6 +214,7 @@ def decide(context): return {'read_context_id': context['read_context_id'], 'vision_unchanged_reason': 'Current evidence preserves the route.'} driver = CurrentAgentCheckpoint(registry, tmp_path / 'runtime', state, identity, decide) decided = driver.continue_direction({}) + _assert_pending_direction_cannot_spend(registry, tmp_path / 'runtime', identity) if change == 'related': state.write_text(state.read_text(encoding='utf-8') + '\n## Acceptance\nRevised obligation.\n', encoding='utf-8') elif change == 'display': @@ -232,8 +243,9 @@ def decide(context): @pytest.mark.parametrize('failure', ['timeout', 'stop']) -def test_direction_failure_retains_result_and_respects_existing_host_budget(failure, tmp_path, monkeypatch): - registry, state, todo = _fixture('file', tmp_path, monkeypatch) +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_direction_failure_retains_result_and_respects_existing_host_budget(provider, failure, tmp_path, monkeypatch): + registry, state, todo = _fixture(provider, tmp_path, monkeypatch) control = _control(registry) json.loads(control.should_run()) first = json.loads(control.complete_task(todo, AGENT_ID, 'Candidate verified.', next_agent_todo='Verify remaining work.')) @@ -252,6 +264,7 @@ def fail(context): receipt = json.loads(_receipt_path(tmp_path / 'runtime', identity).read_text()) assert receipt['decision_basis']['todo']['status'] == 'done' assert 'current_agent_decision' not in receipt + _assert_pending_direction_cannot_spend(registry, tmp_path / 'runtime', identity) @pytest.mark.parametrize('kind', ['validated_progress', 'repair_required', 'replan_required', 'wait']) diff --git a/tests/control_plane/test_default_result_freshness.py b/tests/control_plane/test_default_result_freshness.py index d9d3be229a..e1c5be3267 100644 --- a/tests/control_plane/test_default_result_freshness.py +++ b/tests/control_plane/test_default_result_freshness.py @@ -32,16 +32,20 @@ def _default_fixture(provider, tmp_path, monkeypatch, *, handoff_mode="soft_clai @pytest.mark.parametrize("program", ["candidate-cli", "refresh-state"]) @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_projected_replan_commands_preserve_result_then_fresh_direction(provider, program, tmp_path, monkeypatch): +@pytest.mark.parametrize("validator", [False, True]) +def test_projected_replan_commands_preserve_result_then_fresh_direction(provider, program, validator, tmp_path, monkeypatch): from loopx.control_plane.quota.settlement import read_heartbeat_settlement from tests.control_plane.test_quota_settlement_cli import ( REPO_ROOT, SELECTED_REPLAN_TODO_ID, _configure_selected_todo_replan_fixture, _initialize_fixture_authority, + _configure_completion_validation_todo, ) monkeypatch.delenv("CODEX_THREAD_ID", raising=False) project, runtime, registry = _write_fixture(tmp_path) _configure_selected_todo_replan_fixture(project, registry) + if validator: + _configure_completion_validation_todo(project) _initialize_fixture_authority(project, runtime, provider=provider) turn = "projected-replan-original-turn" executables = project / "CLI entrypoints with spaces" @@ -103,6 +107,23 @@ def execute(command, **replacements): assert not retained.writeback_run.get("autonomous_replan_ack", {}).get("recorded") spend = next(c for c in actions if "spend-slot" in c) assert _spend_run_count(runtime) == 0 + # A separate CLI process restores the original admission, not a new Turn. + rc, recovered = execute([*prefix, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", identity["todo_id"], + "--turn-instance-id", turn]) + assert rc == 0, recovered + cold_actions = recovered["interaction_contract"]["cli_channel"]["next_cli_actions"] + (project / "cold-reentry.json").write_text(json.dumps(recovered), encoding="utf-8") + receipts = runtime / "goals" / GOAL_ID + before = {name: (receipts / name).read_bytes() for name in ("runs/index.jsonl", "rollout-event-log.jsonl")} + rc, premature = execute(spend) + assert rc == 1 and premature["appended"] is False, premature + assert {name: (receipts / name).read_bytes() for name in before} == before + assert _spend_run_count(runtime) == 0 + assert "settlement_owed" not in recovered or recovered["settlement_owed"]["kind"] != "quota_spend" + assert "checkpoint-context" in shlex.split(cold_actions[0]) + assert len(cold_actions) == 3, cold_actions + actions = [actions[0], *cold_actions] rc, context = execute(actions[1]) assert rc == 0 and context["settlement_identity"] == identity, context assert context["basis"]["todo"]["status"] == "open" @@ -123,6 +144,15 @@ def execute(command, **replacements): } rc, direction = execute(actions[2], **replacements) assert rc == 0 and direction["autonomous_replan_ack"]["recorded"], direction + # Recover only from durable facts if the caller loses that response. + rc, after_direction = execute([*prefix, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", identity["todo_id"], + "--turn-instance-id", turn]) + assert rc == 0, after_direction + assert after_direction["heartbeat_receipt"]["settlement_identity"] == identity + [remaining] = after_direction["interaction_contract"]["cli_channel"]["next_cli_actions"] + assert "spend-slot" in shlex.split(remaining), remaining + spend = remaining rc, spent = execute(spend) assert rc == 0 and spent["appended"], spent assert execute(spend)[1]["appended"] is False diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 107b4ba24b..1e2c2c7d91 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -905,6 +905,27 @@ def await_cycle(*, finished: bool) -> dict[str, Any]: } assert str(project) not in json.dumps(refresh["delivery_workspace"]) + # Retention alone cannot end the quota cycle while direction is pending. + pending_rc, pending = _run_cli(registry_path, runtime, "quota", "spend-slot", "--goal-id", GOAL_ID, + "--source", "heartbeat", "--execute", *binding, cwd=project) + assert pending_rc == 1 and pending["appended"] is False, pending + assert "end" not in json.loads(cycles_path.read_text())["cycles"][0] + context_rc, context = _run_cli(registry_path, runtime, "checkpoint-context", "--goal-id", GOAL_ID, + "--purpose", "supplement_checkpoint", *binding, cwd=project) + assert context_rc == 0, context + direction = project / "direction.json" + direction.write_text(json.dumps({"schema_version": "goal_vision_replan_contract_v0", + "state": "vision_patch_proposed", "vision_patch": {"acceptance_summary": "Verify the retained local candidate."}, + "path_delta": {"outcome": "continue", "prior_assumption": "The local candidate can advance the task.", + "observed_reality": "The retained candidate has a verified local workspace identity.", + "retained": ["The original candidate and task."], "evidence_refs": [f"todo:{TODO_ID}"]}}), encoding="utf-8") + direction_rc, supplemented = _run_cli(registry_path, runtime, "refresh-state", "--goal-id", GOAL_ID, + *binding, "--classification", "gitless_validated_progress", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--checkpoint-read-context", context["read_context_id"], "--agent-vision-json", str(direction), + "--no-global-sync", "--suppress-external-sinks", cwd=project) + assert direction_rc == 0 and supplemented["vision_checkpoint"]["satisfied"] is True, supplemented.get("error") or supplemented + assert "end" not in json.loads(cycles_path.read_text())["cycles"][0] + spend_rc, spend = _run_cli( registry_path, runtime, diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index 76a7d87ef6..a4faafc809 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -22,7 +22,7 @@ import { acquireFileMutationLock, releaseFileMutationLock, } from "../../loopx/control_plane/effect_runtime_io.ts"; -import { BLOCKED_WAIT_REQUEST_SCHEMA, prepareBlockedWait } from "../../loopx/control_plane/quota/blocked_wait.ts"; +import { BLOCKED_WAIT_REQUEST_SCHEMA, prepareBlockedWait, projectReceiptBoundWait, RECEIPT_BOUND_WAIT_REQUEST_SCHEMA } from "../../loopx/control_plane/quota/blocked_wait.ts"; import { evaluateTodoResumeConditions, TODO_RESUME_EVALUATION_REQUEST_SCHEMA_VERSION } from "../../loopx/control_plane/todos/resume_condition.ts"; import { projectSemanticReplanGuard, @@ -484,6 +484,68 @@ function request(runtimeRoot: string, overrides: Record = {}) { }; } +test("retained results stay readable while original direction qualifies debit and recovery", async t => { + const pending = {schema_version: "vision_checkpoint_v0", agent_id: agentId, + required: true, satisfied: false, decision: "missing_required", replan_stage: "awaiting_direction"}; + const accepted = {...pending, satisfied: true, decision: "patched", replan_stage: "direction_committed", + read_context: {ok: true, read_context_id: "original-direction"}}; + const obligation = "replan-0000000000000001"; + const ack = {schema_version: "autonomous_replan_ack_v0", recorded: true, + semantic_delta: {schema_version: "replan_semantic_delta_v0", accepted: true, obligation_id: obligation}}; + for (const row of [ + {name: "pending", checkpoint: pending, allowed: false}, + {name: "pending with an old debit", checkpoint: pending, spend: true, allowed: false}, + {name: "checkpoint absent", allowed: false}, + {name: "truthy satisfaction", checkpoint: {...accepted, satisfied: "true"}, allowed: false}, + {name: "other Agent", checkpoint: {...accepted, agent_id: "peer"}, allowed: false}, + {name: "direction read missing", checkpoint: {...accepted, read_context: null}, allowed: false}, + {name: "fresh direction", checkpoint: accepted, allowed: true}, + {name: "genuinely not required", checkpoint: {schema_version: "vision_checkpoint_v0", agent_id: agentId, + required: false, satisfied: true, decision: "not_required"}, allowed: true}, + {name: "not required still unsatisfied", checkpoint: {...pending, required: false, decision: "not_required"}, allowed: false}, + {name: "semantic ACK missing", checkpoint: accepted, obligation, allowed: false}, + {name: "semantic ACK other obligation", checkpoint: accepted, obligation, + ack: {...ack, semantic_delta: {...ack.semantic_delta, obligation_id: "replan-0000000000000002"}}, allowed: false}, + {name: "accepted original semantic obligation", checkpoint: accepted, obligation, ack, allowed: true}, + {name: "historical admission", checkpoint: pending, historical: true, allowed: true}, + {name: "historical debit", checkpoint: pending, historical: true, spend: true, allowed: true}, + ]) await t.test(row.name, async () => { + const root = await fixture({writeback: true, spend: row.spend, + visionCheckpoint: row.checkpoint, autonomousReplanAck: row.ack}); + try { + const path = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const events = (await readFile(path, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const guard = events.find(event => event.event_kind === "quota_should_run"); + if (!row.historical) guard.details.checkpoint_continuation = "current_agent_v1"; + if (row.obligation) guard.details.semantic_replan_obligation_id = row.obligation; + await writeFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n"); + const before = await readFile(path, "utf8"); + const readback = await readQuotaSettlement(request(root)); + assert.equal(requireJsonObject(requireJsonObject(readback.delivery, "delivery").result, "result").failure, null); + const qualification = requireJsonObject(requireJsonObject(readback.spend_qualification, "qualification").result, "result"); + assert.equal(qualification.failure === null, row.allowed); + assert.equal(requireJsonObject(readback.progress, "progress").state, + row.allowed ? row.spend ? "settled" : "spend_required" : "direction_pending"); + assert.equal(requireJsonObject(readback.progress, "progress").next_step, + row.allowed ? row.spend ? null : "quota_spend" : "durable_writeback"); + if (!row.allowed) { + const recovery = projectReceiptBoundWait({schema_version: RECEIPT_BOUND_WAIT_REQUEST_SCHEMA, + goal_id: goalId, agent_id: agentId, todo_id: todoId, turn_instance_id: turnId, + settlement_progress: readback.progress, todos: [{todo_id: todoId, role: "agent", + task_class: "advancement_task", status: "open", archive_state: "active", claimed_by: agentId}]}); + assert.equal(requireJsonObject(recovery.recovery, "recovery").repair, "durable_writeback"); + const obligation = requireJsonObject(recovery.obligation, "obligation"); + assert.equal(obligation.delivery_allowed, false); + assert.notEqual(obligation.spend_allowed_now, true); + } + assert.equal(readback.spend_run !== null, row.spend === true); + assert.equal(await readFile(path, "utf8"), before); + const identity = requireJsonObject(requireJsonObject(readback.identity, "identity").result, "result"); + assert.equal(requireJsonObject(identity.value, "identity value").turn_instance_id, turnId); + } finally { await rm(root, {recursive: true, force: true}); } + }); +}); + test("work inference excludes only settled selected planning, preserving ordinary progress recovery", async () => { for (const planned of [false, true]) { const root = await fixture({writeback: true, spend: true, From e89e0498be411545c4b4405273ac63ddeb91f1c8 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 13:13:58 +0800 Subject: [PATCH 75/80] test(windows): release the actual lock holder before recovery Signed-off-by: Tartar --- tests/test_file_lock.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_file_lock.py b/tests/test_file_lock.py index 31d575252c..15c51345b5 100644 --- a/tests/test_file_lock.py +++ b/tests/test_file_lock.py @@ -46,7 +46,6 @@ def _start_stalled_holder( script = """ import os import sys -import time from pathlib import Path from loopx.file_lock import exclusive_file_lock @@ -57,10 +56,11 @@ def _start_stalled_holder( operation="stalled-holder", ): print(f"ready {os.getpid()} {os.getppid()}", flush=True) - time.sleep(30) + sys.stdin.readline() """ process = subprocess.Popen( [sys.executable, "-c", script, str(target)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -73,9 +73,10 @@ def _start_stalled_holder( def _stop(process: subprocess.Popen[str]) -> None: - process.terminate() try: - process.wait(timeout=3) + # Ask the holder itself to release the kernel lock. Terminating only + # Windows' venv redirector leaves its interpreter child holding it. + process.communicate(input="\n", timeout=3) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=3) From 5f1a32ad7bf293c38ae718bbbebcb1ef0cf69e04 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 13:13:59 +0800 Subject: [PATCH 76/80] fix(heartbeat): qualify composed recovery guidance after rebase Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 25 +++++++++++++++++++ loopx/control_plane/heartbeat/budget.py | 7 +++--- .../testing/cli_output_budget.py | 9 ++++--- tests/control_plane/test_cli_output_budget.py | 4 +-- .../test_heartbeat_agent_input.py | 2 +- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 0e09ccc78d..25cf8a5c03 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1313,6 +1313,31 @@ fields; this does not introduce a new telemetry system or receipt gate. ### Budget Failure Decisions +Rebasing the original-direction recovery onto `5b224f094` retains the explicit +failed-guard recovery instruction. With the same native Windows interpreter, +physical fixture root, 320-character scope, five capabilities and explicit CLI +route, main versus the integrated candidate (`8675ad488`) measures 4,850 → 4,939 +normalized body characters and 5,514 → 5,605 raw JSON characters. The 48-character +route extension measures 5,042 → 5,131 body characters and remains over budget. +Raise only the thin presentation allowance from 4,900 to 5,000 (61 characters +of headroom); preserve the instructions, full routes, negative case and separate +JSON and host limits. This qualifies the composed display input, not model +compliance, recovery adoption or execution cost. +The full-scope native pytest input additionally emits 5,211 raw JSON characters +under its isolated physical runtime path, failing the prior 5,100 ceiling. +Retain that failure and calibrate its presentation fixture ceiling to 5,300; +Markdown, line, growth and semantic assertions remain unchanged. + +将原方向恢复变更 rebase 到 `5b224f094` 后,保留失败 guard 的明确恢复指令。 +同一 Windows 解释器、物理夹具路径、320 字符 scope、五项能力及完整 CLI 路由下, +main 与整合候选 `8675ad488` 的正文为 4,850 → 4,939,原始 JSON 为 +5,514 → 5,605 字符;路由再加 48 字符时正文为 5,042 → 5,131,继续超限。 +仅将 thin 展示预算从 4,900 调整为 5,000,保留 61 字符余量;完整指令、路由、 +负例、独立 JSON 上限和宿主硬限制均保留。不据此声称模型遵从、恢复采纳或成本收益。 +原生 pytest 的隔离物理 runtime 路径下,完整 scope 实测 JSON 为 5,211,超过 +旧 5,100 上限。保留该失败,将展示夹具上限校准为 5,300;Markdown、行数、增长 +和语义断言不变。 + The thin heartbeat's 4,500-character presentation allowance did not cover the complete combination of a 320-character scope, all five runtime capability flags, Codex App transport and an explicit CLI route. The existing public CLI diff --git a/loopx/control_plane/heartbeat/budget.py b/loopx/control_plane/heartbeat/budget.py index fc261fd7d2..13ccc03c33 100644 --- a/loopx/control_plane/heartbeat/budget.py +++ b/loopx/control_plane/heartbeat/budget.py @@ -24,9 +24,10 @@ # 5,715 chars; retain those routes instead of shortening the instructions. "brief": 5_800, # Full runtime capability flags plus an explicit CLI route and 320-char - # scope emit 4,851 normalized chars with unchanged guidance. Keep that - # supported combination; this is not a transport or execution limit. - "thin": 4_900, + # scope emit 4,850 -> 4,939 normalized chars after retaining failed-guard + # recovery guidance. Keep that supported combination and the oversized + # route negative; this is not a transport or execution limit. + "thin": 5_000, "visible_goal": 4_000, } NATIVE_GOAL_HOST_MAX_CHARS = INTERFACE_BUDGET_CHARS["visible_goal"] diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 46344da6a7..a8fac26c04 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -329,9 +329,12 @@ class CliOutputCommandClassification: # and the target's larger Markdown ceiling for the combined rule. # Body/transport limits remain independently owned by heartbeat # budget and native host; qualify the complete merged fixture. - "small": {"json": 5_100, "markdown": 7_100}, - "crowded": {"json": 5_100, "markdown": 7_100}, - "multi_agent": {"json": 5_100, "markdown": 7_100}, + # The native pytest fixture after integration with 5b224f094 emits + # 5,211 JSON chars at full scope (including escaped physical paths). + # Preserve that 5,100 miss and all semantic/line/growth guards. + "small": {"json": 5_300, "markdown": 7_100}, + "crowded": {"json": 5_300, "markdown": 7_100}, + "multi_agent": {"json": 5_300, "markdown": 7_100}, }, max_lines={ "small": {"json": 18, "markdown": 72}, diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 4537e2f9c2..f54b24e0ea 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -1764,7 +1764,7 @@ def test_thin_json_budget_retains_full_supported_scope(tmp_path: Path) -> None: ) payload = measurement["payload"] assert scope in payload["task_body"] - assert payload["interface_budget"]["max_chars"] == 4900 + assert payload["interface_budget"]["max_chars"] == 5000 assert payload["interface_budget"]["within_budget"] is True assert "refresh-state --goal-outcome-update-json" in payload["task_body"] assert "Local outcomes never replace root continuation, budget or stop criteria." in payload["task_body"] @@ -1804,7 +1804,7 @@ def test_thin_budget_covers_complete_capability_and_cli_routes( # Preserve the historical 4,500-character failure as a characterized # input; the allowance changes, not the complete emitted instructions. assert budget["budget_char_count"] > 4500 - assert budget["max_chars"] == 4900 + assert budget["max_chars"] == 5000 assert budget["within_budget"] is within_budget diff --git a/tests/control_plane/test_heartbeat_agent_input.py b/tests/control_plane/test_heartbeat_agent_input.py index 201ddf1b2b..6a53693694 100644 --- a/tests/control_plane/test_heartbeat_agent_input.py +++ b/tests/control_plane/test_heartbeat_agent_input.py @@ -65,7 +65,7 @@ def test_thin_cli_preserves_readable_admission_and_authority_with_host_scope(tmp packet = json.loads(subprocess.check_output(command, text=True)) assert packet["ok"] is True budget = packet["interface_budget"] - assert budget["max_chars"] == 4600 + assert budget["max_chars"] == 5000 assert budget["budget_char_count"] <= budget["max_chars"] assert budget["within_budget"] is True body = packet["task_body"] From 0448a0ed43ffae2fab97187b1605c02bb5878d83 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 14:07:36 +0800 Subject: [PATCH 77/80] fix(recovery): retain original completion facts across terminal closeout Signed-off-by: Tartar --- .../coordination/todo_terminal_lifecycle.ts | 41 ++++++---- .../work_items/replan_semantics.ts | 2 +- .../test_declared_terminal_settlement_cli.py | 3 + .../test_quota_settlement_cli.py | 77 ++++++++++++++++++- .../control_plane_ts/replan_semantics.test.ts | 10 +++ .../terminal_source_conformance.ts | 18 +++-- 6 files changed, 125 insertions(+), 26 deletions(-) diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index b65110b360..06064f7f84 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1211,9 +1211,9 @@ function semanticDuplicate( return undefined; } -async function exactStoredDeferredParent( +async function exactStoredCompletionParent( store: AuthorityStore, input: ResolvedCoordinationTodoTerminalLifecycleInput, - todo: JsonObject, + todo: JsonObject, deferred = true, ): Promise { const operationId = completionTurnOperationId(input, false); const saved = await store.readReceipt(operationId); @@ -1223,11 +1223,17 @@ async function exactStoredDeferredParent( receipt.operation_id !== operationId || receipt.goal_id !== input.goal_id || receipt.todo_id !== input.todo_id || receipt.command !== "complete" || typeof receipt.delivery_read_context_id !== "string") return null; + for (const requestedNoFollowup of deferred ? [false, true] : [false]) { + const parent = {...input, requested_no_followup: requestedNoFollowup, defer_no_followup: deferred, + delivery_read_context_id: input.delivery_read_context_id ?? receipt.delivery_read_context_id}; + const exact = await terminalReceipt(parent, terminalRequestSha(parent)).read(store); + if (exact?.status === "replayed") return exact; + } const result = canonicalAuthorityObject(receipt.result, "original completion result"); - if (result.completion_policy == null) return null; - const recordedPolicy = canonicalAuthorityObject(result.completion_policy, "original completion policy"); - if (!Object.hasOwn(recordedPolicy, "effective_claimed_by")) return null; + const recordedPolicy = result.completion_policy == null ? null + : canonicalAuthorityObject(result.completion_policy, "original completion policy"); const policy = input.completion_policy_request; + if (policy !== null && (recordedPolicy === null || !Object.hasOwn(recordedPolicy, "effective_claimed_by"))) return null; if (policy?.claimed_by != null && policy.claimed_by !== todo.claimed_by) return null; // Optional transport defaults preserve the committed result. Even this // reconstruction must match its entire original fingerprint; no supplied @@ -1239,9 +1245,9 @@ async function exactStoredDeferredParent( reason: input.reason ?? optionalString(todo.reason, "original reason"), delivery_read_context_id: input.delivery_read_context_id ?? receipt.delivery_read_context_id, completion_policy_request: policy === null ? null : {...policy, - claimed_by: recordedPolicy.effective_claimed_by}, - operation_id: operationId, defer_no_followup: true}; - for (const requestedNoFollowup of [false, true]) { + claimed_by: recordedPolicy?.effective_claimed_by}, + operation_id: operationId, defer_no_followup: deferred}; + for (const requestedNoFollowup of deferred ? [false, true] : [false]) { const parent = {...recovered, requested_no_followup: requestedNoFollowup}; const exact = await terminalReceipt(parent, terminalRequestSha(parent)).read(store); if (exact?.status === "replayed") return exact; @@ -1337,7 +1343,7 @@ export async function executeCoordinationTodoTerminalLifecycle( if (todo?.status === "done" && todo.completion_turn_key === input.requested_completion_turn_key && todo.completion_requested_continuation === "no_followup") { - const original = await exactStoredDeferredParent(store, input, todo); + const original = await exactStoredCompletionParent(store, input, todo); if (original?.status === "replayed") return original; } } @@ -1717,11 +1723,17 @@ export async function executeCoordinationTodoTerminalLifecycle( requested_no_followup: false, operation_id: completionTurnOperationId(input, false)}; let original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); - // The result phase may have durably deferred the original closeout. + if (original?.reason_code === "coordination_operation_identity_mismatch") { + // An ordinary freshness-protected result also owns its read key. + // Recover absent transport defaults, then require the full original + // fingerprint; supplied actor, lease, evidence and keys still bind. + original = await exactStoredCompletionParent(store, originalInput, todo, false) ?? original; + } + // An ordinary RESULT may defer its checkpoint without requesting + // no-followup. Its receipt, not a Todo pending marker, binds that fact. // Recover only an exact parent fingerprint, including actor, evidence, // validation and lease; the pending marker alone grants no authority. - if (todo.completion_requested_continuation === "no_followup" && - (original === null || original.reason_code === "coordination_operation_identity_mismatch")) { + if (original === null || original.reason_code === "coordination_operation_identity_mismatch") { for (const requestedNoFollowup of [false, true]) { const deferred = {...originalInput, requested_no_followup: requestedNoFollowup, defer_no_followup: true}; @@ -1732,9 +1744,8 @@ export async function executeCoordinationTodoTerminalLifecycle( } } } - if (todo.completion_requested_continuation === "no_followup" && - (original === null || original.reason_code === "coordination_operation_identity_mismatch")) { - original = await exactStoredDeferredParent(store, originalInput, todo) ?? original; + if (original === null || original.reason_code === "coordination_operation_identity_mismatch") { + original = await exactStoredCompletionParent(store, originalInput, todo) ?? original; } if (original === null) return terminalFailure("terminal_completion_receipt_required", "Same-Turn closeout requires the original accepted ordinary completion receipt.", {}, "decision_rejection"); diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index f83fbe6d0e..816fcb243a 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -195,7 +195,7 @@ function admittedLifecycleSource(request: JsonObject, obligation: JsonObject): J item.completion_continuation === prior.completion_continuation && item.completion_recovery === prior.completion_recovery) || (item.no_followup === true && item.completion_continuation === "no_followup" && - item.completion_recovery === "lifecycle_reentry_terminal_closeout" && + TODO_COMPLETION_RECOVERIES.some(recovery => recovery === item.completion_recovery) && (item.updated_at === prior.updated_at || (typeof item.updated_at === "string" && parseTodoTimestampMicros(item.updated_at) !== null && parseTodoTimestampMicros(item.updated_at)! >= admissionSecond!)))); diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index 859ad243ba..5e85d2462f 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -589,7 +589,10 @@ def call(*args: str): complete = ("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, "--evidence", "Original declared source check passed.") original = call(*complete) + direction = call("checkpoint-context", "--goal-id", cli.GOAL_ID, *binding, + "--purpose", "first_delivery") call("refresh-state", "--goal-id", cli.GOAL_ID, *binding, + "--checkpoint-read-context", direction["read_context_id"], "--classification", "original_delivery_validated", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--vision-state", "vision_closed", "--vision-summary", "Original source deliverable validated.", diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 1e2c2c7d91..9b4b126093 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -1379,11 +1379,12 @@ def test_typed_blocked_retry_without_successor_defers_the_only_todo( assert next_turn["should_run"] is False, next_turn +@pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_scheduler_cache_misses_after_todo_state_changes_without_run( - tmp_path: Path, + tmp_path: Path, provider: str, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) - _initialize_fixture_authority(project, runtime) + _initialize_fixture_authority(project, runtime, provider=provider) guard = ( "quota", "should-run", @@ -1448,8 +1449,78 @@ def test_scheduler_cache_misses_after_todo_state_changes_without_run( refreshed["status_projection_cache"]["miss_reason"] == "goal_todo_projection_changed" ) - assert refreshed["should_run"] is False + # Deferral removes new work authority, but cannot discard the admitted + # Turn's original settlement. Recovery is not a new Todo dispatch. + assert refreshed["effective_action"] == "unsettled_host_turn_recovery" + assert refreshed["normal_delivery_allowed"] is False assert "selected_todo" not in refreshed + identity = initial["heartbeat_receipt"]["settlement_identity"] + assert refreshed["heartbeat_receipt"]["settlement_identity"] == identity + assert (index_path.read_bytes() if index_path.exists() else None) == index_before + assert _spend_run_count(runtime) == 0 + + binding = ( + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", identity["turn_instance_id"], + ) + rc, context = _run_cli( + registry_path, runtime, "checkpoint-context", "--goal-id", GOAL_ID, + *binding, "--purpose", "delivery_result", cwd=project, + ) + assert rc == 0, context + writeback = ( + "refresh-state", "--goal-id", GOAL_ID, *binding, + "--classification", "deferred_turn_checkpoint", + "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", + "--no-global-sync", "--suppress-external-sinks", + ) + rc, retained = _run_cli(registry_path, runtime, *writeback, cwd=project) + assert rc == 0, retained + assert _spend_run_count(runtime) == 0 + rc, direction = _run_cli( + registry_path, runtime, "checkpoint-context", "--goal-id", GOAL_ID, + *binding, "--purpose", "supplement_checkpoint", cwd=project, + ) + assert rc == 0 and direction["basis"]["todo"]["status"] == "deferred", direction + vision = project / "deferred-direction.json" + vision.write_text(json.dumps({ + "schema_version": "goal_vision_replan_contract_v0", "state": "vision_patch_proposed", + "todo_delta": [f"retain:{TODO_ID}"], + "vision_patch": {"acceptance_summary": "Resume the retained task only after its dated wait."}, + "path_delta": {"outcome": "no_change", "prior_assumption": "The selected task remains required.", + "observed_reality": "Its current source is deferred until 2099-01-01T00:00:00Z.", + "retained": ["The original task and its dated wait."], "evidence_refs": [f"todo:{TODO_ID}"]}, + }), encoding="utf-8") + rc, accepted = _run_cli( + registry_path, runtime, *writeback, "--checkpoint-read-context", direction["read_context_id"], + "--agent-vision-json", str(vision), cwd=project, + ) + assert rc == 0 and accepted["vision_checkpoint"]["satisfied"] is True, accepted + rc, settled = _run_cli( + registry_path, runtime, "quota", "spend-slot", "--goal-id", GOAL_ID, + *binding, "--source", "heartbeat", "--execute", cwd=project, + ) + assert rc == 0 and settled["appended"] is True, settled + assert _spend_run_count(runtime) == 1 + + # A genuinely fresh Turn must still wait until 2099 after that original + # obligation is settled, including when the projection cache is requested. + rc, waiting = _run_cli( + registry_path, runtime, *guard, "--use-projection-cache", + "--turn-instance-id", "turn-after-deferred-settlement", cwd=project, + ) + assert rc == 0 and waiting["should_run"] is False, waiting + assert waiting["effective_action"] == "agent_scope_wait" + assert "selected_todo" not in waiting + listed_rc, listed = _run_cli( + registry_path, runtime, "todo", "list", "--goal-id", GOAL_ID, + ) + assert listed_rc == 0, listed + [deferred] = [item for item in listed["todos"] if item["todo_id"] == TODO_ID] + assert deferred["status"] == "deferred" and deferred["resume_ready"] is False + assert deferred["resume_when"] == "resume_at:2099-01-01T00:00:00Z" + assert _spend_run_count(runtime) == 1 @pytest.mark.parametrize("include_detail", [False, True]) diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 7d5674dd28..da278c0cf2 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -581,6 +581,16 @@ test(`${recovery} restores only its exact admitted review and still owes Vision` assert.equal(packet.semantic_delta, null); assert.equal(projectReplanSemantics({operation: "qualify", obligation: result}).accepted, false); assert.equal(projectReplanSemantics({operation: "qualify", obligation: result, agent_vision: vision}).accepted, true); + const admitted = {...row, no_followup: false, completion_continuation: "active_goal", + completion_recovery: undefined, updated_at: row.completed_at}; + const sealed = {...original, lifecycle_admission_items: [admitted]}; + const sealedCandidate = {...candidate, obligation: sealed, source_obligation: sealed}; + const sealedRequest = {...request, transition_candidates: [sealedCandidate]}; + const restored = projectReplanSemantics(sealedRequest).obligation as JsonObject; + assert.equal((restored.settlement_action_packet as JsonObject).settlement_only, true); + assert.throws(() => projectReplanSemantics({...sealedRequest, + transition_candidates: [{...sealedCandidate, items: [{...row, completion_recovery: "unverified"}]}]}), + /identity conflicts/); const historical = {...row, todo_id: "todo_historical", completion_turn_key: "historical-completion", completed_at: "2026-07-31T23:59:00Z", updated_at: "2026-07-31T23:59:01Z"}; for (const items of [[historical, row], [row, historical]]) { diff --git a/tests/control_plane_ts/terminal_source_conformance.ts b/tests/control_plane_ts/terminal_source_conformance.ts index ad3484be95..8a54a02c9d 100644 --- a/tests/control_plane_ts/terminal_source_conformance.ts +++ b/tests/control_plane_ts/terminal_source_conformance.ts @@ -88,7 +88,8 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut assert.deepEqual(await loaded(store), saved); }); for (const closeoutSource of ["turn", "lifecycle"] as const) { - test(`${provider}: deferred original no-followup upgrades once without new validation (${closeoutSource})`, async t => { + for (const [requestedNoFollowup, deferred] of [[false, false], [false, true], [true, true]]) { + test(`${provider}: original no-followup upgrades once without new validation (${closeoutSource}, requested=${requestedNoFollowup}, deferred=${deferred})`, async t => { const {store, contender} = await factory(t); const goal = "deferred-terminal"; const fixture = productionScaleCoordinationFixture(goal, "native"); @@ -114,7 +115,7 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut lifecycle_grants: [], authority_reason: null, decision_outcome: null, operation_identity: {kind: "completion_turn"}, lease_idempotency_key: fixture.completion_lease_idempotency_key, lease_expected_version: fixture.completion_lease_expected_version, allow_user_gate_auto_acquire: false, - requested_no_followup: true, defer_no_followup: true, + requested_no_followup: requestedNoFollowup, defer_no_followup: deferred, requested_completion_turn_key: "original-turn", requested_completion_identity_source: "turn_settlement", linked_successor_todo_ids: [], successor_intents: [], note: "Verified candidate", evidence: "Validation passed", reason: null, clear_claim: false, validation_declaration: declaration, validation_receipt: validation, @@ -128,10 +129,11 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut assert.equal(todo.status, "done"); assert.equal(todo.completion_continuation, "active_goal"); assert.equal(todo.completion_recovery ?? null, null); - assert.equal(todo.completion_requested_continuation, "no_followup"); - assert.equal(todo.no_followup, false); + assert.equal(todo.completion_requested_continuation ?? null, requestedNoFollowup ? "no_followup" : null); + assert.equal(todo.no_followup, deferred ? false : undefined); assert.equal((await execute(contender, request, async () => true, null, undefined, check)).status, "replayed"); - const closeout = {...request, defer_no_followup: false, validation_receipt: null, + const closeout = {...request, requested_no_followup: true, defer_no_followup: false, + delivery_read_context_id: requestedNoFollowup ? request.delivery_read_context_id : undefined, validation_receipt: null, validation_source_provider_revision: committed.provider_revision}; const changed = await execute(store, {...closeout, evidence: "Changed result"}, async () => true, null, undefined, check); assert.equal(changed.reason_code, "coordination_operation_identity_mismatch", JSON.stringify(changed)); @@ -166,16 +168,18 @@ export function registerTerminalSourceConformance(provider: string, factory: Aut null, async () => {throw new Error("No repeated settlement verification");}, check)).status, "replayed"); // After lifecycle closeout the exact accepted Turn RESULT remains historical; // changing its actor, evidence or lease cannot borrow that receipt. - assert.equal((await execute(store, closeout, async () => {throw new Error("Historical RESULT");}, + const resultReplay = requestedNoFollowup ? closeout : {...request, delivery_read_context_id: undefined}; + assert.equal((await execute(store, resultReplay, async () => {throw new Error("Historical RESULT");}, null, undefined, check)).status, "replayed"); for (const patch of [{evidence: "Changed result"}, {actor_agent_id: "agent-b"}, {lease_expected_version: 999}]) { - assert.equal((await execute(store, {...closeout, ...patch}, async () => true, null, undefined, check)).status, "failed"); + assert.equal((await execute(store, {...resultReplay, ...patch}, async () => true, null, undefined, check)).status, "failed"); assert.deepEqual(await loaded(store), after); } assert.deepEqual(await loaded(store), after); }); } + } test(`${provider}: protected completion final CAS preserves a racing peer and historical recovery`, async t => { const {store, contender} = await factory(t); const goal = "protected-terminal-cas"; From 1c30d627eb7491b1fb2c8134e66d6af0b94f39e7 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 14:15:40 +0800 Subject: [PATCH 78/80] test(ui): qualify current packaged checkpoint recovery and reread Signed-off-by: Tartar --- .../checkpoint-recovery-browser-smoke.mjs | 5 +++++ .../checkpoint-recovery-retained-desktop.png | Bin 84148 -> 84133 bytes ...checkpoint-recovery-unavailable-narrow.png | Bin 40345 -> 40486 bytes 3 files changed, 5 insertions(+) diff --git a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs index c97df97533..64a56d11c9 100644 --- a/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/checkpoint-recovery-browser-smoke.mjs @@ -98,7 +98,12 @@ try { await page.getByText("LoopX · 核验信息暂不可用",{exact:true}).waitFor({timeout:60000}); assert.equal(await page.getByText("原执行尚未完成结算",{exact:false}).count(),0); const reread=page.getByRole("button",{name:"重新读取",exact:true}); + const readsBeforeReread=requests.filter(row=>row.method==="GET").length; await reread.click(); + await page.waitForFunction(()=>Array.from(document.querySelectorAll("button")) + .some(button=>button.textContent?.trim()==="重新读取" && !button.disabled)); + assert.ok(requests.filter(row=>row.method==="GET").length>readsBeforeReread, + "Reread must complete a real owning-backend observation"); assert.equal(writes().length,1,"Unavailable observation reread is not an execution request"); const held=await command("inspect"); assert.deepEqual(held.counts,{result:1,debit:1});assert.equal(held.dispatches.length,1); diff --git a/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png b/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png index a3510e2730f09c03bd7fc625e59ed0505b0423cc..6105187ea858d24e6b992cb7abe9459bb91b4b3e 100644 GIT binary patch delta 67964 zcmYgY1yEJr*F_PK2I&SV=>};50qO3N?nifA8foe7Zjf$~lJ4&A?*1O?@0B@vtoPBqxaZgqFVD~HgQXU?i|C_g0(|@(qd+yZ#nJef7<4SG zacMku3)R)TI;-WplI_YK!fXvjad+$jxjqqC*iAC@#?f2dI3pEc7J-4EKmAmP)Lro_ zydOXkb3evV(?bL_%g>L+&vteBzxvbB3(-qzrU=J5*3*NOO@=;gVTrj+8NBmQP zCs!GlSK`I;tDyF109D)To9Eod;7m$X@xRn4`wRYCkho<4-XF? zx00>cK?}~uOD&$Bo*)ni0iq~!lbh0WuP{F)6-ZUv?9HbzkT`sczNJ7<8q273L?rMm z19!e6h@T`_T%b-tM^{=>BFPn~Jthe~Noi-t@6tAy*jQzRKdE)PgU~>=28~fdIZc(M zoWE$-#QJIb=Y;?8$D%g07NtSYdvy)@_B$)BW52gBmKCg%!6HlFr{+0q@(@xgzZiq0UV%PvL_l%wD! z4rN@qoU90Z{@giLs?l{2K(RwUNJ~YfTNCVHl{U`N_CDT?PI7dh_i_145(}_yBH%x7 z1R`wmkX#$>wcXX>YBR*KSXPCk`dML_Xf_|P+x+elewdZsM$GLH@?)c$&OKAP^`lCS zPEw z;w9)GnB#867+g1_<9}2ph3Gt9=Q%NHNErePq7Ww{q1&IX~i;`G`D!RqjcTVIc-=OVbS^ z#4A*EN*M&eS1B=EJ`LDJcJu8@>=x^is$rZo48FE|vK`j}T)Bu_1rel1FcWtR2h4No z3lo$QLfqnuMuMgIk~q~IKCx^xiQ+o%8%4t#8iJ+@-(I7ELznW6!Z%Yj3jDs%^YDpA6*&n|0_%d|p@k7T1O7C$MXH+~$97a6FtF zm*MAiIRiF^`JW%Imw^{cLoUwhO4ZBqyf-|c1*^inRodpUxux=}qbo$av08mjs2&%+ zx1-Lb_tD7NAYfWjP`@*g#rW5+U$0-kcK7gTyW69=`^e$ETP*y?#>Zih z@IP8DH8bgV`vnB>T`$<#b8uMBu_j6y*)|P?0om_+4kf>$cajAju58^fAgv3c0mz614SQ!xq^XnWBy-@o(5GJLum`+XvE*HRVgsNm4Ph8h=xf->*cAS(DqLfV*bZ&EZr zz@|&Cp|vIP)TeY<&*|3%2F|cPau6PIFgN#wOMxI=Uu#sone1*78b%w>1PyJd@Q+mP zS)gDEkGBJRf)M?1l;5Hc#BMqFnSnvO`3nQh(7go&ugguM)A`n*(O}Z3RY9xAy*S{O zRQmBeesSI@z0dCGq3?HOxpPB8l1|D~eV_& z1NY;Lq&oB2rfDSP}r0q5J zgjIV~uID5BIFYKOwOe;Z?xUYAtD1kqO(VM|Q}TW9Ah+tO*S*bTr1SkmX3$+pfda;1 zRSukb{kY_?Mcf{G0h)i%1XF@h;KI>OkK$_gkoA_4!{fvi?;QNSv{saJj7E?|G{Cd7 zE8j-W6;A=)Ewa-M_14p&I5;?T>eVO|orx;t+PUea8g*qE-w=8+qLQq`#yxxAF_g|G z74z$}#>?2Bj(g59)>`QV-M~_QnTmrNbPJqr2*Z(1Vbqt4Zl_}-8Q$eo-=JP{`4#2l@Iu|)u zzCjcz75Q0e3b)b#BS6RV);~%mSKibD3U1~DR8EtcKFvwCE6mYv=@DKWO~;O%&%$Re zDqG#MckY-~BVRnlUOUON)g)$MW+ZH-IxVTyfe56=J4qwwzNdTMQE_ppCk*xW1_cFC zQBh@9Bq~Y2dThXHl3Bf5r~rn7W24(tr(Aj?eWQ6phlaTutRlf( z@VU`Y7*eV9i(o8DYX5G&K8#LOm>1Dv`|Lc1vLr(j^EHY;{E&^~9<$8G&Yt{Gri3p! zvykt-*)ad&>eH{PP>&-1=b-uKhwh<|U00RlL5Eb1s}YGt*ZQ%e3_QU3hiB0g!ZC~a z!J785@6F{`5>NXz2U<^?s+?H9ZKz~1 zVZK0Oa*vrjUT;>Y0*)pk*&WfojWu*?KePwKG~DFY zPad#F1>IqJE0P*}<@$XwROnL&i%s!)V81hKv)(=X1rI6}5S@N{dSYn3S&O6700+d^ zP{*e|MAOUEeAE=Y6an&BaH{V&Bo{-2z-CDt4jX=pqI!CRtMl^Ocvg(B#e=j*EE~HO zLx%B?lbc&`F-x??;TuIwByCoHtEN!o#1;eu3WV5a0mXvvI1&-O;~ZCn1f2A=jP}*6 z@6O&{z)cnbGV)=xR}DsnhKDni+2Ak-5s#gPUUDmcX9zn@ z5hNj>i~J*}Ui;^!o}V^Sf?irp__B@ z{_4|3F@!E*=+vqg8jMDTQ@*7hQ7VTHG?aL*6~9$6xK-RgR2dI@?&ijWmRqF+#LBf> z4h|0!Em72G#N@I*GmU>kCEXfIZE`+c13OtwfChw+_3h^S(r!rCSF+&Z*-ULhQo!$n zW($suloED_gY0^{gXBQiCjyL=mX97WvgUzkZ9J^0E`C=>sjV!!)blgk)E`L%B`xdg zMN8O!T}E^SB4i4Np|U#^fzo6ZZhgpoUuI#9QchNOBu5PGF+$w>?qZilr3@PZR6iV) zo=^nJ6rr?5pQjqEnh2>^DBhI?Qf!ldGD5P&@wj92^buzM7O~!+qD$q3^Y>AbH|mpv z!eeKb#rWZjfH&^4`NKoh?w$%^nCPD8`m7;xZ)l-eB;53QwhwI^n`q%%%)nQrdYw4e z;TaBE9>d-9B*>L?LksRr%6Pj>Nxg_%=&U!V$O(Ii0Rpc50XT(IkyKM(fJ|d-iCQ|| zP0BZXF@yMQvIRzIpksRXLp4ZIQ+6fq*kT1&d-C*)3g59}nDShSmTvmR=1QpSuD{@N0o7vKTs2qgdaGc>Cd0aPvob2eYYtvT9)9c| zMlytnWl-Prg>yq z3X^9+<=;D$22p+RNfa_dxu4EKtzlWJ?RM**Q8+C61S+{G)anF)=E;D%!?&s>F(EjD0J?v~iaJQF?USfjCQu$0ORcWSkWDyZ64FY?oqJG;Bk43J)Vz|2qjkC4&4)q6 zoye7l&%TopDE9ZXR*;wfVLG0-o#qx!=uoa+)3>6msq>SmNFXx@(%KG3N|iVShi+&} zZJhr-H|=aZk+AsWl9>Ef!EYPgbw6mHtQhv}#Wv#^0={5SVEoNvG?Dl z0e7%1&4z{sOta^N3+jo5zDCv#XGt*(*`Jzq}iv^_uFA3HzaFF#|_Yu3A7nxPR4Ua49u7Kbx!GD8F9=Bzg$ zOJ+s3#LdH+B$?A1{Z4UidyhC5##nlf+R92>POVpA6ZXO9D7>RPN>g!)PJE3~=5}S; zNri;S*#Uvy=xJ!~UoNgZ&cs7vGBWw@hv&om*VotS{J;|ok$c8+q7d5%EXZ#tC?q6= z>IWJMmmkG2+*Nada6p;H(9UiZdc$}sBUg6#CNJ>GC?r*tp&|470u0G z*f~!$mYJk-1aA+NKn!fIE8$MSYyn9FTCOZ?#5>zm`>SG4OPPW=6Lge-YP=5iL! zttZ#-kE@*ckLqseK<#6P;@eE5Eo_y8g7hP>2s&*|{R236qhY#x>|ZVv^(CQ-iy94A z&0H_mxsuKk0C#&ll^)XUGsIW~#3edbVeP&TEVJykb?(sPE-AW2fvLHTJ6_~sJ5O5OVFRk>zdWc$O$ zyn_sx3PTWPF;W!wv%PmT&QKtW;ImtYqH&Z&I_vzY3Ls%C4stZE=v}ZGr()vudGQbv zY=41|WtVLN(`&34)Q9Eg$7SH@JiWTQ8Vq)psaEQpu62QDx77NLQl+WeJ$XRSz4GNV zhlR#Rw8lI+hw6GuG5$6j5wjoG@nRjdm;2K)&Y>T8oZMzGSGr-kWE~(1lSGI2{p`hn z`|s6dpNo6I&_W;Ii?9eFV9v zi2xVY{KtqOwE|qrQSy_v=h>t4VYqBOsYS$9D}}ACL+Gb(Hb94Bkd#}dU|-=FY`(1T{Z#o@Uj-frqYC)SvT?RJ_Mz(lmqRm5`SQ!Ek$hM@Z+2E= z`2+SIMYl9o+QZvfX?_y3@x9+$1yh8#oO|%tAiCC3%x6zUEM`|2s!+(So(Q@$s*4rc z`29D=dP!uCyg5a*o#2Ir_&g1i>*S~FcZZJdR>x?!-fktMFBlja@;+XS+oKVpqMq)T zw_WW`_S<`7gePz^^F$XXcj%~#o7EI#;X&%_6uAwJ4&>%5*cV2(GY5nn^b`%4gyA(x zW28G2I+fi!M_KVG-JU8ENrR!|o%QcS4X&5e9`Eh81~N6?-FX$sf_K$>^*WHp&Bbe?wel#Sp7^&+OHZWPMkLb{8>ZM-wN1oUd~ zPlMj^GLJp#x4IO57cd>e5nJw4tWZ|zPg)5T<9kN}H}2N+;CKkce;LB4>-r*YYvlaa zV+*!L0(rjn6YyD*H_tMsg!`aNegjtC=-znRxFW2e|~wRC!wB3drWWg0vo z;|qyMk;ZYxnkfLD(@r!E@ZGb;^>VE%M8|p6AB;a&Br!T2Egq+}qn*dit&K@0LvyGL+Wq zmhw^)+EC!_={zwqcm6*1KS5!UzV=#9{37YH$v)WsRXU6L4g~Pu2QfQayiP zU!NfESH4C%2PY@vINud$v)D%Lg_m6pCZLeprAl?dP%$`4{Eukx5>|X)UkHF99#c~) z$OTq@prgiGm?%~`1|#1HKd(kOqFd9$aM|?!)vOWT|2~3l0tz z2gJp(KS^aDlX&3ma*HiX-gz?rE$pS<>8*d z{I5eTYJvsE)u--19bhP6XGn)4n)BmF)#hS! z0z*SX@KS*ZrD{=8(H^+z=lkO}9HKSZ?$3cepTeZq!HY%poW*iGqaVQ}(a`|?x19=D zFtCvGHlv`Rpt^cuZj$zcVMSeCxen0Qn&|OEmP`OfSyuLwsH*Sdhk4@QnH_wt(*z9S z`hSyJfr02eK%vHv41qS%^OvgwAIz{H@?psvCy5$EcxqDzxm~*6AX;vU3l{d&J^&@Rd(+;D+g}8)bHknOYQG#IJe{wwC&xgVc9_-)^!X+dQine!|QOR;_33 zS+TAA`BR!jU+;&9y<=~i8fgRCzve#0suf4z^{Gkos0b*b@ioq(b=V>m7B?20VH5f@ zd#eHc{uM@B0}1h%PPBF=b2-bEzN@Bh zsmJv$ywFuIPsUi$yM={Dz)7e(Lagp7$8_1~Ft3JarbIwr83W|4%d!J_dw1KWUGj}3 z(RzaVYHRQ+9G-pQL8A;kCS4@Fvud1mNT$ME z_^i>d|Muh+C>y(C*I%3uFl-;tWj6q7>DXe3GL%#-*CkaY{h%uWUM$jKq+T2eW!N(6(o9 z&-MDUlSVA0VMo$}_NU8@yYFGxMGBJ6U6oCk_u1APcr%$S$v9=MdQ0L`lWN>Sjc$_8 zXZB8{!UfFPMgT)$HUYt&p(s-v>Bgi{MdVPaG7$G3k!9pWdM`K671sK!^- zgd-j)U$R47jy*GW^i>`wL3uY(`RE=44YJ7_1wQoRf-5vE^}>9jE3du81hsQ8+*d`mYx=@LFEDE(lprlQ$d0>U~T@ z1>d^i41Z0*!0GeY%!VriotlQJ(^Az@YvNK@qn4t=T@@#ZEFBH?+qma0i-D<%mloMN zh9Z-P;nWIi-12!DM(@}4@LHWxe^y*ZO(I@c3myN|Bh`L2fk}mG7wtan=WMPG99dNn z+nY#0%W_ho>Q4KfOuo#zmwGvM`=De*Ji)=97&m3v#MXiO6kVQa0@>l0nTdqC+36oM zrRV*|6z7nFHcFAY^-u$IG7d~EXIzE4J&tiyA-z?1ikV>0fJMtz!RA3n0uLqZ5-Y>bdih?+VV zwlhTBnIs;0<9^g>B&u8jA+%|fJ(vr1ol~aSwkQ;y$JJ=#x=O66^sEduEao{gFy0MI zaI;K!SjF9du` zJr0hL>EW+TnJmUj%=+~o4xQolPv1g#uF6p|gYEr}>IrV#ObCXb5~3am}R zOJ%P9#ofzpnM5%f>0e?h#POYK>EVv^{(2jMvBpsRjT2Uj5wOE1b%El@YcMzx7t60V zhlPjRm&>+LZ0`OOC1)J~MKZwTs*2=Tq!nU1ysxlZ8q4TzhTG zEB%)1;jphsc2>j{*~jfkeaqkX&1TiFiFd!LzNxd*xlA6}pwax1;qZowDDcXbg46lP z*OY*l!=mk?x*EX89iyPN0Fq_Z-v<$OE`-es{Px-3d+W^~o;j^Nlv+%s;qZ+vFrX|m zby#W){~ge$zL!S!goj+@$12(VT#{VR{x9(sxJQw(8Uzt}p z@wrwQ+eLglCHP(RCCg10`l`t#vZli<6B8$c6qX$}*jjAY4QLJ>s$ky*%UE=(29^_d z9=eB|DCboTS0}rQC2*c$Oz=fDi4JO!0?2MsEq)+97=+)P1IvfS)1x`CMvEe2yqR}K z^plU@I4}MrZ;3bctF^F(`l!{U&ueB69@HUx3${B+ajql*s!ah)3rVu- zALBJX90_@R!W!renXrv2bAdtJ1nJKsWW-wrAW6Z^17WqTm@VmHN|S93LyM2a(i=%^ z4@S^t3k4Drd^?s&&iM|kSjg3w)AExm*y53sz(U1-QcAWN8)fKscj_WEaysoVkn&MC zzr`J9s?~cc^=sf7Dx~fNitH~Ym!t3SI{X=FIugj5GIzSU-%}YEVDl?t6IibX+>xD6 z3bIO6+hNi7WssW9S@JhHR^mG3|@m}mg@+*f4y@kX%{!qGI_BzcpR#d+HYZ?6x3VF^dFIz37fXJSCe#I;;nx!+0q3Y}(uCXku=P&)g> zP9l`OJ;b&U30qvxj>zKc^u+{Ei4NPynm+)!(xE?PpL3uocV>QYbsno;ooGReevwV0 zZ@idEuj2jNIv=Asdj@7IF)Vt`1nzj6ALR4HN>#AQZUQy@Px#g_sEtMi);hcs$%vPr z(nai$w!=dn)|_WqF&erjKY&4&9Lau9$N?0rfsCltL6Ep{o5Ujp-DYi7QN%MYkD^{zQA}46=f2^-9<8h@9sbh@4<1gbiDAm6kA4=&B+~g zXDu@4_2RKUS%PmIKe_CWv+H{<9zG8}ee0;Rsv>LT&cka-$&=E+Z46wWT2m)=0i@JpK+vtGBs; zeoAB&zB>n~E*=khMjR=tP*nb+&nIdto>#(d( zW^cK-aqz{qyDD&-pOzb651Xx?GiYAJba zG(q@D&Q-HQR!dS-Y6?I7vF;BfdVZ*jNq8ONCV1J4KD`^gCH6{h9dP5Sbd>b{ix)b{ zIYReABHVD1es&jPh-{iEnMNjC9w&QPf*cbxvomZ$^9M3w%+*Dz>msoW8=CPO>=f4& zp1|3dRe<9otG2K}4i_S-4{vK@*vX_}Y2v6;S*k(N)o^N`dE6tYV3cxhEV&#hhfnj| zR$-8H1l|H=?Pb0u9Kn}3+HY+gM!!5j($f8LbV^Rnt)Xu1bgSwDnXe<21|dl^wAHmH~iLkVR=7eea;+Kj}3f~26&@5PMqlbS?MuZCrZS*PuiYE4P$ zqK^0(EHqp)*Hu@ilhg=PNwFShjN_0^kE`R|0X7=zTY%oSt88PARC|e z)G2upAC>KiR@7;bY}WZf?7ohDuJ~=LA+n*JO5PVAvh`KJi|F!w=!)UJi6doS-%2R{{G7 zNheTfTltpNkzdnXiuouis^x`M7}ZMsh>giN4%9t0&0#UI;0(MG*`pv{mUv|h%v3qw z38xhM^j^fw!MTEN!==3G7@A!{QJof~Pl}7~)yi!QgBs1lx?A{n4_opmbp|u!r`5H;Ce6VXRVH>$ zv6)X*Pe=wVtIwo}pc74TTq&OsL5uL8dUzk>xqn zMpEB%0$1Q4ylr_G6$PQ#K#~zr6c!oLD6s9ps}%m6=oQ$AD$NZ@0kmg}jMAx{iNY-$ zmmKlMpYw`j@+K4H51Nwx7|Bxa73iOOikzX&3-ROA`ilyR|p_Gvi_dROLO;_`IG999EWuQt&S(^_7OQEp5ToN+b1~V>7 zxZiUgJsL3yFuQZZCAHMw)moJZ=P9paH;@gEEDr(7y<=Y-qHZA%4qB1LRvA0`hU>gkQ5!ANQzGQ)E|jRh@e5=- zqF0w@Q#bS*mtr#|XLGJtImD7Gucl11!CKhsX!Ln&HY~C_m8Dj?I!kUzr+8N=AcVtw z?-yA$bRTNdaR!EIWMP_~qEq4?HEfi9pcNE(H@&80bana?*7B>$JCm6iw#=d?wMO#1 zLm|Nca}2AT!kBt)g~2cNXr|Q5vZ9bhLk>tp?5y-rj^vT>QB0oA{!CHt<#SH`j$BZn zG1ju!K?bHrDeMG{SB*u3vAAtI07&&TPJfapr#DX?qnTk>c>yObG+Qd&#}y~u$oeuA z+Ru7h_7W$Ah^H>=LW9_|Y;Qmacczf*byNx;D5P@K?zYTP<4n~3-ZA`I^%z}UR3V8Y ze#$z3yAh;>dAC+_@WGvXT_ z&CT81+>D(Y85$Z|T3Tvv&-8q}`~R^x$H9U=6gD^=6AH7Jh?|_hBNXO;fx_?uUryx9 zGvN|vN`1*#7>14^DEv<<4gpc|#rk}u9Wo?`{WWzkoWb9sVE>#3l_ajlW1RUK7@Zx= z7TlRE0E?p!=D+Mz7HGG)3bqOV*K<+d>+4DT@gds2H7Fl8?EM*VkuM2(2hgdQ|9VzT z1h=K+h)}r4fI@o&{unTYU0M-@U;3#y^G`qy?)1}S206SUE))|O@Y^b9C<)!N`Zp9H z)-X{nSJLfz(P??Z-_LKM(II3~aJeU3T z+#8HP_hWDy)Wuvr$ynOXnP3Fia=tb!B4W6&PY;unjA*K#7j8``Esp@(XL1?+TPP zTL+6fQ_;IO8NGEEo%%f|Ju_vPcOmycv4cMn|(V8WsJ@%kBA$j#2y z@12C%Dj*x?@FFNlq%y20i(qUffa!VV6DFF_s>Fw9W{Q|+KoY@_gQ>OkfrmaR2th)8 zg=R)JxWoTr09bTh3TI~#bGorv=U`RF*)GFtYHR1_=4QkW0MCUA`JjdR(@7N_FdY2n zafrb1#cNz{1#jw$-En_6hep#ju(~TBkWSs3ET(K;XmR5xMMFhxr!67o>vXaY_372r zGIWuXOBQVD2nYBlqbsY!An*M0n70o0igKX#<6(}!sO}&C8Y)Zv$kg4@Ps7VLKB*R& z|1CH;bU6}TK%#TUXzrw4yRZ;_-Xd|+)FeGNNMBEAK~Nu|1REQ(+amdNC2u|lI60|w zS*Fv$RmmMh1T!eJVCqmvP;iHa)%oP>zvC_2qsmm@0M*KXdipy3`GN93Rztr83@d|mk>C-!xVTK0X=TT|T_1TIw_GzbGn?Fj6H4GQ z0ooOAFWu^xsZ2Ig4NpK8*q$?moZg?M>TPLCyZfuHFNxu(q7jI8tTQxm4ll z{B+_Z=jZ2oiTjjh5tO-7b@94FVg$f0-Mv#$IZV z1zE21&3K(FF!~G@0bYy^4@VrefYWBg$1l!b2;8$Q3;$7h6&006N@HYl!^eZ!Br`fs zg(>LaJtD5d-w&`vwW=5gQT494bQei%t?%xatG-xz>^qW|fa9XJBKQ5F1*w3hOo4yNy^mlu6HLS_xp5c>xmDb=C+omtz{tac` z;w>hww#(2QBa7{dtNv(M`%iynTn9UoC?LVIVr#Vj~nQ@Tz>_xvu0*O`1tsp zK*XfiREILaJ1|pQO=3AjP0GcEtN)K){G??hx(n;}5zL}8eFR3cghj*gZ+rNkbQx2@ zDxI+4U|hzpKZ7x7F5=~9k>#S``tY4)UoK%JsS6WgN2N@$Uq3nLCtysim zhn6){sA0DML|b=jU30rsltpz^n9!URZM7vgbWG&2Dvd6*_)yb{6d8ExJQ- z2F&oa)ITbNynBTyg2FON>isP8l8 z$iX2YgBtOiTwGJ-I_a~76aP_Rc*fqi9FC5dUjV=!&fx3(hi^94*7Z)uU>3D7XcDad z1o!(58U3&A&M>1zdErBcFBaV}z@^VWgxlEKQq$1TY{tgKyuBqD%$Mv*3ygU8xA=;# zDsjE(NJcxD^Gn#2^+q5>y>zafeFyjNwVbgx9ID_YoDm5U5A;9g5<+%cjpDtHy?x0X z$|!UQm})&40n?VxEA75xWAgouLeP$mju-V=VE0i#^rxZ8pUmx2V7fbr)l|^wEjWNu zsux3{_W|c7j;ujL{Cp24G%4*r3%99{&c_(W#>S2kap%g5Q8wsy1pJ9@ekRe*ekkMv zB3?m!3d{QBJ$51zhRam(8txbF$k-{i~HaRG3%3#Lw4NwpY{vQ)|2@b20yRaWCE=%I9QF6QoqxRRNmGhU&8yE z7pUNMy)+GB0i7iN3Sz?aRD)FG=CXysg_2#Z$2q*@j#l;Yym{)s-1D|W#)N=O}ZzGS8 z*#2;4KjSjG5g|S;D&b=hP$DB-UEl-!UA`0{ljq~?NIQ(yTGaZ%8 zKG*BERB=IRhx2q~tzOB=P8*Nc+YyA=?>gL@Q_=Vj*6;bIDX2Ku=-DEu#v(eqy2y>G zRVQUtvx{D%5ixVIS>#6!8Beg8xLOT(1uR|tcLSbqwalOn8|`D?n*+7Q-yY|DuvA}lp)K6{UwK59cn57p!sf!xH#%D^Y*a!l zxB5Yaf}*&w;`Blb*)-!7Vc<&jw7gJV{ZL42L@#EsNPMz#v^UT8Oy2egfL5#F9MR!+2fzIt4 z7`NZmDit5#G~U-YvJd>K2Zc63D@2KN?q>F{=ZoB&ulu(U$g>1 zL9(rWwtO=N};7`ARb|3K3#-I z44du2ay7CpqLikrK((kW*ZoD5U>jEwD)3^do{5?@5sEpYf{}f0FwDv^HjG{2rMs`( z;ANxW{3zgL!yi6RSYPZODi z39Iy6n3s`?h;KW*2?9!325Hgmf$Nbn0W5O0v2-+NiPlXbTp2hle3O-HcmE!p zR8}=SaG|)hNrKf_Z)3Mky=(&3_+R><#T{sbiT)8t$dWXH%^$QZ<>vNwQ+++7*IaH! zMuyAz)`2d1k2GMyEmgZ|{~6*MIft0N(<#e#ae@>Gsh7Vpr zlXVkRO%#+59yQr%2T2D(-Uo2i$6v7OfixL`quV(}bl>Ul#uodCCChdRy zNk{jZRJBknjA~ZkCrjxcqOk#OF5Nb?rfPLXVc_(Hr#8B;W13GDSx#eV?~F+PklF0& zSWZ`{zj7{h(wIys@w19fI5uxttPxi+ASX}x7@uox?XbUazexzrjDUyu$>`>G9~Bh^ z&hTff-~;}*)4Xn})mQ9UM1MeId)14`)qwbq9-X))GTq zBA&O4!nyUSXA6ZaDrK6S4trT2_m!_=X=dxH#Z}Vo9rrmXe-wsZNrn6f_h{K|3IXpX z1aL}#r;QDhuNgS8Ju>peGI#+>YVYgi((dT_ishzP3ayZi_PJMTPS+n6;f5c+XMCmI zpQ%L1ZF;UhEk``Th^wbz2`A{6w{pJItX0@9JH!#@5MSTa;c?2HC_N^l&OTn@$cN@p zi?Z>d!H0mjfaMg_)g^(XX##8J*Q1&LxazNLac@D)^u{c8#xT|LKpSY{u;w&cdw;bP zq&YxsyLTd3Cz3?YVDCuZ=q_ClZnxjrz>{B_3d6HF#dp8Px!xtsYH~?PUiU35sxVX5b2D@!=%Gb_u_u5X(Sdh{ObTC>I9v=SpFeTBBs11bS)g_)s zmpxRUbvM`nvw}azE_w-r=i#OXN@MLemCJ?>Lf=PF%#^akTXEL`v#B-IrbI*@N7X+9 z%Li7F7IX+#b+A2X_s-K3TdK_O%RKJy8ndIph#=aBrcCS1f`V`F@cXc^Rl`$LQ^7K? z$GgkXFMlGTB5$bI-$j_+lvQ(9eqiVQ{^+n4>I1}nh|N8E;%yv@(tuOXDkREQlrLHD zJiP2K44r4DygpFK|B>ih<+0R2=3ibJ%sSZE!VdPn)IXS`q{$v02IJKjWIRf%LIoV^ zJ?-AGM?0d}*I-GLKZd7r6K<~Q)^>X}Zzba@T>y+vmEts|#sVa! zZ*qC!BYQL#=UL($D12Y1&Bseyn?x-ReRSymdRzCd_35F%L$P^aw&mOlRpD__ygWtd zx)Y;c{6?9YBt&TmtQdUxg#v|qX))t6^_o5KuMG^q_4W6IRfj4QNPkWn*m`q@^2_ zZaC7pX_S=imM$sj?(UTCMnJmm0^i?vzxzCQ{|D!sz1LcM#hhc#F=pTtmi^(*Juz>= zPTjsf^*vTnXK%{ecVDI^nv$9oKFPxcliew7HYwpYjGDhb7~#5X`N}o(w|g8Xm6nXw zS}o<16#d>GyGMRcQPwc~k9M|IW2Dun!X#tavH=;9?J5|$yw3+fpMQ3}ynugDpDvp` z+)&q@8wc;zkKK=Wc+8Pe@ToEpl#}0Ut``eibTAKU3`4!+W1w&FBgx3fsHrjRWGt8-F8i(cP1X($l?e$NhK5-a zzI_$&B?$1NI{)kV=F11LvIwxKhGj2=bCmMc6DA1Fdu)FLV#zqjSVtR1LPFAVIT~&G zhOMEl?!^Ug#Qy_Z;d;bP<*bjK4AvKsHCp^Nqm z(&TmETxD4HW$n#2Tk5j1Px0{qe#+N&;(|>~%=7n=ZNh3?mo z3AP83QcEY+I2`K7W{j+!ttEj;@aexkL!a1>2u6%q3}lZ+gm2R>4SxZz<^nk4hKn%< ziG+NPY$a^?_))-f;n6vL{ok(=H5?qc?!VpziLbq}>|uSC0HX87ByDj|BGUlb6xvK3 z|9z3;Apo>BK+y8;YG_Nz{?Atg`II2yhJX+@$BF{I&cOkMmRmQ^0j#R5x<&RfvbPw&>hEc7Mt1Pff-gH84Bpq57qqjHu;?3fF) zbEeh~V(cMs>umrwftzIV)_oKdl)ERh`!RMto_3SGy9xH=yCmfa#0RiXCYE8~N&i3p z0lU}E_V(Q#I+ArOyW(m!rXM!!d!IFA(Chz5MRK@#f|u5IMtI6Ob8NtDJdzo z-rL6CUJb?U=D?{3QUofX-{5QlRR7Y-NkhFUuNTBq@uarEv$5}zG6AV z+P?mzlfg-6Mp`aDw_*^pf`h-jx{4Ql^XtwP1M@NHr?RCL&{Y9GE+mKg?umpaG6qc@ zGo+>(!vXyK)ZXmxUAi#EmBo7Fcgd{c7zv~i-TfcbU~nPsu>R@d3bc~=mXm6mEsmEg zVgKC|;e$#>HjWSj;BQ&l+UjEkqRP<|J3!X6tUf%wmemvQ_*R-_xKVnb$G7x%5oZjnQ@! z`+J#=ERnz9Z;}S&#eCH@A{-pVR*7mTKIhk?-TC)R3o8+cu^XFt^S>-Hr$Zo|g}oQA zdb@wqu~Uxf+&XrN-0h7Pj8WaJXnx-L7bqxHBBUXYm@dZ>XNji;d1?KGjAVWa*#!kcV3|aE@*w?ug?HMnB`?3cyxc^EmJWw8pqB$>(!nJf0K)nnyj;}p^>R7zv-K_;(sGfV^u&|;WgibR>Y^@?vSq0Z@=Yd zf9@pm%xw2IiqcE@(IAZ@qf*DUT=;(ubC+j%JpVf>i*;$=;uomBS$C^r6D)X|V8fvw zqIyoLSv+3+uyq{fYinjV)h@)F>sL!Z5TaFZa6hyb({d4$=jxh%v_v5P&V3$0yw<3! zZmJE3wAE3OUM_ift+!IzlOR#Wz(qzCV9FoZBGwDRb=53QT?onKh; zm_CmhmaccZj>Q+-Pj+5L64CAqL5%SotD~&y;dWk?7@~IVDs7p_-%7R8!+Djy_Ky~j z#R`@%U=IKwu%!Y07Eu~bm!}1MB}mu3p>@n7Zn}rJO?`!7q>xm>Wo3Eg3-do1e30LI z`HP=|td+tmN%-ZZkql}~Qj&=F;`z63&(NOrn2E{B!G^a*6kD?bxwE<8nx22MEbNOO zjrT6MTb<9j$}*P*4fUT5H$oWx79{?Ecc0W{nFL53SwW^e0z|9_a6ue1)tXf@T;-KM zJ6@L>nsiYK#c^3taj7*=l1g8!S}_R;ncjX0X}zIm)T|mBH!G%JUR?b>n9u2cScI`v zZ`(av*i@rTB?MC-M2#1q8G{st2;a&!Ej0VW9&{fm(g(RF$r7K1-;XLOj2E5Y>HinP zd_$@em=YA?QNqF z^|GWMiPDHWpD>FjZ{NDc)+yTH27#l%QBYl{SwJP@PfHd}Z|-74S>s!^AbbfDerUMG zJJ&~f08vYKYh z?GL2v+{SX&tEbst6j1;PO#0O;b;@iYN%G*q0}@WlXAFT`?B|mPNjo#uu>S>XM(JOZ z*h4^DOX%H%B4q`0+5{>%DQKgu(D2()@gFw3`%+R$*s%W!Ovi~U^VBvW_rX2d7+$*q zqB}>UN->nCzklV6M=^rK3NQc@`CT0C?N5Pe>hI?#D<{_;K-f%%iF|fH&ST-04fW>l zAG>!p-;tz=c5xbj?J{K-#K?dyLJkSzN2}|uc;+;z{_32YgEmL9fnXMC$pch84Gj&o zGGhfrMPa55YRO@e1kML&4=emIN`pM+v6LflQAdv*jCyYmriptB$dF@~S*E_cy zK}hM7!_8Sg1W;afhlAz4`kTc64y!i`0L4;9(t~YmKpVF)HJJ_MWZTX_t@0OC2~?LE zc^C!q)H?=g`W9lk>e7Gw_0P&bftc&JR?X`~L%ust{kMygA)w-R5v2rCTp+_`P;8&Z&C>s%Oqz^0I$k$URr2oksSZZ~YvlQN$d@6d}*;c)1-Z7+8iCyB?eN zHjjvn7yF7d1HZ}+2(7`Fgnw{A2<+0U>@->8LV+QueZuYOv^M%x$gah-S>7+qTTKgG z1J%v-S0blNiaKL_@F{av@n5#LxBdEbeUFS;rWk&Bi7Gqfcg4#-7}(x(-$}v7hy{j8 zUd%r;Pk;At?o@8ixSqSEa`)_a*4Aw49EEk~v?ULCcz7I7AuVNP$I^ zkUvimXrEzS8|J$s3vCLE-kr3s&_sjKMtcR-oiFbrs;5yJXLEIU^ptdVR$l6w>$?}JfQBS5O_iCTV3Az{!ZR?*Kr9`oJDmb6 zJsnCw{@*&>#qqo!@T96BEi}R_7;s{Rj;ttw@!qGn;Ii zgknPG=I$l?l1r(%T#~)WoN3)qV_2A55iGl z{WTHSmt~W}+Od>(zAhKXF_`AjuH1r8B*GtOzx35ZG12#%84R>t%0#q#dFBQA6`|>I zOg_o54NHt~%NVNt+|V{#3v)C+rkSjzoeil&%sMnJ55CpsP^{>i{Nr{>Oz%3azMyxg z+4wg!zw9?-%`PsFe%rJ2o1<`yJZTbdc7yta->2j&AI!*}_y1{$s&zm|=!rUYg{5g+ zw@Gf{wQE=sYKm1#lAJ)n@Ow}!TIIBa0tzRp5K!7W&tU#*Dsjb`-?XOBqNHRK{iCB> zjwl8Ryh7tAPoCgRx3skI9*xK?_RP=EgOmed5s_})VpQ=DSVuN!qlvg#A82-#J5z zbuAVja_L(MSn9XF{J}7js+*vDMm<^-^oDd7yq`!!1@k37$`9BjICMBU*)1*dc+1T_ z)F*#Zw^&duhpeCtB)Xu)frWP*dON8O*NLiU!R@Jgl+u%4c_I)V==Z|{&D!%l=*9`y z>q8@};PpBJ?-%TJZ-#*xQSkF|GrRfUQ<0bHC$?yxC;f+=ta{T8!z7lxH&?mTQyP(f z%YePkO%!w$1V6>Z#E1YPGACzyb+1cnmahH~iuzN0*%uM&uN5)N$#{5SQJbOJ_RCML zJMr5x5#QO)9zm3I%I`poKm)D$gV@P_7i_X|p#YsweD%-$Wd_^l*IcHyX9S&QZAVw6 z*eW8(2p|2=L_W|mWM=y;D9W6`v5hUou^5d+^u;vAr;d;7OZGnS;6{Y3MzINaYfd@o$8-}{2mB!0;jgx zIwLObcY#K=p$AzJKE9H+&r7DLatZk#ff27aLo#&Z~WZ@k_Tm{XC#$u3jlAm4nFkWw_wfR znheI2l`2yR-gko+P*^pZL-YWiUR?uVn--Upu%<{fS?RgYQw^Y zfjhIIbU!yIlnw>GkEzxb%5#mf3osvZ?EBimwV1ng!INli^EOj$aq9v_!{J56nL;cH zo1stNu_v-nY6uR($fMR6nPKGqprxwJeazoQ6FNudN2{i*Ty*r7NOt$i&9^4A{i_E? z6}40KH$o=fwfRcgu1L{og&;*1 zeNG9)p%(_JMG`2!tF;D}bdM)w&Onm6>^n*Jt1*Pw#j9G6e|;r}We#?s31+r~ z_sz(AHvSLSBoX z{WJwa0!(#(e-TJPT8H!{uxLgw0B@oOXn^SJXGl;v9ZwqAL?&|Eh-KnN)Rit<7k-jf zd;-tz#lDFJ1<0EzQ^r_>e0({2))sEniutJ4$N8g?_cyvFqPj6Bu>*t^gOyBc}gQn&9Mn>-8W~H z)8o8UXJ$%$|206H$D7|u`OSMLRqw)@$^Fg$feidOammY@W+oIH8nL& zlEC5txk{>YCXO3rr^Ub5e)w@Q$GZyqCxe+146PDiO3Cn#CGASx;Ih4E>_WGJI?^xViZ>@tPRHpxy&_007}0rS-w z_;qW@{qvSTuY%@s99;j_&u4qCO8e_{MH}s*Bm_NtY^{?Yw%W=2bdw0;*kNQJik14mMYgN zJ(wkD#ye!p|5EdP%p?n(kQHO0pl@MdqZef=f5J_!Nxx>i{zuxMw<(ktc5J&u`+NJt zF)8WOc+v$ZCQ&T+hj+zU+rwJoT;xW1SP@=Z>+;itK?z6~?pDp5*2^ zVvuM}x!eRpQJMm>n)c0o*%2;r*BDjxB8POdkxfw_%#*827Hs#ay;T||F$%?E`s9?% zE$Mh!JwXP?6Gnbl^@7Fqm7T7qmCuA$Hy5x=4R@eags`g%N2#mpP)mnMJa*@=#*g8J zKczj+@<}}TQPoKCW2FXr-)pV-coll6{#;z9Xi`l~(?dcpjCpWD$ zI|b93DdQm*9WNB-V4tNniI>?bQhnhcoAV&`F)IT9#vZc$zix zvsInyh4uX&K3&3cR<$feibtAwx!k2ss(#4SuCgTkd|12oCZ;Ha-jrJDy%%lpLiwSr zH`63f#W}lkEX7F@EfojynMkxI=115>j2y&-oUGW@-(UD8cnSee2?)^DI9TZuZg$hc zt*OJD_=!oQa^z1gGOOOp*&!-FmUb8avAGbXG_l_-$KIRU$9O_op;^{(5jLz<^k`-^ z^EIOZSK@rpRN!8uFco>JVRH@MmawFMci67{D$L5yboiX2bncos6t8H4=F3Y3jZUa- zlF4)}GMgcFM9J6sb>BWYNPiO@!VW6&)fz~}$VGfz190{cLF;tmN;J=^CJ;HXL;WO2 zuMToV5KOOT^BhZ!ZsKHiFOq&1Ik&Wd$G5w%)ji9xLlyXK^AVCB z6LNkN`K!P#$!J5HkjF*Qa?~#T8?^bPvliv7bFxh`E-358Go`&(xi9dLEHP49(w(gK zZ)g?{Mkda%GlbF)T*&B#{A-=RPl0!Z@P@&ls1KxsG#q!R_da8lar1{-`rv&%4g)@3 z524yMsYJPx-G|nzJVg45a=)Ax*LpArp5%>oN3Z2EINx;iu-l8vdevjqL*Mk_H`#9) zl%pK{kq1#-<$jUWJFi{@BL>*$EYV;oJDql|Eb%lU*2e33W*}3P-@3$ZQ<#$J$-9C` z9{_4;Myn7XY5%g;O&dS>+BGpKLY3>z^A1Q2-?Iyf%2oOxXYcsywd-;#QkNJ z+WXFbZDa6=yU$U+`hJj&CnT?AriyR~j)Tp%pVl``pW4u!@|z>#_a2A{gR|G>5lD8w z7c2JG>@GLpk{dZrdvN|X9zbUH&^!kLghdqp0s3#C2|OO2YH)-g>!%G1Q3u`|5&mDm z#MIx@^Bs`yxL-23ks8*9V4T+FQgUm>-;jZXF@OvbzM+(PU~XX{T`vvNLBU1_u7-yI zjZT*ZX2gBu)s$h%uug(EX1eBqamB^Osi~m6oA z@Q&~S-w{2?1ped$6ncg>QcL|oYgUnAC%ZFr3=Db*k#FWG{Ve_=g9L*hXV>wM14wlS zA%Ugr%(vF6>&rAasBKA%$REXHM&&m_)aX?5UZl_@hD+gq{#_KhfdG|a-GQ3#y+}2R z=WX1AM2dujloZ6MjbT=v#K@nF{_DJi)iN_eR&x)k`NtXLynAzWcb!8(7{2`v9oGNn zKRfaNngqU*6PEbwC8Td7Wm*SV07FAVU)Wj8iJ_)pXse%15zx( z=sl6BbzD_kdj%p;3%IzrgFdB|4!`wWj>K+oAwQ-#w!OcLJfAh#cL$!^(?y=Q|&lnCY@|7MyX3i97Dq-{pFaYxr-_PJ@F00r=kp5oH~HK zvV)imNG`-6VtXx6wt$(c$v4#k)KoY!1!)kpBL*_oEc?DE_W2#62#R1{oNy_!7TX5> zJv>C?Gcv8*eB*nv(QyKo}s3$xoD*c+eXJxo1J5=b!E(cB)@Y{OZ@+?lypI^)T6 z=-+={Xot78LQ~>8bvEjLXcx=Nx|xuuUF3YPuPIU@27Zt!fNPfMkX`aoR9J3=wOIsX zZ-sG5QQ(>fzeV@Tdi^^jTbmGa|7!VUr9XJu(RnSUCZ(`iEXa+0gH5+73x7t>XEt+j zI`M0M{VHp1bN{K)n$+hd_h#SvADYQMJmoiZt}Z)*y;J!Ajot(z+5;HKqX5~Pw}BNr z!AVH~K!T)B+TU}5R@G9yllzxE>aJg0m;P>`RGn;}LG6Y0ECsbsF5nZ%xNDWi^1Tsf zPABH-e3$;7Ni`pyi?k_GoFQSv!ZKgC)nX$NI{$6NZ!nvjO^}UWN*PwVnX)!+SFU@Ve`+LEUyeDw;HM)$J$AF? z=OmK}RVuY9T`mm2<)*7}G1tH;w-9vV{>f)?&Ttx$)Q=MySW-tcsZ~~IU8iz!xAH(o zi9fq%$c9gGa&a;Hry};6zfPB5-Y=FC&O7yM`o3GsosEA5Uu%tCE-^x(?y&94BuJa7 zsVEKCaut=-ob=W9ANYzsWq)JLI7e`x0)5$Js{ip48e*iV_n29ck%0_xZPpNQj}|kH zMDS#YnrUW4#aSnqFqH6p%dM^nP}O45A1!cKa-Hp!V@(?!v>lhot9MQASg69gAUsX z;57YtF1H&mxZHYwO31xJK|u?a5<(?0(N3!0@xj{YIl{@~ zby&x8WH&+wzbdd3Q<-9SN2d+k>)O{m`%kQ3o3LyACy-+%RfULFJQgS3u`Xps@)-% zE>28Oe9pTu;9Q}Vcw~e#l)CEMY>0-1 zO;;&n*d*#{43w1gBi&7&HdowNSiLHBJonJhp|X*>)W?Akw(Z}qI~+0JZVrJx4g5FL z_1u4{GgPv<%4KJ-xtdqWI@VMj%ba{nR$F(i7+Q7T34D{g)>?0?O&LDTMp zzPf#vHg}sC*Q4H@JrLsi!MXE(?N6Na>0;Cgw?DM?5iU_F5H1_Xv1CWCeu-{&b>XYo zen!OA6%a;!V@-Cx(b!smgPm}B63wip#5BRR%$oLpR)U)DLPM)!EvKt7u`M(nUtZ7c zusfZCm05Ti#tsC{DG9B}Ipa;3aL&hkeZkqYF&8EnoC`BLK_8!5zfs&?UiF29FO7a= zRp#DwC>=ZQ6S@-O`(D=Bt);x9W(}tb78&!(ok&YQZFM;v;SV6iO8C2%#8TY16LaGi zoHVEAbT~q%e`W2~wRjR!4?n-;TPtQ+r@1>1UjaqkYqfMbme35C%8Aj@LM;lq^{3 zoRFr=kS0GH^A*th$df-+IT_BhSc*k)P;*l?Ma0@$AS{?m)q*}U==7yJd zZtZF@hu;-qVWz?Cvxk*=))4Vqz#*htWt!Ug*Fh#TTF_oBS%p74d7Yv2HEIeu(aFMR z3ukuy@!%A9f#O~NRg5#6qkA?o)dJn*tS*JFGNnheqH0)P#==kK{+_#cMNPF;+;=gI836dm+4quJK78{*5EJ#NF`H8<7zwUwFVDkdtL^6meM z8ZiVyn*0~rB>*1~J#p=@QVd!LwLf;XXWWVI2|0ef~g;Y;(Ev>YjKP z1z_iR>h~u~KKJLMgBcCA{6+o!3o_dpO7{oA74QZCO^%F=jNfv;0T@3cBO?GX{^|Q} zj`^0B`H2`66_xf>;~!H1@zzGpNpHiDCc7M*%Kl<*yfe8{cYmDxTR=AkYC)eWj6_G{ z(Xg|M-l|fDoc3X~T;hUFfQ8W_D4?Pf2TEszco4_FX02upggk@*|Hi;z4On%Kq5>cw zmHv^&E9!rPp>~7>drGFkOVHiJ@R23Uh5vNk6BL%3XkW~1O0uI$}{~bJXHf;#D#~4+iZ>fj}n@d zvx#PY7daW=%#lhS0d;li-txTq*9A9Ud5Q$=cV@>xJsu#;P9Tu*!R)`@k;x`Jgv`Gz z@lVbfU;o(k(R5EI<+OB@CH4>b37Xgy^QR_9!{(RQ{gd)B;(Ph)? zPtdp})o-T1#i&iR9B~lqbAtWvpF_{;j2=EE%2(fXzu=GMX_IC@>dX>ZhQiB)QS5%v z<5asy%1lMO;EcXtuB0ekjQxZ!htclkk}J8GGwam1wmL#^kntn)ON!zW3T4X4-@ldg zZTxiB&uy-g@v5xGNiO1&)GI9U51p5mQeeMGE-oytM|E7*4z?hZP?F9~qSBD)gUkJu zCf%f9mr(p&G1e+6ji~S68|X7l#tTG($c?`vvyvh2C z?d_MAHpS2UTae3ov|x$+*;lqE&c?qllQ`!NxUL2SFD!qQ9&fSE@UgLFNJd|fZHRIT zgf%_Qm^uk)RoTQi{pmH78N(X0yOI6pGWyUVdwf1Mnxm;2Z7%e1=zUUj%z}hKwTu}_ z*b*yg_U*K5py;zTnPvl-MHLmNz}8>QpVc0CHtwwkE!LE!#o>9bLcAY%VnoUQ?zhi) z$G2B|8>fHE7dSfz-VLil%OyT)kE)&^E@j@jW|oaLK0udto{VcpNPbZj@y^=FIN{^d zW)$@CCWQ#I=~sj)01790>41n&P*EI34`-QptuuGG)K8hc8N@`e_vnKRP zSVK~Tv@+Vxm6l#r!>LWf83>-sxSMGUj><&l#uvkkwZ%3#5W{gZYl332bTJh4u9h-gEWP+#e3)&GsMpD)th5y-Hb? z`{`9*dYZhec`uI5_&#)4{8A@pgF*@R4O5AiL{8hI(3VvD6M|ci5sdHGjXg?7V&2`H zb~CYVR$|{@*bJe^(%V!N5Iks@IVkzFoTtf}_`BH~GxXt`Jmn@4+)gJN8D%CT$e(hz zBrHUcZxJo!ndCci=Dv>#S%V~nALm*OimEt7iBI&`$_GBfDYJ5hqk{yk4@sN9`KZmK z@4jS+S{7X^dy0R4lmBkh@W=4`)zw)RYPz_yiv7ya{;lboUVecLJ%g1}Yr7@a;;@wN z62x)evn(tYQx_)2ntc^sjbi?UWaw~+R@9L7HUjr2t61VzJA4SZqlyY3=q@QO&BVxv zr#bW+b6-0`l&Fp!98RGkfu(Z?d3jmp|T66$j*e)`@Yhbs{QwLZ_joOM$)FG{K;`Gu>u+~IX<}X!@=zl~jm`JI@C&FI?ofRqZ*s&bk-=-;Wt|Ra;n_pdjX0IDo)zbQmoHha5El;zT12G zYR;g|vA|K(eqQ^8fZ1Siqs(iPc37vlyo<_AA z#`-_fD}@_S7Yy!aL0|APx?9e&{~XF%Wx~2wWLf*g$5d+g19%?7`@UgwG1ZQ|Qi*sm zICz&(Y5!h@ssa!N^{^H|x&fXX@gO)kZ;LE6Vh9O}r4Kdet53X;SZOm;vFcICO^VqK zTeTa{2}|1?U0n)R%kqp@{43C=l)gZ_Vo4Y@fGDO=4hi!lZ15))E@F>6E+$|39FYF~ zavy=C?GxowQF2%(wQ{CZIp)5N$(Zkk#a^=+?~rBJi+o%wLAf4j5nOWIPQl$?b?Yayv66Ua% z8Bf11*M#`FoI+!y4sb63gZ@l%iAM$!|WG z;6fi`kR=ZCu+@aVd43<%ZEtxkhWBOqmH)>R;XHGVm*4a_L+9Um_`iBECFx$_$V@*i zZ>#fz#m_;$z2k4S*<&{UcE|FFW&QmQfw-`qY;Avv!#WU=c zkYsv)aQLaiH zV38AcnENvV^wKVA++`w>V2tXuDRS?l6=V^|g5gN)aEW-%kpnK# zK)m~&>cocXFI(-xuoJggo9B0>sphnIdvGWu(+SAWtsR5VUh7NnKHq5e*}KY!8aTli z$B-%vOXW#rn%p>Y@`Deva{)1Je%(LG=OrD(ndl{Lzld$)dKLs}aMGs*mt zRa8`5*#^nT8?cBMf(&F~J3p^aL%Ze3#5+lUpnhrzNqV0nQ-4MTgTA`pqOqIv&J8f` z-b_}LSLAdx2QLM8L1$3fvk#BbF<5Zn4pvWw+3Cg2y!D7zGlc5jXL63~Pm${otFo4` zKh+TQ&z95^_q<+skuK@OSrz#;RPg!{+p^Y|x7QCh_qf)+EFj`PsM)+Hus80r#=pkZ z@332`RDIHY$eU^Jdo8S2Drwcq%U&h37@uhn@2J6qketb*LgR@IX=I_}N!(;O`1)W5 z$+yis2>ie((Z|bYRCV_0yLezlQ442O7EPs*QN-#!iY-|b-|i8i407WaIA+gl4efH| zWe(9_E;ZoBgnRiv(4!68pqlN%d9Jq;b8!3H?;>K-r-} z1;Kd1C6aV0tt3o{rZCFti@feg1O|T8CqMpQiHM?;$%?PX#7d`XvkPqRG1BJ zej_3{`aI{~-Psv3SPgn;oDdnk_zh`L+bsNbl44qQi#Bckn59vXh%lz7XY3RUGHGB+ zfK(vo3kr(fFLg@&O^sZ6vFz zYRWkZ-A%W0+VI479&g3WWw(;lE{=)8lz%Y;LK z?Ow^g2*i^KsOxoPHXe8_NZK7r$DKN>kTXie$UsE$q%L`*=*w1`^5Oa$4069%iXhwe z1~y^Fz)H#C{F;?X9elQ3i^MuzBlo#BH7K{XD7n&STTm`LK%q-|tI5XEfh+c+K(6Tt zP>aZF8cXPv{Hp$(=TnWOo#9`WCG4%1qbCWDwGs)|NjQwnX%9-AV&XKJ4%Z5LqMsg| zXHm``2-1yneX%@U$tMElotsIrw83ITSC<&P71%n!{MD+kTmWtW@KYo-G!mRSLCv8! z^D5D{HwT}q8zm3<(vV9(7zglpsY3ed_Y4lQ$IDlgz()bO)=l6I#YaU!F#OEhGM ztyZy{zqr|}+FDzM)OmoS&5UOBqn}m46SxiAf~`Q^#97WQL;Yf2xVWs$#?sPIO;b%x zO-qYu!0E{}g5(`S%;sT%I)Gy$6|uoV!=j3LSH`>7h1Xx(y>N9sHJVJ8R zFi`g%!TriGt9<9^AXo@;}X>k~{r^BsVsJXuCbcet| zQ$yMFZGC+O85zO|ij;Fohz+>&q(r2|v^OJ#ffC0mc%)O&?H#-Z3DFjgSz2+=B#Ht< ze}_j!l|Qr-_G$-}B*23a#uYO?h>3~g0GXY69a&K~?I}XRvl$QNBHMh;ta|~jJAO?A z6G$0v*@e6_h0L4$)GG-IBepg9Ie8%t)x1F3crw+B0?TvfBJ;T0+ga||9LJRaN|=pJ z8A9Isy0iywbi(x5Sy(66q*OL{S>NB@v0}A~L#b5}WNWI9!w*u` z9_O2oM6kYK;}}uHp`=bqWYfx=i_Ck#O?_)rB1q2v;zzLL3(c0)|F~T&x=Z0 z%u+xfVcqO+OeVGDp0haxQKlG!f1Q;DpS*5P=wyYoY6eMR9RDi^p=DIP1fAR2G_She ze3fE3L(9NmV`YWy#vu=qI*Hgm+9PgP4fNKVdb zcbY4vspBD-$Wm>v1OY|GNM8U?cQ)%$XmpytoST2G&_NTD^n{%3R9V)0J;e6q?rW#T z)>p(VEh|4y{xcD%S72cUi|5ZD$gl-gmPHtf0rsHc; zicXZ4yUrrkfMwdR1Qu#B6Rzs*c zRRmA3!;R`oaR5JyCaN??Mc=IJWrzEHksynt9pew0RaDZE)LkQOd)_m}rwt*&tM-1L zxiI~X4uj?wF;frfLu~{jlR6wJl}Q2|Uu7%Gzig#^Bu!|NbtDk@H)5qo5^ZdG&Av^S zoJrP$!lAC^>BWFoGj;f(3tM}6(LD-iu0z^Ch=lVCC)N97yY?VnG-;g!5{I6;_e(^4 z4#bOx-WG=I6B#p`hem_f{3XXi?sM9D{f|}%37=vQ_oy8Qeqfp+p@r!_mDAlc6$#wQ zeR%fw&wi?A;$0vdA=yRop*ax|k*R5(vmNbZvgt1?o0C>wVg||Z&+Xgw`LV!+9*oy` zWh1!mOgAD=o!>MoEh;gD>Qz`ML2(w8pg*P&TAuElk96Ig%gr=w5%UMr8 z2L1Vqqb(xlV;T2LlZu19|CqL8G6KgSzE=L@P$|Nf4-+^{HY_3gV8&-;W#@VBdP|pp z3gA?6R78Oom>;h#(J=Sr7Rexg@!ki%-dzZk{Gz)(;u4IHU9HHl%%>)+t|m*a0!U4l z@t@}H4tmqHlhChQGRKK?;BhcC1aisV?M>=diVxKbY%E#NdbbtCRArRnf4y)XY!e|P zj=0)iYDJ~pym$?Ov! zcnE2`6I^t$X{A_{Ur8|{=!Pf|gSb4nlmc*tr`2ohi^Na9Z=z3r%EX^5vOAJJL-t~b zTcU2M3m_r%SQmymHJ;}dV-o14?@d=8_P*BA+n+(LZ~cuGPeaRH$3xXp_tIfjyxOZU zNlNh~zNEG0ysuS9Xg1b?rR>o+E_He9<%YiKpT|^K*=uy21F)dhY)KV1P~oWkX|=7z zmpLP=Hb0ML2meL!_kWh;YNjl-mNyR7beS}?N81pTMU(g;7f`?J8ECGhWM{B$l&U#y zMNS?`g}TnMx!CEa*CVKNKfRv|{$Ply)K=1bE47&VsQLbVa&h`61!vzD7FfxZOP^;Z zPdqc;+FX8nY%VAfkG(-BrrnHBxr(#AqsnZh{%QU+>aUoZSe1;D8YsT>{RwPONRL~l zwb6qEMgmKOEarx&H6=PbLvZmaBg5^zAJ{RY?Z+P2|D@rx|0(6wgL$->d_}!ViNSuruGeLWcRJnH&*;T)(=c6+QAWc&CapEXJ##C~3RC56X6r`*{w z%EYt6N*7%ICI+bsU-sf zVropF!Jr8T!cC2I#=@f94FNz2sdMQX5^VOxN@CIL5+Qd9kIKy3ac(EI3m_NJM5z6G z>HJK9a$;)g*2Gljc>o(AqMOF70X4Li)Fy~LiRb2jexf9%=@S*@F?{@Dxwly@qf$e- zBtcG1#`JN4X8!n;g5TCQkJ_vhg$8HRs4`S`MK?702%CytrFgj!g&b$>C#b&X=7vB` zoe4-CeW2hPK;1#fPYWBHBY?d^`~F&AyPGAilV2Sj9RXX_4i3Lf_^Yr*NA?U1#Hg3@ zv{es$0u$Du(9x#KQDZ*G7ZG@z^b0PsU;2}-u0V_@_d$8N{m+#HMtb(?1O@0@byLoc zR!E;zJSX#u7cUqY`)DTv(ee1ep`xJ?XaaR4!KN)eFQdZK-wa%IXl-|F3xmha$A-?m zC4k?TtaNM)B%DKlo{K$jOc%IZz*10900kl=ZYCrXxD^oA<3V}pu6p)^!eFrbfFCxL zJrf83GhMO%3e%FSKohc*`yLsZIxAdl-!3lB>SbcMtZS|_B3^quWpc0vn9@gMY8D|g z2nhxj7SQqbA$c%}bJ4&IUJTqwn$XH(GUV zZ<(3D=dL5?k=LJ6Q&W33&V%}Spb?ffoxz_A3JMShqM=+Eg6TorWKN#6j-U$~2mir@ zkG8v}xG;x`ib`7w41v2)I*A|n9Kd}!z~{d;Zj`()*C_?ufKA+ir2}q}i$6V4?U?O+ zzbUeJA6r{XIOxsZOSw1(wO_BLQP&f12SDpc9t4UM6z-g!&j;_K?(R@jictz}#L`Qd z?yXY3uWS5B*MeJnAG^SN?&kUgi1-Eh`%{$NtCGCV$%JmM4`2e|Nd5A=%?s3|6i@^zyr0Q7!6|L0M(~>)RLG2#3S9@+>V2QyR-c% zHC%9&?Xb0~t;{qW$++b!u+2DZmNiQnc_ee^N;L-|K%zo@r`Ez2C?t^v;5 zJq^rzme1K)S?kAk^Ho*c{wwV4?4%^Px9%;yE;R#URI08_V$};41((+^oZoIEK35H@ zFv6``FW;~^Op9Qd@%YozQRbNNs!yhEH{hQ|jG*KXBzHO+8{cO6&sZ~67nMWh<(!Kd z*u#Z?O(J+(T`Xh_br8~kr`)8|E7Ho=7c46>9nGMFP53IDCR5K!s56HD5Z^v7-!3dy z7$z29LSJJklyTc+umh^*VNX9~uigM&K9H>o3kwU(IXsC0l$P(U?B>Ym1eV$FY|yj= z?aqP%*rei&tH%N+zs5ny8JihTU<_)*7UI3Vk_yJ%`Xh~8T`$@@g{lUS2DvN$3xyVt z6Qo=N^{4A*B!{^9Z0&4!ihgzRv5h<|@bMmD; z{n&+hHZQX|=%<-;jA$cFzeU`Mav?O}e)g^wyT^xP_m8n^sBRFIa_ggl%~(M=>|ULC zXdNlUfryZ-0BSuL8XA5V=+9-;tf2v4uynYUXO$iWo+m&k&g2k?gux6U0(LxgGtpV2 zplc$ix3wk*&VDxq0thzto{ICJBod+24|vyXBCgR{+1VkYfpKxSzy!clw0tJB1F&*) za{3baww9M)LZ7J+b{3+_5t1PWma>o}GLS^Qx4lh&`(*DYbt@zq6rK(kkN_2 z_SW_Ny!qVi2kJ@uF3Tva_Y9G>o+7-#US7Koh6IeGn~9j~ONt8jjJrGC={^|VYs4tc z5J7<o;)7^$TK4_jd)Okpk zL?S&zSXTk#KXD6I-QNBdlsfQ?L10V6e*pXMSjQC&#z>07tCy)ajVR!x7Sfu}MT2)s zcRz7`-gu3f`huF08Q%WZ^ACDr$!q$C{A_R3E1yx~6GHX&K26@B#n3pegFb3vqRLJwo)RG~gEjKSh>!x@h~kw=QoK!^o~G4kxP&L%C91 zkpFBgYg7HRU3Ylk4yvb$&HuyIS4Ty)#_i%UFbEZC5ETId0coj0rKC%`RJyxktE6-z zE#2KY3ew%p0K?EZFhk5RbGPSw-&*%x{$&=kc=x;aet%Db7X(}yrJb8$WHZ#E8aJt} zv)AMUsAw1YRIQApdJBTVz*d=Ad+p~EkD!!pl=MOlD}Pph+^24PX%NREX*(|O6ui5) zA503`pVuA2jr;)FuhDo8DDqkTYb|YiA)<^Oiv+#+fmP*QqvvzWEar?i?^LpwLNK^)oTh_w4rxdB+1PGbt!am5iNI z9||;?!t*I4hs)!IpSrY@Te7O@2DTX)-|Lu}(F(yf7Lv=hINik>zE=6*V&YkObQ()8 ziIfi$$OTN+zmIZANl7hqw%#B(b(-p_iJBtSz-c^Yiv;Q|FHWpc=@HLU;u-oOAC?oY zhmLDjD6v~LuzWp0csG>$n7QgETR>;#dRi7o2GoAEin2Xj5P0R%eJT=6qo#!~kE1Le zpahLO1Y&yj+HfMUtH~2Zs+?RIptRE%?u@sxrVLD_MC~s8` z5`S49nPz$QqF2xEhT?7dCIMtz+bx3-#Z_EfB%^`Su5!z*$YaN1t^pC3!rqU&+zNug zc+a$lw(rm6)% z=lRa4xwvktd5buiM2{>$mrrrrOW~gYGy1Kgwc7n6wE`!HXlQVA%}cJ6T6yOgaLq>5 zQQsa2wm(%5XVz7+jc4-{zTjmvHiE;yVf{O(*2C|T#OTR>XSH%skBM!}Nv}EImF~;@ ze}~$v(og!U4IY@UWKvQ+?mYpsNzX^TEwyY7rKQ_)YI=JsVAM(9`#S`|19n)mUr;B=c zHzVTh!fBA^f@w1M2sANGi?JW2rv+hXfHr5n|UFa0u-w-7d`xkmS{in^!aSg2q&cmrxEsh6de!G-} zS~mKR61em`Hx%6NlS~+f7}hYd3Hf-WRBr$d>Cw<}NZo3q396&B`5o5snqU zCEzSg5gs}`PraI)Nd>VF-d)$K=gM4Tvf5oAL!<3To-whB)_lXgleE~Q< z8{+ZtDJ-&?&ENbyZ(zTnf}E~C9+76hZhnyZ>U4i|9QFBk&ZnN5neUaiUHKxqGN?e+ zI|;G(sg!-**Y(RHE{J<(u+5vz$2BH;8@W<%^RE+G_FX=B0QfoOE@rGiQB93!QQP@6 z{kKNW_g}iM55Hd!m-cmfaVmS(3XcTbr3agpnK|C#EFF84@2MIWEp?knQ$PB!@kxMY z%VgD3i{XsIx6ak6j^FL4I%SWVvN_Q`Vg{%v^V6Dc$Ax1NN~wRRlC6tUx_?2J=H}+N zQx|%$v+ao}XTL4f3W~F3oJoGz8@$sW{sN=wTyGzBl%O~9xqg?y^QGT*n@Hkacq9kcRzRwq zvjUDWOkg;zQk5^`n0&hNnuCD~A+)elkM0UM{=7v6pQQh^Ye9#Mb>CWFiNQrY2J44GXE@!L0nT?b8f|mF`z-oyu}NJjBEZ?CA@SV za$iq1-UeJzbCXefZ|3PkUjwiX!jZa-t@HE zyp2WRIj6XI2OM#8a&iF7UMyF=*C~1Z`q#|od(9yngwN({ zpDcb$0uDv9>h;a->pR8w>-VD{-AASTUZ4$PWuYV=pHG*{EF!aN7qN4%wm*~{3h}8oi9(+ zVT=CHD3s}K<}Kkbh%8HTAdcz6)>h}Izsu(wff%p6MV8Ypab=CfA42i<=w#csBkfe5 ztCpmfv&O2jHt1DK1}KhH*E zI{Ot(Z(X&E8Y?D`Q}u_ttFR3YY6|LKca=c$@J9HX+>O(cZd=gSniO>Lng8v-G|*3M zb-kyWbgq$&$kLI!&lAOgakGHFU9yY=8eCmF4E5!9#99Hkn)qicVz;5Y&7JYazuLM zY$V*fK?U4NKPfARLuje%Kmu=oG-|DymBE8J2xRYO7mU)$KP|cpKp<7x06i5W70q=5 zt>s?od(!O`Cxg6^^(g)h7MGanqoGQn)j6p5K6P7I*?k*ef9WEN?}Bg8ntWNKNi^>VxAyn*n0iC)ZmW!DMX!;gT&IjG%EHV`nc7RUW{3w zS&yjw^cT^*U6ss~fP@kQAk#pUu>(Nt7#;GXIQ~e4$cish)F6`;WX4t=z4j~hp6|(| zGE9SriAfi2ya4Q1A(mCu|Fq}1i8S6;hKPu9njt0@Yz$0j6FSTQ9)L#JCpgNDu=WAwd@6lgiPPa#TvB4%1CBdfU)Y0y$Si<$ z#^CSl{Zr#FBd+}^A{9hZX=&ME>2d-uQpv%~2>#4)v)|zSAE1Lk>l^t$Nnw;+Zrh*| z_bF{FR!IN9Pl=@@T>l?OjdoZ+-X~^?Vt0hF`NKS8J0`Xc7VE>VHrtX1)7p zwX;A0NJfsyFo52hzF_x7$DZ?J_`m!sLEHQkt)sEHmD7)Be=0b|86l!aRk?Q^lkcu| zd#5(Jo{BjOZnex8DZn8 z-YoaU5%|ZQ^b&iC|D8eK*t?^?(ZYaA1a~NhNR$uFHoN3}H+@$JjgSA-n-_euLk_Pz zx38a-_im4`8j>aCTDg$qj@du zk?+?E&OaRD95UyDH!vb0p=t4d{?LPrB(KMCMO6Z>_@9%qOOy!J4X4mt`OmZN0-mlO;rDKIyX{rq_^v~p;h zn{0`OosA#s%kK8ym^JyJS_!)|I;69kAWZ(3BLtGQpMgB$egeH}PPY4DT5SDuQHW7S z@pDg0kLue?`n8694NS|V=XLe62$ zK_P_5EU~=LdQPQVezot`%-llMfbjp&f&$YqA`j*FB*;iIRor~|dbIwse|@~q+AN$4 z7dhfmKobCpY-oY);5^`_Usyu768IT)Dv-02tYEB31+z)7&<)W4?yH|I<;y?_?Z~K> zrlR`hji!s6Q7HGC`TKG7C;zzfWimZy-Z;)rmht;E@lQP2l-b@a3&T*xMg>Jn;1G#H zd}1qO*~etyq%iYe8t(>1GdC~yMb!S42{LE0NZmakNKtiDSZK@eoX`7HdYiS3@=t#< z8WDllRPkTz|9DNx@m~z4!bqGjVJlyq zw)3$cqH;SX{iO02VMe5(!$`!E(G|PzcDi$v_?7Hh_|xo?9ilvqj;_wB_D-d{=22tr z93Ezt+CRe@pDi_3r--;&?J}wxK~49Ur^Fu_XlUtZvZfINO{d?a=I6b~z?%|$Hc>7% zZZj(-J28!auX27!QS9%&=j;0WnqgwQP5dKv>#=h$&y>3_ebd-EU&M${Ouy{o-bbCD zp4Qnfu5K?pA{1)$G}xh4EKr*D?wd3N>k9|t{wejh7R-E?Tte0W}SDV&lXe4r9GGl!AsCE54JvyySC zb}9P_jEwZovg8*%P=1ShJN8UV6`Ys81MB|a#8j`K?wmXy`dK2qtt9D%s=LWnnADet z%ai#pX&OwlepRRIb+k4+c8HD*T;a~3FwhLHuTNJ*?2t!I6a+r6spoF9Z|CfQ$~q!D zljoCHdmG&?2X~s}mzuq;dds2|e0||iB}Girm6oNDYmR?V{Qd&aZ>-Q;#$ye{8BC+p)@|E18Flv)juuj+O9EM@He+=}f)afSM487vpBJA9Y zAjBSCoyC&G--)WK0U+D;Z_(MOy~Fs84|g4T;4qPq?@x0M+ig`^(pxv5;sf2=e^kSJ z?So)e%PG4@vf|#S<$5p84uJ$2w|4yMaHodPLFV|Vb3!!8lf!aQes(m*|1%limGsPS3X<6o(P<`FBtMENSQ-;^%i%%B?6|czyQ0F+43T zo=I5}ac`zlC{7O`N;@Xjqk1MeADhhXh9F;m&YAK`^wm+-pNA8XzVewK+)OMijCRY} ze_|h2+=rL_jHkHkX7gT7wvY8-=Z<#~SR|A@FZ$QtL$mJQ3+BH4M=)VWY-fV~L5$-ctwU%#a%v_n(tTFe8?%x2dv>1I zo2?6Is#wOM{M9or8fz7=jP)3jUs)f+PKLkt8?c)#(buEd>t>H&rq!EB>hI0=>Y9Vi z>^8fydFPYemEHy7ZL1~1jt56UMuAJ1KN!<=kr*T$(b@kNLy|sd)WoL`P+v zPC~*QU5TVMgZknn=nBQ0kuRFdRI2sRPe0S7llf&KV;_@ z`)$TFY_f;-S80xp?Ia{^7=N_M2+*d${jx!e*((!!0J!J$FL@9R{%q_R%fdYYHK(}=b zm0Mcgx&^+u#QtbdJMudEs*ZV$Nr4%_WYc8-*W_wI+T-jrh!${na!N&S$aI}#goLIG zJ=KKN{$;Vzw~cD(k5|vS8)ly}OLr?Q*ICHywGzP%@|xW`mbVAEppT|`L4Ckq?(1Lb zD_R^(LyQArW1mf=MMvMirVlf5aF!xdxQKF4RE+TaIY#W7&@-V`y1=w(9?`BELRP&V zKhVkGb8*h$RC3xGqIF_N1dDHePrd&6y`CFvV!#l;Jq%k?O+4L@F%=wMMjwA$n6y$} zY-ug+bR@&T!j)JtVBZr1gm9YLvtXRY*#}_FUQKYRu=Jvurw>`0?3|F7*G+&p z@3Ajccz}}!icd{|!RmPDxaD(?E-^Xl8a$utG;hU0@?}5RiIm9Hc6zzGdLsV*LuM~; z=F7CBwG#mFz>G-Ay^FlMDuMVQ>`Z?SZL<4k%%^yGbldCW7>#4Tv&T&hhQ0`Jl#qI5G z$7zKNLJ+j(0CJj9PMM@4{ZYLmLYx8iu-ZHi;U0EOuvvMAhVU!Q`3U58{`b}yKlo4t zD)kJDL+vHKR3$|)^8r$JWJv{-99kRVceE5w^uz92%h{&ptUo8PBsp{Z^p#%m#9L3U(5mj z>C`BLA91gfA;l%kao1r3=o(TMcPTlJvF^Q2CS}zhnFel&WY;%Dn5pXx)b2znr!~ZWI^?6B`|^9^6bM5s%IIgnrn#D8?a~i)D9( zayL@@!90u^ZY@2jNywo$h@O^LivqUn;EPppgidT-L;io-)4P*a(pv5d;YD6{mvF5$ z1|Qhb%kZhchTSA@q&jsr7bnSGrB0>3qD_HDDCc zc)j}g0G7{Yasf3Or54;5ZiYss8=REW+ED{+v5Kkz>22vni_23=F7KR~yeXrUh{A)&2CuwdT#Zb=065uZWo4ag z48@5P64N}FM}tu)ST^?U#(7oAH!*cH+LMrlzi!==R)< z`}eAv!z9?i_57tn!;6FJz(WQu4lxw;#}+hYLv{(>2~@332HWQ)Oid+R9S%jb(1eNQ zW3e)8zvjE_ca;+p)3HALnag60(BT}`Iw7y~_m-B)s}FZyvO|3eUzWv|Df*nstew;W zD#PWq=-Ggxs6T}lVMNAk%IPl?zN)^N%@O?2-hx!l%#3x7Y{w7g-chG4=risT%8RKM zW_mP+2Ux9g_8M0|=oski^cQS$`AHbfXOr${ZXv0Z(s?H97S5}nTo+U7>~WDA_3e+5 zbLstaK(T_SUxc#5#ab08k&E`PbmMyOeJfB@RCNzCA#AhN!oVH}CB2U3J!KgAYx>8rKwzP_T`+R3poRhJnsHjM*V zb{-J}`*)p;XDdHd*MUy#jm=F$FyVsW4P{@i8Cb#!iXt`wyXQ1WNJwI0V!*z>md0+o ztD$a<1Z57)RA*5?BPCwPzKkalN>#Hpg%`mNK&1owEV}K$9A+5)a;tAyH?=B4L%x0%iw|DT-a>q-aQcf z6JO^oqF;vISO^MRSMS&DDv!$%U@+tnWZSbF%dT^b8h%`FY<%8^*oEqI`;8eHiJ<^4 zQPh!9g77fB1Ao~q@%Y^$KORU{6)`e!N~&&;!jR_RvEz|UT&<^G(@E^pG7O&2tZ++! zmNJ3|z1{Fw^If!?(xk5-&cf{|h|Acj^f+Ipq#4kJ+61UR)-QxHk?-!Who~I4ik1 zz|cA~8zo!83=!$Bbp{Xir5B`lR9ZF5QL^F=w$2U4Vaxe$)5rxRfHPKk5?{SiFK}{o zC0}|-OS>>S`ULuZc9!p!EHdk5JgI3GK{*li>*l7WJ8x*&*w}6n?{r8~U)(zQ_&Y8j zAYgECFeK#m$%m^yz<094){|jH!KDw{+UMYeA zOcY;QTDnQ_b5|)tbbEKVWMX^%z-zCnXW9F(?Z*$QBZN^ zm?YjC1TIRxF*x|->0YbVSqxl)A!P+Vscl#JL0Tde0$WVO|43Eb&eI%EL~HmUWmB=VI>k@dE|g`WQDBAUX9=<>H` zg(VQqx~aS7xVUHUg7`J4;PYc~&6D3lc?8=sm-rm#uZF4f(ZXXA_Yghf8g?F#|HTS) z(bG=;Nq--Yq~h2kb8*ZZd7C)%XzD5E>JZ>6mGq-_0P7_yD#YCn^j$ZB$*HM63A@JC zAn?uZhuLocYfeDCB37Io+%#Zqh2{8j91pxd=I{;m>$`7g*P+2Y=LbH%7q{$E4Cki- zT+SllrHuGxjAs_MM~I&v1$M_EjdU-LS`ORe@Mut5S=A;D;yJK9&T9k?s7K7u)*ble z0<+@X$Ckip8wkoUW+c?H8V=_;Km4u~d^EGX%h6ikA-)f=gI!BU`#VfSts+TT)&at3H z_biu;9xwK%Xrn47QhZ>n zKaeTk%lWWVpSK}zUg1jOk1x=FloZ2A7*?3Tz;M^sDfiPMOA~}wbLg&`9|L!{QJP`) z2>IJ)35J3>1>dSFv`XfKCe*E`y2MX}(6|;)#tyuwajpuxP?;|dep=0amX6aF0{-~+ zU-X0@7vfxzhW?zn&OJ0}JH;X1NBkjK?>x>4Jzvxi*`N-oq;K7%h5|l?*Y#-Fdv9l@ zvoQ4|c}@a=XsC=g3-Se<=su5)mC9&k;#D!{Vy3~ygV)_XMQzs?*zNbqX!dMM7{p;6 zf+g}TSLg+p2Ry>)YDbHhnApkRXzF^B2?8v&HgP*))X*Xs=Lo4wD=D0l*IUlY@@Jf3 zf>AG(0Utqx>~fV90A5TuP%jeEy#R<=9|PFqd;@mPQc$#as=%0uJz+?F{DCr~+=VDU zOTo}>c_rqPVq9XY>(}xUUedl=#`UJz==_}mWs12MVP^~j!zV{G2-^sq2H1+QncA$2 z>3t=3sX5OCMg|&7udUpP3zl3{+5_h@%Of;{4V&AUgP14qLOEbd*wyC!dr8RwK`;H#;Q-ulCHvg++8<9AQUDiB>1&|ZpkS+3vPuGzq9ueagR z{Rst`zE%779=l6~qypZrCwtnuRXF~+3VyyfK?^V|sAPwCw9O$p#%(oTGO#z-vnjMF z_wUWj?x*#tOsHhEG+?uizt#ust&@*1q~Ler7!zRTGCe_TsL*+l%FYa}!${Z%h~M!) z430v*c3*~f=YA^|@O5#5nx5t54g5oPvfOaIO9{;2J9q@LYICQxmZsxG%b?B|tot`wz9&SoObuTn_rtCfa;8wqxQl zN<`r(DR%hb>k2Ejo9uf4&2VrFbyoA^>LA#gp*yamBbP8 z6Zgjire?@K7Esvgc0tZ^TH2Kf8m{DMZEuXL{!Qce6!79h*(U-%>1|E$~nKyjFZ(n+E; zBHTtqBlN2vffr{!Si0-QWQtnMf_iv%_fj;7pFU<-YsRLpAMbcXUm73U4LlabN!i0M z+fm|vZesxyMc;v+_`WD{h=5=BD?ObHUf(AC2fP5wcwqQaO7zbefMc|emzt*`KQW4! zfw2K?uh@&u|?ra~_;9mWvIZKN#8mjO^u_w=C5nRBhj7OtY%R(CcB7DNSOBQKua~p1I zW?}F`j0@vpS2_=%tBC{+gV(mCez4sn*oq+PiP8{A^D&oQ7s-DJS2PuSMom< zMMg$d9bOpbp-E5cgdOEp&1m33*5u!sRK9%c+))FZ&GULu8Y3=7Zt1*#E)8k2*xC89 z3A9a()wfHZ2XN~Cd^x`>Y=#{@(-OxIwjf94RvFNxUEIKn#HjsZw~TlT-L%FfrJ%u- z@+h5)T^H0Hwl3buPa=w8WFmD75@84_udD48WB}6;^zPvSC9@jsPx(vGWTbkkP0yk9 z@+7Hy!IO3~C1r`Bb^p^M3AXuzuI|pp1~Y+eVPPQ^pM}ci?tow=XoB?ij&Kjxt+&zf z^TUBfl7QYmy_nZ&S;O-Dhk`cgot>TLNhVMxp1u6B@IhpGdA(+dKB%y;tmH1EoX9?n z-2}~!Ne0cC`)P#>gt{f0Wq(tM)$G47E74uj==W8#aVZ1?0YZuZHBj>vZOg)&I;>UiTK%EV$@0v~pgCC{ zU6W4cig{y=Y9+tuGr2&5gaGQ1u`MXQoclcl-b84(ZC+=Y8yuCb%6Cj+Qwdjo@GVnB z6{!(;wS$^y6Em|Nloyy&8b%JvF~AV6gXUfE1VrFK4!S2mRlW?tR&56Tq6Z8p5^3u| z8sxaa@{G z{{b~N;7Mcz?3FPQ0kBxCu#zpOvh6a9o*>{E( zeME`QoB0e*>cq~`FkRDIfOR~e&U!j?DW_=$U>)5|&F^yTn&H_`)Eeyd@dbj*^Eg>I zSzpR1{Tv%qC`0%wsRk+F0O+?2>;od4yA|KN-1Dbkeibqn-#bfCQx zG*o~>hV%*}Pzv8*4`X*12?Es!hK&wc@&;CFC#L<8?PP%P*1!_~HMox+mIHGzix2%1 z{WXG}Iq5NG&7WRsq7ac?`jbaMJqyDRzIqOT07UmNZ##||)l9wcp_p+oWU zy(nuE>95)J+gv#^@Iz<8IU;Re^kJ~0V`N=ZZvLWan7($ORD;=-F_rTR!A((Moq89F zxqLsY)f2|PKbdjw#I1P0RWz;SUUf~Bk8R`1+V=GMqKI(r@njur!_3qaP(QG6&eA11 za;YOHhS3l7_2HXm9e(TV>!D_J=GN+4+BdD-vW6;RMRU;$t-eO|Vq-2l`56p6?0kpH zu(uhUSK$@%Xlgx2;?H7==Sp zio*_+*^gU0_Z4>US?qKR^<2B2e%YtA0kC9#E34`hViM@@5^TSO-My&R^&{7X>aC-X zbxGmuoS9mF38@8f7F+OjJN4K&*Hj%pp4!<;EegjH*tw?^f?jpzX3H!6*gn(NETJu#Dkx5m-g6 zm`J9shH)GjH#^U31y3p}MLXDc+~E$@9h6ThvM7 z`$8!q$yvlbjhFIk)$7*Q4+7l9E3Br(jIDR1`-4*j_F3zyF6>68`BQ7AR^R3swbGMi zUMiLqzCQX57#V5YLo_YRYeOv?oD5^m4oa*c@gv3P*@JT}n2^fE?65d&puAcr^=#P) zE{L<8IG7N~wmHj3;a`z-TWm%r7Ls056PF|(_`lc^KOCHK?Un0o~ zaZPY}R~jWnrT)Y-$f9l9a@^TmzL?B{C%M91=xn?d9p^FX0Pt&O>S{b2OghY?D|AxH z0Q}_KG^PZb@~WX-O-)Q;UBf_y-8cpZ5{#EzN>t6J;UO% zPmaK*W6j3MWxMmh*=n%0;ZjOTt*Zs51n}i5c^5L#B6#Hr|4LymNeD~D683hEPq$^n zGZ}q5MB94$?ytu0fC$om`GG7PK(}69Q|!_{5GLrr8wMz*;M_te0CmXw&?s0;0!C3o zM7en9#GTA7ldpYfd3I?no8KsS-d+uEtZNYLwj7apAmG~3xa6=Qxtts{&&%TMtej?9 ziS4$#6wLB$L3qPk#CDBf)yhq?J5cg{QZN(hjk6cpAP>waL2PY#pn<(P_6DUPP`5PS zl5o!CbJq2B{7?~@q}Kr8+mxN#$;fUA9J*ZK_9fJ7poP@z&?^cb$H@&0uaD`oXfKG# zs(#*lK-F~^PQ9P$2(4#A{n-kRcaz2<-_8xccI06jf+sy|TSs)1`gSVuoRRgKw zg1$vsEwJ|B;qv7_`I9I`!T$NJpP#Yz*g z6}`!`w8A|o9ALwsG68}bC~5~4$GT445zk> zo@P-9cVla$lbH6=5Hq*shOb08ms6^s+X;1QosT`;dv;pk?F)=uQqQ7SC&pWjJ+L4Q zB#305{CzI>EidC1o18%lbA5e%$Gizp1D?~CXrJoGy5?GpMrasemHi&nQahN99FUPc zZf^2yFkab*WcnRz?C&=)ES=Ewmdmg~&r{&)2x%GvlJx2I76*NvnmFc|zM2nq)vDR!b$r^t# z&qfR1-mwZ!tg`@vI?n;uiG20oA4*H(L-5CLy#qe>Csk|N9lYi=X@s>6xR4_A>P_N2 zqn?U>0fRf{nQRC)k|XMIDv@)NW4l@B(sc{fF#>#6M3UQ2Sj6>erKDPmBKnN4*8~p# zKpllw>SJ)TSgq&*=0}}c-M~FugB55)NLofeyzWw9vO&hXbbOn zv`A!;VW{rCF{^62zf>G&Bs9Bt;M1%^)V_3Ddv!&aDdeELNQtc+${GaD8XmKY8fbgf z|Gsd$SCIwlU4Xnds5=<7&ABc^DEivqOlDunWBIv7#)uO$CkZlB z_1cI2?B_E^Z6@&3DeKg+N$smjXzbtjoKyH6fnE4?vE(6ZyF5E&ZW9IDZ}2%R z;kQsRuscvi#6&pnYt7vxUD{jF|GkQK6!eWwFo0u+wEXSTc~o%HhJFRDjvZVDOVj$M zUtY&871ao73!|M=gR=7vd+X|mvrjJK{KGD)i1`yV1URn)x@jZ%_%jA?(cEq3Z2=^k zsZn;A0Yp-`^=c}2{7bZ~`a2)dk$fEMwi+rS<6h<+D0cgO&BLrx{e_Q)1AaeiI;m5G ziuU7_$lASmQQZiMkY0jgr-OJd?Bw> zv_=mb&p1edOOp07w*3-&S|qKV*XYSO3;pWM(OX>K*Cq|WxOVr2H&K&|ot||pzS81s z{1?hJ4OIfP}j8OZ`4O6kb7uzvW~mw~b}$ z($>uFS+L*rc6^-BQ-8&q0wfzETqrx6`}-F7@OK8i#rrJ!J!8BX(6XHccrcQ5?|i`f zF{!wiv1CK{qkll0yQ-U*;PfL;3rIA?T84$&?{KEtND45~#t(%au=e8Q7B7$Dc+G}C z;Lm_{<#w(}pRt|9uAH7_%^z1IVn&$o7I~qFfvvIS0_PK3ef?XFjcH3Yh{K{DT48Tt z`JU$U#VsFE%sp4tgiB*19}Onq5k$RSt71>6y*dtg842e=N3~wn&tn&BcD8LIfy&iP zBSU04#6Vc`qP^&aknXI*NkFjQt3l^4uYr1&0v?=c?i6KNk=jNO*hh&-R9MYFH+vZj@^1&ADivi(i zr=MFJ586A9>%l-mAn`JxXKR7eyskGW2n{pZH;`uRSpe=kn8Cbt3TOw~2R!UYsF@g4 zmV;TK=N;$5>EIXHY=!8_Bb5Y+u6|2203#D!8?zZX6h@uj&Wy7K&BAD3KM%wNAOLyUNX^uXAa#;y;01!{d6A6tB25 zd#AVZ$NVfAB%z5kuT1+GBe>Gw`*Hauwh>W&L%*}K>fL?;=R(Gc)}*c;*UYano8N?Y z+A^kzzki_aS4?DtWZ*WL5LbpBubfGmpOY}?#-C2v{_dxF%h_=GU2Gd7PGZ5Y(y^%t zC3Ta8rcoRn*)vd2Iy!Gq&JBIWFBnSmG6Lv<`Jp?2LB{7pn~5h}+qtZaBXsxsF-KK* zdT`a{7rq`qi$B~Q9)x2;AT){uhy|`6G@4pWQ5H3+KStXgKf6>)d32PNt7W95K5XWE z(s?gfe@D)(0H28GxzNR0^Ekaz(Z!}{p?lpkM($?{zzsxubUf7J0;qOo#5LTe_G~pL zunGWjb`;tz1AUhIpRPbhbT-&Y0l)m)CF6UDeLO!k?%_XW>9KWN^iqzoR| zk5Z|yDpM@U=bVdpTu5e5&;%U<#}7z6Q-|pN@F9y7xn(#aoo2c`7i>Tv^h+)um9OCQohjt4H>Z~bx&{F{0x4TeSV{YJ;a z>|0z)%WCA$ps7MGqFRH~RRae5-A_B*-prVn*6lTgwbdXQyzVI%h+I8LhZd7?iedc3 zmVB<)`ZCNn6WvcTS$O+xx{ZBqJLhHl&+1CJ|4Z>A66N_-k)Cl>t)q)2o|nan(B39V z{^hW%5Ple3y|Pzpw`RDUTrBP~f{+=(e<6$%EVq)XSg(=A8CB2&zv|TE>ByTjxxil& zXq$nawMNZW2?-fVvzW90SQ034@HCO=gLDfl z#F{T|kS$eHrvG%|t7}b9XN)&YopQ*W^l&>Vm_hFC*RhbMb4@3^MOd`b$?bQj$R|nL z$|zK0Z4%g10Cq{4Hi7AWBCCrV^OGQ?_(kJyv_^T5vE?C+OH`yIznB1iCzF=d_{W3Q z)n{4UBGJ@uYzByaeGqe%GAS;4`P8Yb(L{4}%##+8QpY;@&+UJ+0IRYGz_12m3629* zC(;udD*?Fe-gNWi@72{n_Uu^2-=XeXKADAu>ra8PEVKRO*tmT29A=P0j?iTZ;;YYL z5PkQYg>rk4>)u4&AH2Nr=BL|HfNtY5LY> zJ#>7C`Rd!2XV}I95?^Tw3W+W zsR3+lvqrW%4$>M}Z(7offA$oOfSjD>Zc}{D(wzH#bLS)(wn{(`Zz>QJAL%}p5Uvv7 zak7PLMEepOv}kg1k}Hw#Pmz*DXc9i7CfvVGJyeR~-p%nYaTJJ%N?=*OY~qh-;qKN! zk|*2kRB<1~aO>qG)oQoiu3G+GYW8m{V1oTe+o?dzNdTUTfd!?yx?EP|8I*mCyVjIr zA|&?Wv%x2e(|@QLYwctjo@mZc72jssx}9#=D4-)tgDxK;(tXwTs!! z#;mlz$Eu}eOr}-2OM%f=lbrWs#O!F>&8kK@)90%d2eE5e;wtPeQQzfIv$jio^~y zVnv1ZQ+`IK_TUAEvWgmCAIJQXqJDQSWu7NjZ5o6X8rXlJBI+cLJWuKb6AlcK`T5c`(9x za+|M9YJaT4fMuhH^GKi`u%sLQo8HNbW35RoT+aqC_|D1$M%_VN%$;DK7@4qX=E4nJh1)3L_JfqM`;ttf4%gy0rciwbaYIY6mkFzpVY;u&3&-oQJh^T~E7Cz=k3wP|ah(`b=U4?jUHg+4HQeQ|lnAZPl3kFk5iIsZa=Po9 z65+B5j=*t-O3i5(&&iiRKl(giMP$2Ziop6zn`Fg=6u#H|{0-?F^M)JwE`^qw@!R#< zfk>2`-Q8L3zXfJ1a@+LkdE)}il*;!&o*+#9;aTz_`>^a7W#a{CIvX%45a43ufOdd( zv0o*o2?m#Un^eJ!EVY736xdZ?U6NX1y`T{tE^MC<;Fz@d(jg!v-CYvWao2(G z?~eZ+b7S&GpPVKhKU~D$8YXEo#3!>+;h?m2b)q2q)#yh4c*g(=Y@d z1Zur`#nm;HmCkrYUluWfzM zvd?}rfiMo0_z=0K!!L-(%IR!5kjO81iSf?_h;wO+6|OrN>k1P9wQ69C8>?OOQb+CC zI447UfJTld?V+J z+oRrOzM%F<{`>DA{p;6nwts+=@=i%q~JfX^{UACIQ=<-;R4?FM}kNWWJ(=5g9;#QxZy0%lLf5TU!1g zR;PH#&*APlfB(@_XUjN1$X+swf*C zIR>&X_b=@i+pyQ0qzc%MaPB@|%W*r*;A?zw0df(LYyVQa| z0Y!A&4t9l7QAD45XR=);?2!^oLTkv>Iq4}eg~+;Q5#VbN>u~mj1AZBT+2x%Ti}SB< zppYeix;3G^mgP4#|y?+gtvXHUscO0V=xkH6~8K-l4kAkXQvdnefyj_$ zr0BhzV6AAvVme7*=(c~16A=1HJF=3Ano(CL;TOtV>4n?`eocgfim=B@D7$O6`Wx-G z`vp7p-whvaw=H=Zw50A>wMfRSRZ-i1d9KXMz)O8LR1Gaet{Pa$_@xKvqYX>V1Rl~< z+0{_79wg!3W7T>@AwG^fUT#yIuK@%3+`_ExsnpNWA<;n z=#|W_l_@u>*Y#s>lG!7g1IPIsWo=5WM~2s$U#9A{fNt_1QT>FOVt4&~D1j(Mg~LlC zn-Ht<0y@ybYcrVV_FiP)>^5ReSY7rBW z#houzi8G z76Cva1%Y&v9v$W1y8Nvy^=$eLOC|HXYFOqodm-lp%TIi1T%9sOlHyxxYWm9HB}+Y7 zWFB92a{Ji5PT#p)?{gB)G&i9}U!8u?5}V{L(jNcs&ya3mP9C6Hv%$|zo;MH0JFcF8 zJhj(tIQJt&ii`&n%;ZV9#5?1FV#SX1?z%7bCudP({K2RznW zVEuV}^e>PNc21hSfJcbaNXDr03fI4+NMe6$RyiUj)%xUHxwBCHN?cbDo=9O{p0TaO z#}ei}Dgl$$@Jv}=P|d7Gr+yoS9@mcvQ$>Qq*|hP{z<331#3D%jFfHh)zQOJvuC&VN zMWr2_OFWNELejCvv^vi~-EfqZbvE3H9fF2Vhl@v$Xd+K`m-hLVhQUZ@@y>l*oEZOE z1Npl5Qam_j8}E;Dx>xScd@cSo*wqGla|zmf!bI=-`NMg_vECgn-|hYW`8c3BUs|5}2*W(Mb(jy;&6> zsvOD+#%jNtPjhXTj3r3W5#U;e(qjXNrJ`!=j8F~$OyHo}YVHdd=?^vvf$&aD!(08q z{8N2mX0AXQH^Xf{MH5(+76B84OiSiq7NmvM5C)USIM5(J$8ucPv zwQ25q7&tbwGRoHJ4crcL80ww&&{Zr}#l)~bEL1MVTzyIK9J;CL)py!jT+}x*q5{8d zwe^k1QObiQO56Ny$|EIoBmrW5V*}9IT9*K~V$ZcZssmP7{O}^Mog`ptwA?>r=Gzu2 zJtieiKl2wtAN~xCcc9JD+Dh4w$J@A;M-j^l6+=a^PV-uua9Zb|wah zkM0h+nadsIAD$~Kc)Twj5%>-Uu}X5{Gt}1B25R?F#yC6O2$s$y{AsPo$BZ0N}en z6deKF`$Q*Sv~c{h7JL=}%>e$Q^V6IA;6F9&_w?``13l4H$q>~}yJ^Gs!aLCloRV!& zR;dB@ZDDg$Lrskangd%q6f4D!P_4b&u|YxNed@+Iqo@#VXe26%24$Q^ZB31mvT`@( z6Xup{5NSB*Vh6W}Z|0TuId0Q@S=Nnx1ww_XO2o#q{`CkX74^#L6 zauqtP?gVjkC+PYf8S;9jhRKiF7A1@n(a z1L(+MjYSsUcyxu6*564{R5odme5b4XtJDBB(~lq^zzQy0)AA#V^)92VT%gxHl%FD|;pND9MjL&@uVQ=s;+Wg)YjVgm`A#h8J z3dOASEG@N_V`5`V#PITYCo6RH5_-xi8mB=JtOFKb5tX3T*hH zj5i;h98G>M_~nB5y|9#+?tF+B+8hC|k&epS1q4x%Yy?$p%}LF3(qY`qar_v%4MfO8 zIE2K-HQC|Dp;N#9vf^`aqilWq+OEGHd=B(T;X+r@P}x*H`>7n?CPsuP!&5z8Yl2hNF`(s7G1D zrleqce5SM+&jT|oeV0S7pLjf+nou@EYfs}q|6G8jtQCnUgGA1U; z18(M80uI=uC3L2Kv#```(9kE&vRhOJbB1$J;_Q5wBm*_Gu-2!PJj-`;+xYh6L=ufY zNc1d7#(mZPQ;uRVH9LL-p@Rm4IVx??7LK;S zQZNC**>h67j^k|M!nD7m@NfYkB?IC&sM#|8|I-S(Ah8)>K9Zo*&_+x_aH^|UKOQNJ zY#pF0L8SBBFMHPyA*fU>W2CGL2gEo~B07UkL?00mJMhR`v3o7dY@43&PLn)$T9X8cZTY?~J zE-nJ=ecWqiH@@ZFjB=U(sNB}d(tpH3zn$ua)~p<0Rs#F$hKob>1>&&lUUbAer2K(0 z>tdCWB_^q%tx(=zJprjI4KNQrp7B3O^A8Ex1jOIsBu|56SfT4DL$@Jvo=Cr>2))Xx z>WGN{QMn)_maAkh^&*>VqDu5Qe#;-Tk)xsYF%uKB;a;IIkM4>A{tnG>1@@tow#P|E zaneA#Cbb9DhFNGVFZi47zZTYY}KospSU|&a;@T@^U+{TBaERlnncQ zAXxadiQYX^5;*+e=3ymqc$j&nQIS8m4K+=1k+BLl*iQkof$IaP8TdTqxQRrX4Wu-= zpi4Iwms9YwfH(RrG4bO3{EL@Y0ne()u&&PB7dU1xkWHFd=xj=rdF;pe5Ij^jhgrQv z8;k(dThJX+URH)ZMPZu^NY21CGZxRq*||5C$ty#`YUvF$e|=9=td;PsLBFHz<+-yp z0kSp`^gGV}_zW1vplb}oY9=Not*x!e&}Fr?0TSB`!qtthIBjLXU?Y*wRY}9Qu8#L) z|MG075pao-3ks4Ku(!W|gWvh_uj^rxB)Ey-1VAUsdq;v$Hto4T2=`^Ehv4QFwyH1v z^^h5Kp8>q_)Gs9G_5~Cj6T=aOUj<*f{y~&csyzCOjTGQTQczgP4@9wD&OfKFO6a;dVxhE>$~QzneSJL$&@hlu6j;u=Z5wmJqV|Z0^KS^xc1JhV zeI_zoZuWP!i91p1=4UjD@~wER@&H8o96b6nMU9s=fR+p#h5Gs!IXP9@xQR)=YZJzO zo%qFn^sze)0HdREC`1de_a~m^(F+4`^5lTZ#;~>&> z!eAw!SMRrS>%note8@!>mzQ%}+u|Pp9dNJj=^SxldM|_^EjyebQH!l}v#-(X7fNN} z#qe1pExOb^%om{R*W|9H7Kn83P_*Mga2=}drRvT18?}EiwBtFuxyZ_@eUD+>8Pq(~ zG&FmM?4(v{J!V+fs56U9u?pTjte#ARiddSAMKO9al8GAjUiY?c^e;o-4Hr9JBv(Q8 z(AtF_H-%)*|G}^X2uqd5hSjh9MfuZuDl+c2z!`x9`Ugvy6i3ZMbS(>2h zVP&dzhV9kM52O(e#%;x6Vju1QBV*F`)}96vu=FV2+)?=2ZWG)@LBb5~WvEYA_CrM` zvJM9HS>S91!QR>JdjU5tGq0MuOv%w_1}npNq%fHYwOMRNy8DJ{$+`hBvVG~%qqs%O z4nAo!@U}O?$@2gJX}Se(}EAf2j%}dJ~b*MS~S);69*bl z@H>3k5dY)-2(d%vCTPRfdfh6q|L<tq@`L<0HL z)nQW(iREI%XDm%Kht;u*LS)h*NP`rR4Ha@(QJ~)GUp2NDibJEIo0RG@w7kXf6KB)J-(bYvqRx4FC?&myN~mWDklrY?H!|uj=JZ>v54e)c})5} zwlH^(ZIu4m?kuePiWU#-utC`lMZF`OFs_kx`j>j65`n`4)KK-hoLc7NletVnxg4@k zkAj9``2rUXk#|!pPT7vmY7VN|j)UL7uTL?Fm$tY2a*hw-=lZ;1VAXqrZ_ekP?yPEW z?^Pp?TXRpCE{XfkkS9F-R8;45+Q!i_EJpLSZ$&UHmu1jallNArkx?F|rsm$Eq4K?) z+n?77C8E}s9z%{w)z@#*TRj&`E8Egy4SaGy+4yPj6`R218~gCDwEq>ko4@Z}vVIuP z>Y(1tQ+s0x03Q`{*9qn+OU zPRdY#~02h<_?-ocFZny8fCS&=DR^I z2?asv**VYn2l41-l%ff^^qM+Gb(5Ui{_Av*gR!ugOF13xXt|2)yksG{dP;6?I)rBU z{hT9=3d^N$KUwc;F@-OG&9r_;q&m$xypE+tXEtamJt)RqFr-Xq`rJD`FV7-D zcS6bMADWl3W}j|rArt-Cf=-NX!|1YZa> zTtSf{2Y#2n{$R~{^WC`Rj;l_KxG6-9%fZ&D^z|^Trat99D=Zn0FI!p?wvbOy(!Z-( zP;B}x#xvt6^vT>%#a%u!^3;+mp6ml@D%w%2wW6^f5Kt4v>Gn) zy2T`eScn=ZY~ywKT)6gnf65}~US#MSVpRHStA^Cvz2McC&i;vs?lT_xA;{nGoR%h^ z@c;~b`n$9R``^;)Pi>fVtqk?`1?5$p*TJ&4KzSn3r~4)n-MU25T5%3qSxa6Eje6)P zFm+5i!_-o3?-R851NC-yG}0?Bv%f>f^$vYvDluq0_UZ^X2j1iQl6-Xi zE^Cj|?Hu;j3N9!V+E}BXw(_V)JtUd~U8=M^kvxthHMm}NQTX{imr1lK%M zs61@_;9k}aLu+YrWT#x5<*tab>v5$hmqBV)3#+TK2?=Iz@C|$8Dar`JK4QFlZLu=~ zw6;Q76UJ=5nG~A+{)>ZI-QB{5wde#Oh%@P$y~i4!z1Uc17BnlANT7weh?3@IX5rJiXwB2{S&Ir*1B;+=cl8M9>DQ~)cru#>^r;QGn^#cG+uc|yWX5& zM`Jtl?3#@4+*#_O4ez8s>FJq~A#%9?j?Hn;F`b^VXW2CHX5dyoy110QO-GEBuz*aq zl@BRX$Bp(R@u}MlJRx-qbFC@H%^A{t?qN7#ZO9rzmfO9h{Yvn6Vx zjq|AJ4VYaY+T|9Q9rPZDTx@L;)6jNpY;1668=9Hz%r8$@S>D)F{;>C&MH@{D)|*6g zNfMZmfeL{W-yZpj5BdsxsnX$)xim~5j^uhzfOx!XfUicD9_JCl4vra<{Q^}o<`Wqz zCdO~Qhu}H5$z@l~k)fKS#k?g$9e^U?C@98A(-$BdfgcCY#S1VltEGQi6Qd&|mC)5C zXym!oj}K0@|Lab}Ss{P}0HYM(?63nig5aw^q8u;JPk;r;$ypij_UV+?z15EeCP`1F zjvzLNlz16U%_-0%1b|4_wn^mR zjTB6*VL<5x#lR?%Uz0IolcG$Oq;<8|c06b*!LHK1dH4{{J}21Z6mH51j? zw--3j+_BPpS>c_jfw1~)Zvpg3*pRGuO?UMn%%gsA8-O4O*95J`bx zqmEZYnt|%OE@0&@|8P97v8ETb;4zH1+*zEj%X~s`Vwnvb3`HdfT(koXoD(hzE$pct z@dH9E;XmKLYVK0s4az#M0_{fBYn5u_ad2G(1=dU>!RWl~nlZh0V5B7b5P`E0G`#LHppX)%jY(iNUQ`lvfT zPt8p4>=wd|YZULZ*y7mr?dzjxJz|1(#K}5IGC6yT(X-V|)?o~=b-?gZBN^4=CU>n8 zM;;Er>y9+-@Z69?E|r8{s3|x;K0ZHvA7N|08Vvh}BLDR_WO^LbqaXsuYSoG^C9GJ_ zF0LGq^oc*m5B)l^YeEq%HSuhuoGW9=6daDPvqZ1m&CLyTdLbKWlJZ`x%w~IfMh;V> z2Hm!ayl=D{$*w4;WZ7T!OM1~7q;u{$u6a$|q4hx3+TG0yB4i;J9}v$=Wd(l>j#h{? zzF37yh9(UIJ7g=}hwqvw5jwKMGjjTOXdcJ0XBZT|M8|YN>xrWb58hAhIjXK+%cMKR z%8Qa{m$no6sCK^2+Fi?gz3_N;3xf5XPf^&qgwt4KW_fmf{_7LQg;_j<(?}C6#bTeI z51D_9iTJEdPPVkj=FvZ@2_x&DZ;v{hfFuoiunCYwRiJnR=@xRJ0IO-o+lU{nrvC6z z+&oxS@0aE!+LykMy5QLuZQHRUqfFi2fwpo7 z_NZj(EluS_1IJnQFfo+dHsBXL#n%80h%aI%vNf<+HuDxi87p5jk2)O>oqY5$D(9KlcU!T877S|b@7}{)el|Lc zv%-3%DuYa|plD!$IsdUyGhWcm0n?SaNyc`z6m~xNw8;@-i}oBp_sX zgYdpqe?|_sYj+L-9%9aq6xY)?DmuDrC+SwdZ*z0A&QITK_bjva5aRva-KU=H8eutd zS72R|las5l^Z&y(T8q8BZEF2vQge%)EryNhdQp6fnH85KhdqjiQ(y_nzTA&#DF$Fz z_0h;BmI%TsBbPjNAO_pXE~M?jeNq$DIVg|`gRIcX{%<&oty3&I72E0Ld3L=@)53$Ib zONIW$iAe=MF59ibL0ZA$ojC{vXKN`jDQk;&ZN4~NA)0q9;7e_*hSPyWONj5nK{oyt z`Rm?d*N3-CEHSj&PY1CN5Fu-wI|0^(99?(?S`vk`z>8!J`Po7dCh_+Sh@uA(WjVhz z41^P-7i`KG)69yW$R!9SvY+XRz8X zY?Bcc;b8pJ?pxIH;TwlE>7gNO34C~SqRKK&40f{{FQgjLSXc()*436Jq^=Ap8@U|= zJ2#-GnFD7oP2*-p?rv5O5lYt30D8mFwj7o)H}c;>FNe}ViBh^<=DH1}unPQ9v1`kt zC9UUwXMY)!6d$0|VA`I}2pFvN3vlSFU-UY!F{vHIJEdJ30G>FQ5cRxwN&Ho_Hy#!h zZ)pdcx$WWmd#HKG^FfRxko&;^a0IHR=cu%pZ~%}O^y{lth&e)Jz2A(^c)l0Y=yrLs z5=z1spPbAnPk17O`!LT`?SYY@%^(V~{Xas1LsEI*QgKnoCy03{Sb?uveenq z!^Yyy%zcF9WAvQv{wk6&dD`_m`R_8>ffrRH0Fp@OF8aE?hR|Vsr-4)Y-p{ zqrX2fySHX8wjF_lhm_1FwLt#K(qliN(w;l*6_c!Cs5y zKcR|GO}A8?R{P0-|Iy9cVQBOTDRQl80q=H(4)N+$Cab%up8&@ zNvq`2-IIGhF$wi*6fmtwh-|Odr=~bI*0aS~+;RA!1}?BOh7)qIXRwD)s?<6Z(7md- z9+n1GVTWeMG;qSQ-xvvCNI*1vsMd^R{%0~e8F!CxXRko6Z7h3Z z^Z599pm+pouVMWWXk%Y;*hB2T%==g0kbp-8WAIPfAjV0wUt98b>_MN@Ih0ZDaT*h~ z#DykVY>!1Mey|+$CYm0;JfEPBITySww@rK+UK0#QCZi>7fvK3wFDZcLd+kyH%uDJ+?)b<`dhYWv13>*7*_;&T5chW}9^6`7o@EhqP~Fl~Nb*I5qr z;Ld-$2(vZ7q@T~NrKGxBbIk|u2D|Svy>NE~ddFP0p-PrN}&+J|w8iGMBifxnx#H6GUKyQ&= z9k*XzUhbv;?u?+?-`nFq+bp{{I}$iqjM%#!0c6DtY5tQ%Fh|7@hJ=JPUR~^*9qr7l z37PF<;rTF%vA3cpMWkQhVyD_}9PGB)=|)oLt#ZZ7N9pgp)*IlgPF|T<~~!{xUT-RsoOuQNvMv^5sDm=u_OA z+g)g1qI#wJ()Jq!Dg>0b-M6b(7A=1OUBnQ)7B3o22r+!Uz3-gdt=(^u)SA2ve9=yQ z>r44;0+WTT$JhS~%VP4rEOjt-q5E*u3O{1^Wn)ecixV4oFHR2Q?&Q$z-mIp+`|_ow zaAa(v!AZwr+;nCWPbOS{!B_i$Ih7uGR8&p7YyeuFJ_M5eN=gp1KTuD&zJqiN!twlg z7XUB1SCSzHfi$o^dbf@B*0RaS4CSp6U|4r!3Wf3=#-1A}1MmnyaZ(GofyJONiO+Sh z19YhVa{D)|40vfjfe9@Lw-k)}lEB6-*}5VuK0Y4Idzl#-1bCvh_V!?QFc&40vWCF2 z>~)y>Ah}IK*6)v#Ppg}$Hm}>XxEb?HrMt8g|1(5Dgv1l!vQwm(ypR%>0v=w$Sbeix z=Tx`W%#G~{RRbM};!1qClPY;J2Y^xZRmh>| zq+NSp$(AN2QcnnrB;#G7`=-mz)McU8Bl^HnYCk_}0;hHvEyPvdME9MAHSk0@dfW(6 zF-RB4KXL5)>x$R9O_R-sfAr(;JtaI=;>4`Y2ims9O3!vw>?~)!akxr_XKKDXWAdh$ z)5yvmd&kMTjE2Z3fLRt$P%Qrxzw>2l!>T4D?P>;SVQc5TgOis6rjsC;-aLvj5eHFf zvV<166zqO9Wr=}-BYqXx-}U*!A#?rt(=^<&mZ6^+2low}LJ8u~I^SpcIGpuMTs~X@ z6M&x^buapokm_s42ODAUB!K_y4`A*djL+nD{1^2$8tYa#&AICB$zc$Wx z_4KgfJ|^Z?@Q98|MuXRaIlLMvXMoxn8k(;8hsHL_c6JAGEJi(z-U+nS(P84n3@HXV zF|e*@_Ck#=TBT&ycFojHQq%Ff*lD;Hv4^`0CuUmQuj9M^iC-qypM~b+UM_H6zWW>$ z8;)4Po0*PFu~qSSrmhv`>tBO|#rXTNzy0Fc{@twC~X?pk~HmA7EI?BO+JO z8ZDL}p!QPyK?0fK0@iN|3R#Y4k$D>D9>2J(PPY1jh-OcY7q%z+P9|+t$&PHbSN5GD zlH?_GHz9*tp(eelbx~dU`0bASS5eWG7S>4?%YHls%TOd&p5b=r#S1=d2PXlMNd+01 z{;numQ00NpYY)i~kLkj7{tL3ppl)bp!$s$a{e(PTA}2)UY;SXQX&cb#)pj_4h$+Fk9I% zp@GT!_pOyx@u7kUi_QIiy&N+gL(5UhS_99Q#(+fB^_dfV@oo0L%%a}a<;BthP$G17 zbr-38f{%g67QgeJeu3sR4&p=@Be5K+MaR^_8W|dc4_cEXBtF0R%(pVjC2rr4CG2Vr zVhP(a7pM8R4gSTC9k!0GuOJ4vB>F@iCbhpHc=FDMD(p?zacxENVH3o>&vrCa?9wEp`ngi zQ#GAiYi)2sK;#UoB68n2S%StuC<-8^s3@!Zz*RLNDvCmYuH2MdD$5oq%q1lyjiC0! z&@dA?QhXtwXaxHp=lx|k5m)I24u;N8Z{XJRG&)+gD`4|r2kdD}aeG+Xxkb`H9u^+@ zvv2AARD-=!j5-_0b(>*12~gCe(54a8HG1{D)Rs4METBp=T?A@l1%@RfnUV>MCA^5E zA(5vWPEclSk>pc3Wv+l_`lf_aR;fZ0UIwdHMuAVA#jO8eLQ^>B-IsM69Kwl4c*SQg7lq*M+I)4nO&&IQFZh`Y+C#bjuzbvHvSJL4&s`r>OL*im4@H_won^jJ%VO>aq&UXyJ;kV~h%9FMKQ) z@0w(t9;O4Yl<3i|hRzC;Ijx(GS-}>d@cVme%U4iLomSDr7Vg^}K@dUzuy}C)ek`~B zIs{g3w+cT#lD_c@Gxv;p2Cgs|OjkE;lNtrZeHzTPOZWU_1l}z!w39+~jmq6#pdiLf z@y{)V(mo)fayPUTw-OQ$#C$88A3Tcw79sZd{OE*BO1fOHck>BTPdv8}QCXHnmt!)| z$}jEYhH6tQ(_G;;zRoM$cBMY;qVgC@{j7p6iy95{b>{T;YB4^tzB=|fZS3DRjMRC4 z%ioDv3&u_aCj4SJ1yI!pb{eln3~f;BAF#ksP^!93D_{WLnOD@{5|bx@$_Z`bniP%2 zykcdT3ix2RIc+Mx;^EsfjX3+p%h7)R*~PG}ZE9U3UZ+#NYFkolg15a+wUUx2I&O2K z0l9X+%O>fZ94OhRUQFHnhEq9PC?!`KaDFexh?mFm|mu2jCqKNDr3pshCALm-JBU_ z^EyF#Z33+Cy@q9-`VTQMh~9icIs2HZ{P5GD(t)Z?{uA7hdeV-!Wr2YYxA?zn>l@Mj zoN%2K$rakeT{22`r?mVEDoK?uf7tw(>hG6PQ#%Ft)9yAfOFJMe??WgledE=(Mmjn= zMn<5fD27h;B}`Ivc=4%k)6Zr>ZeFVKByk;yF1v#Fa5oQSj$%VkoC?t-Kn7X3=WqXJ#|SJ9lMZ zux&8SIRT3cQ4;4xz@RBZ^8&siCP1}_;Q}ec6gGlW;9Fm18AHxA?_s=6fp~z43C;Q7 zdB;~0aoeqiXo*{Sk$ldO>qmDUZ210t5md}7Dk?HDF@ZYT)qDwJ&+Xwx=t{NBkPmpf zXDKpUhI1gY&2FQt_PmKgscCBs!n-q$o!2Zw7{{)H|5TDq``&2K(zALbyf}|hF+c_98L)f zZjPh~s`$|N3m#66RYAntMc{V|zzKu8RzFPA$}w|rG3MsxKx92xV{axU6>>fko0Rll z5-j4pOd+?toRyiGnVR||MAB_J?(FQGC6#D8_Rf%-#jxl8!-qA!=v|wcauZI!>POcC z9q@U!)=CrHYe(5gGg!C$i~@1`OTab^hgJ#H?JTE<@|!lkqG4cwfwnpTw3bp*QW6sj z1SbY=X6UXu9E5u=TR%4boff^%aGh|g?T$BCTP&wYFJ(?QfOd{g=DT?lWK;Y&-Hxrm zNJKEPE_N;_anU9sr zLO3ii7b1(ak^WR6%imNlUkajqLCPg>8F8?%1R-iE9^XJdZ zO*w^yNz4Xai5frZ={@SH8Odo~5zf^gI}Ia;GXKba9i`0Z+}$D@j%HJRI|?N7<=%uU zv(ZA~AY8tSgTXDHe3g=yuU{LFl^Wb!4>sUC^ORg zflC1;2|!vN^rOpSZa^D_fDoE^hl8PZEWals%#R@I?!>$wrnKzJ$YMpZ`p@755(|)A z5)CDZYLXNYX#w|%0JuIt)5LlQI9gR)d#yl27h^Ru@3Nzf!l%jhY$EbpZrQD1oHa^ z@$&NW`y$_Rgj-=K9ntctKW|S0|r`Apwl&~xLB9^X#fKS4u8)v&`>9e zEXDX-0qsl~*&B{x_XW(=zkQk>&5Fjw#WA0;JclyMMe-xH_UnuzL%l`#!z}rgz delta 67988 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%0D$2 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�HsVm@o$1P^A6{krz%zw~MeYO}xOE~{p$A^V-%?&2>;sNp_(J|#3$R$K zq_wz13pXB4eNCjJH?+PqQf?vbM9&JZ zHfIWPu|8FHmXCK8`ll_E(`iqNShDaKCgPJUb*2nZW)QbtyKrM6;Zgs z?CLv?lpobVaGfw<6w!l?i;EC2pF{{1V}H*s+$djFC8aNL6a4zg2zZW9yT5Jj9#r^jl@1ng#!NJk)~aK~vubLePpk&X#pNt>OWCGq&P z1g8E_zJqQE=#|ir+bY0{M>d7gp70LRD|6UUaZ!=g0;8B-`_=HQqzx7^@ilp%zsxhD;4J;+g)6|2MBZX^78cb^eClbht=YEkmmBi2o8YmuGeQjW%CW@ zUs~J9O(G)=$LZ4EaR?5?VY%B>3h(Z%bc$!` z*j3zeIPi?B<_x%Soa`--U}BP4f8T|d=Y0G2cJ^KdL-C7oQ>#JEc;|0)HnGzVR(w>l zY$X1jSHE|yaCm2Rz8hP zS1nmw2^Add?|=C>GCq2J{m?2sJw1ew8?2mH_CGLc)d7y^(Q1TjK)v1jv)|Z;RY5&+ zUyaqRtHg|zjM#p1bjIaU*7GEFu6Bz%s_zsz;g}>ljdo9Oq7FX;X|^uVZ{{qss+Ltdg!DiB|&VlrUVXQw?HDNB&LPDQ^^TxHu^2RET>OGOZ zAZ9S0WrmCHC4X3k9-gUcY7*k$Osb_sP|1dn2;SB(Z)Ab@H}V)ZGPod_#(;w^l@*4wysTMew`l9J;Od&tl`^ z;A%GH5CooCnL!UG;vQ3DToJJ^WJA87h{XRI2SVlqeP0l$=>Y8e?!9}4pB1#U_zim_ zd22o0G%j5gv_+LqY!2-r!Uw2xrAsqj6iZ7)tjq}KF_@Qm7Ww%23;=_;$s(=5=M1hO z+L&=xE!BGiEab}!`w*#cPi?cKjS)mrA_X2iViIXh>zVvruw?+an1kB z^`e2OKL8dCFpi{@=pVRBUt3odL@8< z0tStg|>+Zby6j-C-=r{JCl4p}~Wf{Nqp z`7OtIs?vNCq)0)O&8S)Pl!%DmcDbE%{)6zfF4+Hf7QEOle@3VLhrfP=(&ynT5|G6r zBPU1nz&Z_}x%?)VaX%GZ3wL&Ga`FZ|Qy9y!YonZ;90)_$aUb8UvEP~~yZ~QLl?4Pp z0HDuDH>5S6if;2#8=#)rA?uMB!S_%YE34s&F&n_oEW1A+O79 zmW1vcnE_4cp=%>ZU*Ld~ZwP=d$L>8P(%j-C29g6Q7uVFvJ|gMskZ(R&)blA0$pP#( zBRS}T-4;-&0n{ZB(p(;>&|qUzM5+UsJES^ho5fdWANH5ufZpn9MTVjezCw`AdxqF2 z4n!gOIJ|WX_~%-+c3DkLh)-p)>8mgkH&R3!GdFI8Fzn98Ly+owCARwN^%MNO{QP3A z`ibG;7vL9%kHM6KLr6%Nhv_*8Zq>7iS?^Y8u-GOKp!SHC1oqc%e;7v8@87on-+!g| s|9r}GL2!XLH!p;&=fDrhh&lNlQb7S*3&)YkZQ@Y;jf_Z<(EHE-1F!C4>i_@% diff --git a/docs/assets/goal-direction/checkpoint-recovery-unavailable-narrow.png b/docs/assets/goal-direction/checkpoint-recovery-unavailable-narrow.png index 6fbcfd0b6aa93b44f90a0a7a86708b6ea8e9b6ca..fe90323f17b4b3089cd19ef278ebbecf5e8e4494 100644 GIT binary patch literal 40486 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%0Akuw0B;>2(-p*1W43F~CP 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

    T!@{GZ3%YT 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 F{1-xZsM7!d From 586882448ea78bb420f51882562e29a04eab1f2a Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 14:22:42 +0800 Subject: [PATCH 79/80] test(peer): execute continuation checks in the declared workspace Signed-off-by: Tartar --- .../peer-agent-continuation-state-machine-smoke.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py index 652d5ce309..2ee899e9d7 100644 --- a/examples/control_plane/peer-agent-continuation-state-machine-smoke.py +++ b/examples/control_plane/peer-agent-continuation-state-machine-smoke.py @@ -33,7 +33,7 @@ def run_cli(*args: str, registry_path: Path, runtime: Path, cwd: Path) -> dict: *args, registry_path=registry_path, runtime_root=runtime, - cwd=REPO_ROOT, + cwd=cwd, ) From ebb8375797936aea79a0f3de04fd007aa30d2e26 Mon Sep 17 00:00:00 2001 From: Tartar Date: Fri, 30 Oct 2026 14:31:25 +0800 Subject: [PATCH 80/80] docs: capture final-head packaged recovery viewport Signed-off-by: Tartar --- .../checkpoint-recovery-retained-desktop.png | Bin 84133 -> 82728 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png b/docs/assets/goal-direction/checkpoint-recovery-retained-desktop.png index 6105187ea858d24e6b992cb7abe9459bb91b4b3e..a23cfb8046d6f14f30b691898205727750273e8b 100644 GIT binary patch literal 82728 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
      e7|WPX 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 V!MVS4&?AoWj-vXlLV2^`{{f71)8GIA literal 84133 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

      >+M1=p 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 CrIH^2