diff --git a/docs/architecture/rfcs/STATUS.md b/docs/architecture/rfcs/STATUS.md index 035ecd07c1..f67af4274c 100644 --- a/docs/architecture/rfcs/STATUS.md +++ b/docs/architecture/rfcs/STATUS.md @@ -31,7 +31,7 @@ appendix may keep dated history, but no dated log heading may precede it. | [RFC: Capable Agent Manager and Semantic Work Handoff (v0)](capable-manager-semantic-handoff-v0.md) | Accepted | [Personal Follow-through independent proposal](personal-follow-through-v0.md) | [2 entries](ledger/capable-manager-semantic-handoff-v0/) | | [RFC: Complete State Recovery and Controlled Reactivation (v0)](complete-state-recovery-v0.md) | Accepted | none | — | | [RFC: Composable State Machines and Recovery Verification (v0)](composable-state-machines-recovery-verification-v0.md) | Accepted | none | — | -| [RFC: Controller / Executor Sessions and One Governed Work Contract (v0)](controller-executor-unified-contract-v0.md) | Accepted | none | [4 entries](ledger/controller-executor-unified-contract-v0/) | +| [RFC: Controller / Executor Sessions and One Governed Work Contract (v0)](controller-executor-unified-contract-v0.md) | Accepted | none | [5 entries](ledger/controller-executor-unified-contract-v0/) | | [Explicit Todo continuation: Stage A](cross-session-memory-substrate-v0.md) | Accepted | none | — | | [RFC: LoopX Desktop Execution Frontends v0](desktop-execution-frontends-v0.md) | Accepted | none | — | | [RFC: External Evidence Research Capability v0](external-evidence-research-capability-v0.md) | Accepted | none | [3 entries](ledger/external-evidence-research-capability-v0/) | diff --git a/docs/architecture/rfcs/STATUS.zh-CN.md b/docs/architecture/rfcs/STATUS.zh-CN.md index a0b1ad1601..373aa3ef3c 100644 --- a/docs/architecture/rfcs/STATUS.zh-CN.md +++ b/docs/architecture/rfcs/STATUS.zh-CN.md @@ -28,7 +28,7 @@ | [RFC:强能力 Agent 管家与语义工作交接(v0)](capable-manager-semantic-handoff-v0.zh-CN.md) | 已接受 | [个人事项持续跟进独立提案](personal-follow-through-v0.zh-CN.md) | [2 条](ledger/capable-manager-semantic-handoff-v0/) | | [RFC:完整状态恢复与受控重新激活(v0)](complete-state-recovery-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:可组合状态机与恢复验证(v0)](composable-state-machines-recovery-verification-v0.zh-CN.md) | 已接受 | 无 | — | -| [RFC:Controller / Executor 会话与统一受治理工作契约(v0)](controller-executor-unified-contract-v0.zh-CN.md) | 已接受 | 无 | [4 条](ledger/controller-executor-unified-contract-v0/) | +| [RFC:Controller / Executor 会话与统一受治理工作契约(v0)](controller-executor-unified-contract-v0.zh-CN.md) | 已接受 | 无 | [5 条](ledger/controller-executor-unified-contract-v0/) | | [显式 Todo 接续:阶段 A](cross-session-memory-substrate-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:LoopX 桌面执行前端 v0](desktop-execution-frontends-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:外部证据研究能力 v0](external-evidence-research-capability-v0.zh-CN.md) | 已接受 | 无 | [3 条](ledger/external-evidence-research-capability-v0/) | diff --git a/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-adapter-partial-step-characterization.md b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-adapter-partial-step-characterization.md new file mode 100644 index 0000000000..43a8c43207 --- /dev/null +++ b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-adapter-partial-step-characterization.md @@ -0,0 +1,93 @@ +# C4.3 adapter partial-step characterization + +- Parent: [controller/executor RFC, C4.3](2026-10-27-refactor-handoff.md#c43-settlement-adapters-over-existing-primitives). +- Baseline: `8caaf678e`, 2026-10-29. The settlement owners are unchanged at `1e3401591`. +- Status: characterization only. No runtime, schema, wire or default change. +- Follows: [C4.3 settlement obligation comparison](2026-10-29-c4-3-settlement-obligation-comparison.md). +- [Chinese mirror](2026-10-29-c4-3-adapter-partial-step-characterization.zh-CN.md). + +## Why this entry exists + +The comparison entry recorded the adapter's lack of a local journal as a fact +about the adapter, and left the adapter's own behavior when a step succeeds and +a later step fails uncharacterized, together with the place where the recovery +boundary between the adapter and the CLI receipts sits. A consolidation slice +that moved this path would otherwise have had to assume both. + +This entry pins that behavior with executable cases, so a later recovery change +starts from measured behavior rather than from the comparison's wording. + +## What is characterized now + +| Surface | Adapter behavior | Owner behavior | +| --- | --- | --- | +| A step returns output the adapter cannot read | `_provider_condition_matches` treats a non-JSON output as a condition that did not match, so the plan stops after that step | The finalize phase still runs and the owner classifies the stop: `blocked`, `failed_stage: guard`, reason `quota guard returned malformed JSON` | +| A step raises | The exception propagates out of the adapter. The finalize phase never runs, so the transaction is never classified. The adapter keeps no record of the step that already succeeded | Nothing is classified, because the owner is never asked | +| A retry after a raise | The adapter re-enters the plan at its first step and never resumes mid-plan | The identity is re-derived from the same intent, so the same settlement identity is used | +| A step raises before the lifecycle completion | Nothing is spent and the Todo stays open | The guard already ran, so the `validation` receipt is committed. The readback is `open` with progress `writeback_required`; there is no lifecycle completion, writeback or spend receipt to replay | +| A step raises after the lifecycle completion | The adapter leaves nothing behind; the CLI's canonical receipt store holds the committed receipt | The readback is `settlement_pending` with progress `writeback_required`: `validation` is committed, the writeback and spend receipts are missing | +| A step raises after the durable writeback | The adapter leaves nothing behind; the CLI's canonical receipt store holds the committed receipt | The readback is `settlement_pending` with progress `spend_required`: `validation` and `durable_writeback` are committed, the spend receipt is missing | +| A step raises after the quota spend | The adapter leaves nothing behind; the CLI's canonical receipt store holds the committed receipt | The readback is `settled`; the retry replays that receipt (`idempotent_replay`), spends no second slot and reports the same settlement identity | + +Three findings follow from these cases. + +1. **The adapter makes no classification decision.** A stopped plan is + classified by the TypeScript owner; a raised step is not classified at all, + because the owner is never asked. The adapter is transport in both cases. +2. **The recovery boundary is the CLI receipt, not adapter state.** After a + raise, the only durable evidence is what the CLI already committed. The + retry succeeds because that receipt replays, and the adapter contributes + nothing but a re-execution of the plan. +3. **The outstanding work is named by the owner, stage by stage.** The + readback distinguishes an open settlement from a pending one and names the + step still owed — `writeback_required` after the lifecycle completion, + `spend_required` after the durable writeback, `settled` after the spend. + A raise is therefore not a single undifferentiated failure state, and the + committed receipt kinds tell which stage a retry would resume from. + +## Evidence + +`tests/control_plane/test_host_adapter_settlement_partial_steps.py`: + +- `test_an_unreadable_step_output_stops_the_plan_and_the_owner_classifies_it` +- `test_a_raising_step_leaves_no_finalize_and_no_adapter_local_resume` +- `test_a_raised_step_after_a_durable_commit_leaves_only_that_stage_pending` + (real CLI, real quota store; parametrized over the guard, lifecycle + completion, durable writeback and quota spend stages, reading back the + committed receipt kinds, the replay phase and the settlement progress) +- `test_a_raising_step_after_a_durable_commit_recovers_through_the_cli_receipt` + (real CLI, real quota store) +- `test_a_raised_step_before_completion_commits_no_settlement` (real CLI) + +The existing end-to-end recovery case stays where it was: +`tests/test_goal_mode_mcp_settlement.py::test_real_mcp_completion_recovers_a_lost_mutation_response` +covers a lost response after a commit; the new case covers the same recovery +through a raised transport instead. + +## What this does not establish + +- No equivalence proof, no deletion authorization, no runtime qualification, no + benefit measurement, and no claim that either settlement path is redundant. +- The characterization covers the shipped MCP host path through + `settle_host_todo_completion`. It does not characterize other callers of the + adapter, and it does not describe the TypeScript-only Turn settlement path, + which recovers per provider step inside its own transaction. +- It adds no journal, retry policy or resume capability to the adapter. Adding + one is a new design decision with its own acceptance, not a characterization. +- The observed recovery depends on the CLI's canonical receipt store. Source + facts were read at the baseline above and may drift. + +## Next slices + +Unchanged by this entry, with one correction to how the remaining work is +described. A deletion of the adapter-side interpreter still needs the +preconditions recorded in the comparison entry. The vocabulary candidate that +the confirmation entry withdrew — one typed owner for `guard`, +`lifecycle_completion` and `validation` — is not reopened here: that entry +already identified the shipped caller that adopts both paths and recorded the +candidate as withdrawn. The independent registry slice — the shared owner for +the ordered host-adapter step kinds — is completed in +[#7619](https://github.com/loopx-project/loopx/pull/7619). That slice was a +registry change, not a continuation of the withdrawn unification. The work +that remains open is the deletion and adoption preconditions recorded in the +comparison entry. diff --git a/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-adapter-partial-step-characterization.zh-CN.md b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-adapter-partial-step-characterization.zh-CN.md new file mode 100644 index 0000000000..6d59912f0d --- /dev/null +++ b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-adapter-partial-step-characterization.zh-CN.md @@ -0,0 +1,78 @@ +# C4.3 适配器部分步骤行为刻画 + +- 上级:[controller/executor RFC,C4.3](2026-10-27-refactor-handoff.zh-CN.md#c43-adapter)。 +- 基线:`8caaf678e`,2026-10-29。结算属主自 `1e3401591` 起未变。 +- 状态:只做刻画。不改变运行时、schema、wire 或默认值。 +- 上游条目:[C4.3 结算义务对照](2026-10-29-c4-3-settlement-obligation-comparison.zh-CN.md)。 +- [英文版](2026-10-29-c4-3-adapter-partial-step-characterization.md)。 + +## 为什么有这条记录 + +对照条目把"适配器没有本地 journal"记成关于适配器自身的事实,但适配器在 +"某步成功、后续步失败"时的自身行为,以及适配器与 CLI 回执之间的恢复边界落在 +哪里,当时仍没有被刻画。任何会改动这条路径的整合切片,都只能对这两点做假设。 + +本条目用可执行用例把该行为钉住,让后续的恢复改动从实测行为出发,而不是从对照 +条目的措辞出发。 + +## 现在刻画了什么 + +| 场景 | 适配器行为 | 属主行为 | +| --- | --- | --- | +| 某一步返回适配器读不懂的输出 | `_provider_condition_matches` 把非 JSON 输出当作"条件不匹配",计划在该步之后停止 | finalize 阶段仍然运行,由属主判定这次停止:`blocked`、`failed_stage: guard`、原因 `quota guard returned malformed JSON` | +| 某一步抛错 | 异常直接冒出适配器。finalize 阶段从未运行,这笔事务从未被分类。适配器不保留已成功那一步的任何记录 | 没有分类,因为属主从未被询问 | +| 抛错后的重试 | 适配器从计划第一步重新进入,绝不在中途续跑 | 身份由同一意图重新推导,因此使用同一个 settlement identity | +| 某一步在生命周期完成之前抛错 | 没有花费,Todo 保持 open | guard 已经跑过,因此 `validation` 凭据已提交。读回为 `open`,进度为 `writeback_required`;不存在可重放的生命周期完成、写回或花费凭据 | +| 某一步在生命周期完成提交之后抛错 | 适配器什么也不留下;已提交的凭据由 CLI 的规范凭据库持有 | 读回为 `settlement_pending`,进度为 `writeback_required`:`validation` 已提交,写回与花费凭据缺失 | +| 某一步在持久写回提交之后抛错 | 适配器什么也不留下;已提交的凭据由 CLI 的规范凭据库持有 | 读回为 `settlement_pending`,进度为 `spend_required`:`validation` 与 `durable_writeback` 已提交,花费凭据缺失 | +| 某一步在配额花费提交之后抛错 | 适配器什么也不留下;已提交的凭据由 CLI 的规范凭据库持有 | 读回报告 `settled`;重试重放该凭据(`idempotent_replay`),不会再扣一个槽位,并报告同一个 settlement identity | + +由此得到三条结论。 + +1. **适配器不做任何分类判定。** 计划停止由 TypeScript 属主分类;某一步抛错则 + 完全没有分类,因为属主从未被询问。两种情形下适配器都只是传输。 +2. **恢复边界是 CLI 凭据,不是适配器状态。** 抛错之后,唯一持久存在的证据是 + CLI 已经提交的东西。重试之所以成功,是因为那份凭据可以重放;适配器除了重跑 + 计划之外没有贡献任何东西。 +3. **欠下的工作由属主按阶段点名。** 读回区分"结算尚未开始"与"结算待补",并指出 + 还欠哪一步:生命周期完成之后是 `writeback_required`,持久写回之后是 + `spend_required`,配额花费之后是 `settled`。因此抛错并不是一种不加区分的 + 失败状态;已提交的凭据类型说明重试会从哪个阶段继续。 + +## 证据 + +`tests/control_plane/test_host_adapter_settlement_partial_steps.py`: + +- `test_an_unreadable_step_output_stops_the_plan_and_the_owner_classifies_it` +- `test_a_raising_step_leaves_no_finalize_and_no_adapter_local_resume` +- `test_a_raised_step_after_a_durable_commit_leaves_only_that_stage_pending` + (真实 CLI、真实配额存储;按 guard、生命周期完成、持久写回与配额花费四个阶段 + 参数化,读回已提交的凭据类型、replay phase 与结算进度) +- `test_a_raising_step_after_a_durable_commit_recovers_through_the_cli_receipt` + (真实 CLI、真实配额存储) +- `test_a_raised_step_before_completion_commits_no_settlement`(真实 CLI) + +既有的端到端恢复用例仍在原处: +`tests/test_goal_mode_mcp_settlement.py::test_real_mcp_completion_recovers_a_lost_mutation_response` +覆盖的是提交之后响应丢失;新增用例覆盖同一条恢复路径在传输抛错时的形态。 + +## 本条目没有建立什么 + +- 没有等价性证明,没有删除授权,没有运行时资格,没有收益测量,也没有断言任何 + 一条结算路径是多余的。 +- 刻画覆盖的是经 `settle_host_todo_completion` 的已交付 MCP host 路径。它不覆盖 + 适配器的其他调用方,也不描述纯 TypeScript 的 Turn settlement 路径——后者在 + 自己的事务内按 provider 步骤恢复。 +- 它没有给适配器增加 journal、重试策略或续跑能力。增加这些是新的设计决策, + 有各自的验收,不是刻画。 +- 观察到的恢复依赖 CLI 的规范凭据库。源码事实基于上述基线读取,可能漂移。 + +## 后续切片 + +本条目不改变结论,但修正了剩余工作的表述方式。删除适配器侧解释器仍需要对照条目 +记录的前提条件。词汇对照条目已经撤回的候选——把 `guard`、`lifecycle_completion` +与 `validation` 归到一个 typed 属主——在此不重新打开:那条目已经指出了同时采用 +两条路径的已交付调用方,并把该候选记为 withdrawn。独立的登记切片——为 host-adapter +有序步骤类型设立共享属主——已在 +[#7619](https://github.com/loopx-project/loopx/pull/7619) 中完成;那是登记切片, +不是被撤回的统一化工作的延续。仍然开放的是对照条目记录的删除与采用前提。 diff --git a/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.md b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.md index 02620ff0d9..5d6f2181f6 100644 --- a/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.md +++ b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.md @@ -110,7 +110,7 @@ That gap is a separate slice: one shared owner for the ordered host-adapter step kinds, registered in the vocabulary registry so that the guard/validation distinction becomes machine-readable instead of prose. It must first reconcile the in-flight settlement work: #3412, #5677, #7580, #7523. That slice is -proposed in [#7619](https://github.com/loopx-project/loopx/pull/7619): it +merged in [#7619](https://github.com/loopx-project/loopx/pull/7619): it registers the ordered host-adapter step kinds as their own vocabulary with both runtimes as owners, so the two carriers are compared on every drift scan instead of hand-kept. diff --git a/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.zh-CN.md b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.zh-CN.md index b137757052..77f3ef0619 100644 --- a/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.zh-CN.md +++ b/docs/architecture/rfcs/ledger/controller-executor-unified-contract-v0/2026-10-29-c4-3-settlement-vocabulary-confirmation.zh-CN.md @@ -96,7 +96,7 @@ C4.3 结算义务对照列出一个候选后续切片:*词汇统一(小) 这是一个独立切片:为 host-adapter 的有序步骤类型给出同一个共享属主,并登记进 词汇 registry,让 guard/validation 的区别从散文变成机器可读。它必须先调和在途的 结算工作:#3412、#5677、#7580、#7523。该切片已在 -[#7619](https://github.com/loopx-project/loopx/pull/7619) 中提出:它把 host-adapter +[#7619](https://github.com/loopx-project/loopx/pull/7619) 中合入:它把 host-adapter 的有序步骤类型登记为独立词汇,并由两个运行时共同作为属主,于是两个载体在每次 漂移扫描中都会被比较,而不再靠手工维护。 diff --git a/tests/control_plane/test_host_adapter_settlement_partial_steps.py b/tests/control_plane/test_host_adapter_settlement_partial_steps.py new file mode 100644 index 0000000000..cb82f26208 --- /dev/null +++ b/tests/control_plane/test_host_adapter_settlement_partial_steps.py @@ -0,0 +1,463 @@ +"""Characterize the host settlement adapter's own partial-step behavior. + +C4.3's obligation comparison recorded that the host Todo adapter keeps no +journal of its own: the durable settlement receipts live in the CLI, and the +TypeScript owner classifies a stopped plan. What was still uncharacterized is +the adapter's own behavior when a step succeeds and a later step fails, and +where the recovery boundary between the adapter and the CLI receipts sits. + +These cases pin that behavior before any recovery change touches this path. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from loopx.control_plane import host_adapter_settlement +from loopx.control_plane.coordination.goal_storage_initialization import ( + initialize_goal_storage_target, +) +from loopx.control_plane.host_adapter_settlement import ( + HostTodoSettlementRequest, + settle_host_todo_completion, +) +from loopx.control_plane.quota.settlement import read_heartbeat_settlement +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane +from loopx.status import parse_active_state_todos +from loopx.todos import add_goal_todo + + +GOAL_ID = "adapter-partial-steps-fixture" +AGENT_ID = "claude" +TODO_ID = "todo_abc123" + + +def _request() -> HostTodoSettlementRequest: + return HostTodoSettlementRequest( + goal_id="goal", + agent_id="agent", + todo_id=TODO_ID, + runtime_profile="claude_code", + legacy_host_surface="claude_code", + scheduler_owner="agent_cli_loop", + execution_mode="interactive", + completion_args=("todo", "complete", TODO_ID), + ) + + +def _guard_payload() -> str: + """The guard shape the TypeScript owner accepts as a Todo selection.""" + return json.dumps({"ok": True, "selected_todo": {"todo_id": TODO_ID}}) + + +def test_an_unreadable_step_output_stops_the_plan_and_the_owner_classifies_it() -> None: + """The adapter stops; the TypeScript owner decides what the stop means.""" + calls: list[list[str]] = [] + + def provider(args: list[str], *, legacy_args: list[str] | None = None) -> str: + del legacy_args + calls.append(args) + # A CLI failure returns its stderr, which is not JSON at all. + return "quota should-run failed: synthetic CLI failure" + + result = json.loads(settle_host_todo_completion(_request(), run_cli=provider)) + + assert [call[:2] for call in calls] == [["quota", "should-run"]] + assert result["ok"] is False + assert result["settlement_blocked_completion"] is True + assert result["settlement"]["failed_stage"] == "guard" + assert result["settlement"]["reason"] == "quota guard returned malformed JSON" + + +def test_a_raising_step_leaves_no_finalize_and_no_adapter_local_resume( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed step ends the call before the owner is asked to finalize.""" + phases: list[str] = [] + runtime_result = host_adapter_settlement.effect_runtime_result + + def spy(method: str, params: dict[str, object]) -> object: + phases.append(str(params.get("phase"))) + return runtime_result(method, params) + + monkeypatch.setattr(host_adapter_settlement, "effect_runtime_result", spy) + + calls: list[list[str]] = [] + + def provider(args: list[str], *, legacy_args: list[str] | None = None) -> str: + del legacy_args + calls.append(args) + if len(calls) == 2: + raise RuntimeError("synthetic CLI step failure") + return _guard_payload() + + with pytest.raises(RuntimeError, match="synthetic CLI step failure"): + settle_host_todo_completion(_request(), run_cli=provider) + + # The finalize phase never ran, so the owner never classified the failure. + assert phases == ["prepare"] + # The failing step was attempted; the earlier step had already succeeded. + assert [call[:2] for call in calls] == [ + ["quota", "should-run"], + ["todo", "complete"], + ] + + calls.clear() + phases.clear() + with pytest.raises(RuntimeError, match="synthetic CLI step failure"): + settle_host_todo_completion(_request(), run_cli=provider) + + # A retry re-enters the plan at its first step. The adapter holds no + # record of the step that already succeeded and never resumes mid-plan. + assert phases == ["prepare"] + assert [call[:2] for call in calls] == [ + ["quota", "should-run"], + ["todo", "complete"], + ] + + +def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: + project = tmp_path / "project" + project.mkdir() + state_file = project / "ACTIVE_GOAL_STATE.md" + state_file.write_text( + "\n".join( + [ + "---", + f"goal_id: {GOAL_ID}", + "status: active", + "updated_at: 2026-10-29T00:00:00+00:00", + "---", + "", + "## Agent Todo", + "", + ] + ) + + "\n", + encoding="utf-8", + ) + registry = tmp_path / "registry.global.json" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(tmp_path / "runtime"), + "goals": [ + { + "id": GOAL_ID, + "creation_operation_id": f"goal-create:{GOAL_ID}", + "domain": "harness_self_improvement", + "status": "active", + "repo": str(project), + "state_file": state_file.name, + "adapter": { + "kind": "harness_self_improvement", + "status": "connected-read-only", + }, + "quota": { + "compute": 1.0, + "window_hours": 24, + "slot_minutes": 1, + }, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": [AGENT_ID], + "storage_target": { + "schema_version": "loopx_new_goal_storage_target_v1", + "provider": "file", + "handoff_mode": "soft_claim", + }, + }, + } + ], + } + ), + encoding="utf-8", + ) + goal = json.loads(registry.read_text(encoding="utf-8"))["goals"][0] + initialized = initialize_goal_storage_target( + tmp_path / "runtime", + goal, + registry_path=registry, + ) + assert initialized["authority_initialized"] is True + assert initialized["legacy_fallback_used"] is False + return registry, state_file + + +def _control(registry: Path) -> GoalModeMCPControlPlane: + control = GoalModeMCPControlPlane( + GoalModeMCPConfig( + server_name="loopx-test", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + ), + lambda: { + "goal_id": GOAL_ID, + "registry": str(registry), + "agent_id": AGENT_ID, + }, + ) + control.command_prefix = lambda: [sys.executable, "-m", "loopx.cli"] + return control + + +def test_a_raising_step_after_a_durable_commit_recovers_through_the_cli_receipt( + tmp_path: Path, +) -> None: + """The recovery boundary: the CLI owns the receipt, not the adapter.""" + registry, _ = _write_fixture(tmp_path) + added = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Characterize the partial-step recovery boundary.", + task_class="advancement_task", + claimed_by=AGENT_ID, + ) + todo_id = str(added["todo_id"]) + control = _control(registry) + original = control.run_cli + injected = False + bound_identity: dict[str, object] = {} + bound_turn_instance_id = "" + bound_effect_id = "" + calls: list[list[str]] = [] + + def raise_after_commit(args: list[str], **kwargs: object) -> str: + nonlocal injected, bound_identity, bound_turn_instance_id, bound_effect_id + calls.append(list(args)) + output = original(args, **kwargs) # type: ignore[arg-type] + if not injected and args[:2] == ["quota", "spend-slot"]: + committed = json.loads(output) + if committed.get("appended") is True: + injected = True + bound_identity = dict(committed["settlement_identity"]) + bound_turn_instance_id = str(bound_identity["turn_instance_id"]) + bound_effect_id = str(bound_identity["effect_id"]) + raise RuntimeError("synthetic CLI transport failure after commit") + return output + + control.run_cli = raise_after_commit # type: ignore[assignment] + + with pytest.raises(RuntimeError, match="synthetic CLI transport failure"): + control.complete_task( + todo_id, + AGENT_ID, + "partial-step fixture check passed", + no_follow_up=True, + ) + + assert injected + readback = read_heartbeat_settlement( + tmp_path / "runtime", + goal_id=GOAL_ID, + agent_id=AGENT_ID, + todo_id=todo_id, + turn_instance_id=bound_turn_instance_id, + ) + # The spend receipt was committed before the failure, and the adapter left + # nothing of its own behind it. + assert readback.replay_phase == "settled" + assert readback.progress is not None + assert readback.progress["state"] == "settled" + # Only the spend stage was reached, so only its receipts exist. The + # validation receipt is committed even when the plan stops early. + assert _committed_receipt_kinds(readback) == [ + "validation", + "durable_writeback", + "quota_spend", + ] + + calls.clear() + replay = json.loads( + control.complete_task( + todo_id, + AGENT_ID, + "partial-step fixture check passed", + no_follow_up=True, + ) + ) + + assert replay["ok"] is True, json.dumps(replay, indent=2, sort_keys=True) + # The retry reuses the same settlement identity: the identity is derived + # from the same intent, not from adapter-local state. + assert replay["settlement_identity"]["turn_instance_id"] == bound_turn_instance_id + assert replay["settlement_identity"]["effect_id"] == bound_effect_id + assert replay["settlement_identity"]["todo_id"] == bound_identity["todo_id"] + # The retry replays the CLI receipt instead of spending a second slot. + assert replay["settlement"]["quota_spend"]["appended"] is False + assert replay["settlement"]["quota_spend"]["idempotent_replay"] is True + # The retry re-entered the plan at its first step: no adapter-local resume. + assert calls[0][:2] == ["quota", "should-run"] + + status = json.loads(control.should_run()) + assert status["quota"]["spent_slots"] == 1 # type: ignore[index] + + +def test_a_raised_step_before_completion_commits_no_settlement( + tmp_path: Path, +) -> None: + """A failed transport never becomes a settlement the CLI would replay.""" + registry, state_file = _write_fixture(tmp_path) + added = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Prove that a raised step commits no settlement receipt.", + task_class="advancement_task", + claimed_by=AGENT_ID, + ) + todo_id = str(added["todo_id"]) + control = _control(registry) + original = control.run_cli + turn_instance_id = "" + + def raise_on_completion(args: list[str], **kwargs: object) -> str: + nonlocal turn_instance_id + if "--turn-instance-id" in args: + turn_instance_id = str(args[args.index("--turn-instance-id") + 1]) + if args[:2] == ["todo", "complete"]: + raise RuntimeError("synthetic CLI transport failure before completion") + return original(args, **kwargs) # type: ignore[arg-type] + + control.run_cli = raise_on_completion # type: ignore[assignment] + + with pytest.raises(RuntimeError, match="synthetic CLI transport failure"): + control.complete_task( + todo_id, + AGENT_ID, + "raised step fixture check passed", + ) + + # Nothing was completed and nothing was spent: the failure happened + # before the CLI committed the lifecycle completion. + status = json.loads(control.should_run()) + assert status["quota"]["spent_slots"] == 0 # type: ignore[index] + + todos = parse_active_state_todos(state_file.read_text(encoding="utf-8")) + by_id = { + str(item["todo_id"]): item + for item in todos["agent_todos"]["items"] + } + assert by_id[todo_id]["status"] == "open" + + # The guard already ran, so the validation receipt is committed. No + # lifecycle completion, writeback or spend receipt exists, and the + # settlement is still open rather than pending. + readback = read_heartbeat_settlement( + tmp_path / "runtime", + goal_id=GOAL_ID, + agent_id=AGENT_ID, + todo_id=todo_id, + turn_instance_id=turn_instance_id, + ) + assert readback is not None + assert readback.replay_phase == "open" + assert readback.progress is not None + assert readback.progress["state"] == "writeback_required" + assert _committed_receipt_kinds(readback) == ["validation"] + + +def _raise_after_step( + tmp_path: Path, + step_prefix: tuple[str, ...], +) -> 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) + added = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Characterize the partial-step recovery boundary.", + task_class="advancement_task", + claimed_by=AGENT_ID, + ) + todo_id = str(added["todo_id"]) + control = _control(registry) + original = control.run_cli + turn_instance_id = "" + injected = False + + def raise_after_commit(args: list[str], **kwargs: object) -> str: + nonlocal turn_instance_id, injected + if "--turn-instance-id" in args: + turn_instance_id = str(args[args.index("--turn-instance-id") + 1]) + output = original(args, **kwargs) # type: ignore[arg-type] + if not injected and tuple(args[: len(step_prefix)]) == step_prefix: + injected = True + raise RuntimeError("synthetic CLI transport failure after commit") + return output + + control.run_cli = raise_after_commit # type: ignore[assignment] + with pytest.raises(RuntimeError, match="synthetic CLI transport failure"): + control.complete_task( + todo_id, + AGENT_ID, + "partial-step fixture check passed", + no_follow_up=True, + ) + return tmp_path / "runtime", todo_id, turn_instance_id + + +def _committed_receipt_kinds(readback: object) -> list[str]: + """The receipt kinds the CLI committed, in settlement order.""" + return [ + receipt.step_kind.value + for result in ( + readback.identity, # type: ignore[attr-defined] + readback.writeback, # type: ignore[attr-defined] + readback.spend, # type: ignore[attr-defined] + ) + for receipt in result.receipts + ] + + +@pytest.mark.parametrize( + ("step_prefix", "expected_replay_phase", "expected_state", "expected_receipt_kinds"), + [ + (("quota", "should-run"), "open", "writeback_required", ["validation"]), + (("todo", "complete"), "settlement_pending", "writeback_required", ["validation"]), + ( + ("refresh-state",), + "settlement_pending", + "spend_required", + ["validation", "durable_writeback"], + ), + ( + ("quota", "spend-slot"), + "settled", + "settled", + ["validation", "durable_writeback", "quota_spend"], + ), + ], +) +def test_a_raised_step_after_a_durable_commit_leaves_only_that_stage_pending( + tmp_path: Path, + 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) + + readback = read_heartbeat_settlement( + runtime_root, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + todo_id=todo_id, + turn_instance_id=turn_instance_id, + ) + assert readback is not None + assert readback.replay_phase == expected_replay_phase + assert readback.progress is not None + assert readback.progress["state"] == expected_state + assert _committed_receipt_kinds(readback) == expected_receipt_kinds