From 5d83b9b0d6964c0da6763b024d39ff33da079dff Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:34:39 +0800 Subject: [PATCH 1/3] fix(quota): read exact Todo lifecycle during host closeout recovery Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/quota/live_decision.py | 1 + .../quota/unsettled_host_turn.py | 46 ++++---- .../test_effect_turn_live_quota_decision.py | 12 ++ .../test_quota_settlement_cli.py | 108 ++++++++++++++++++ 4 files changed, 146 insertions(+), 21 deletions(-) diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index eddd0a6276..a4536673ea 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -499,6 +499,7 @@ def build_live_quota_should_run_decision( interaction.update(projections) apply_unsettled_host_turn_recovery_if_required( payload, + registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, agent_id=agent_id, diff --git a/loopx/control_plane/quota/unsettled_host_turn.py b/loopx/control_plane/quota/unsettled_host_turn.py index 1379dca6ca..cc38ff487f 100644 --- a/loopx/control_plane/quota/unsettled_host_turn.py +++ b/loopx/control_plane/quota/unsettled_host_turn.py @@ -19,30 +19,28 @@ UNSETTLED_HOST_TURN_RECOVERY_SCHEMA_VERSION = "unsettled_host_turn_recovery_v0" -def _todo_item_by_id( - payload: Mapping[str, Any], todo_id: str -) -> Mapping[str, Any] | None: - summary = payload.get("agent_todo_summary") - if not isinstance(summary, Mapping): - return None - for value in summary.values(): - if not isinstance(value, list): - continue - for item in value: - if isinstance(item, Mapping) and item.get("todo_id") == todo_id: - return item - return None - - def _typed_lifecycle_closeout( - payload: Mapping[str, Any], *, + registry_path: Path, + runtime_root: Path, + goal_id: str, todo_id: str | None, ) -> str | None: if not todo_id: return None - item = _todo_item_by_id(payload, todo_id) - if item is None: + # Reuse the exact-ID read path: presentation lanes omit terminal and + # blocked rows and cannot prove the absence of a lifecycle transition. + from ...todos import list_goal_todos + + readback = list_goal_todos( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id=goal_id, + role="agent", + todo_id=todo_id, + ) + item = readback.get("todo") + if not isinstance(item, Mapping) or item.get("todo_id") != todo_id: return None status = str(item.get("status") or "") if ( @@ -58,8 +56,8 @@ def _typed_lifecycle_closeout( def _unsettled_host_turn_recovery( - payload: Mapping[str, Any], *, + registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str | None, @@ -90,7 +88,12 @@ def _unsettled_host_turn_recovery( ) if readback is not None and readback.settlement.failure is None: return None - lifecycle_closeout = _typed_lifecycle_closeout(payload, todo_id=todo_id) + lifecycle_closeout = _typed_lifecycle_closeout( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=todo_id, + ) if lifecycle_closeout is not None: return None details_value = receipt.get("details") @@ -127,6 +130,7 @@ def _unsettled_host_turn_recovery( def apply_unsettled_host_turn_recovery_if_required( payload: dict[str, Any], *, + registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str | None, @@ -139,7 +143,7 @@ def apply_unsettled_host_turn_recovery_if_required( """Preempt ordinary selection when the preceding host Turn lacks closeout.""" recovery = _unsettled_host_turn_recovery( - payload, + registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, agent_id=agent_id, diff --git a/tests/control_plane/test_effect_turn_live_quota_decision.py b/tests/control_plane/test_effect_turn_live_quota_decision.py index 43011fe9f1..752c24094d 100644 --- a/tests/control_plane/test_effect_turn_live_quota_decision.py +++ b/tests/control_plane/test_effect_turn_live_quota_decision.py @@ -189,6 +189,18 @@ def test_managed_turn_projects_prior_unsettled_heartbeat_recovery( agent_id = "codex-fixture" todo_id = "todo_ordinary_work" prior_turn_id = "managed-prior-turn" + state_path = tmp_path / "ACTIVE_GOAL_STATE.md" + state_path.write_text( + "# Goal\n\n## Agent Todo\n\n- [ ] Keep advancing the selected task.\n" + f" \n", + encoding="utf-8", + ) + (tmp_path / "registry.json").write_text(json.dumps({ + "common_runtime_root": str(runtime_root), + "goals": [{"id": GOAL_ID, "repo": str(tmp_path), + "state_file": str(state_path)}], + }), encoding="utf-8") event = build_rollout_event( goal_id=GOAL_ID, event_kind="quota_should_run", diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 457f1fe94f..270fff727d 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -1177,6 +1177,114 @@ def test_recovery_does_not_bind_current_replan_and_reenters_same_turn( ) +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("hidden_count", [0, 6]) +def test_prior_host_closeout_survives_hidden_todo_lifecycle( + tmp_path: Path, + hidden_count: int, + provider: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, + ) + from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, + ) + + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry_path = _write_fixture(tmp_path) + _configure_selectable_alternative(project) + guard = ( + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--scan-path", + str(project), + ) + rc, prior = _run_cli( + registry_path, + runtime, + *guard, + "--turn-instance-id", + "turn-hidden-closeout-prior", + "--todo-id", + TODO_ID, + ) + assert rc == 0, prior + assert prior["heartbeat_receipt"]["closeout_required"] is True + rc, recovery = _run_cli(registry_path, runtime, *guard, "--begin-turn") + assert rc == 0, recovery + assert recovery["effective_action"] == "unsettled_host_turn_recovery" + state = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" + with state.open("a") as stream: + for index in range(hidden_count): + stream.write( + f"\n- [ ] [P0] Wait for validation {index}.\n" + f" \n" + ) + if provider != "legacy": + rc, listed = _run_cli( + registry_path, runtime, "todo", "list", "--goal-id", GOAL_ID + ) + assert rc == 0, listed + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, + handoff_mode="soft_claim", + todos=listed["todos"], + ) + initialize_canonical_authority( + runtime, GOAL_ID, projection, state_path=state, provider=provider + ) + rc, update = _run_cli( + registry_path, + runtime, + "todo", + "update", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--todo-id", + TODO_ID, + "--status", + "blocked", + "--reason", + "Waiting on independent validation.", + "--successor-todo-id", + ALTERNATIVE_TODO_ID, + ) + assert rc == 0, json.dumps(update, indent=2) + turn_id = recovery["heartbeat_receipt"]["turn_instance_id"] + rc, observed = _run_cli( + registry_path, runtime, *guard, "--turn-instance-id", turn_id + ) + assert observed["effective_action"] != "unsettled_host_turn_recovery", observed.get( + "unsettled_host_turn_recovery" + ) + assert rc == 0, observed + rc, resumed = _run_cli( + registry_path, + runtime, + *guard, + "--turn-instance-id", + turn_id, + "--todo-id", + ALTERNATIVE_TODO_ID, + ) + assert rc == 0, resumed + assert resumed["selected_todo"]["todo_id"] == ALTERNATIVE_TODO_ID + assert resumed["heartbeat_receipt"]["status"] == "upgraded" + assert resumed["quota"]["spent_slots"] == prior["quota"]["spent_slots"] + + def test_standard_codex_app_settlement_is_receipted_and_idempotent( tmp_path: Path, ) -> None: From 04467f98c20a5e1b141855058b48f9a29fe9c148 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:34:39 +0800 Subject: [PATCH 2/3] docs: record host closeout source boundary and transaction follow-up Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../rfcs/typescript-control-plane-migration-v0.md | 9 +++++++++ .../rfcs/typescript-control-plane-migration-v0.zh-CN.md | 7 +++++++ skills/loopx-self-repair/references/repair-patterns.md | 1 + 3 files changed, 17 insertions(+) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 07b603d12d..83ccb34044 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1124,6 +1124,15 @@ Subsequent candidates must name a remaining transaction and its deletion leverage; remaining quota settlement readback is eligible only when it can retire or materially shrink the facade rather than add another leaf handler. +The prior-host-Turn recovery boundary remains a transaction-level follow-up: +receipt selection, exact Todo lifecycle observation, settlement validation, and +recovery/continuation selection must move together before its Python coordinator +can be retired. The current source-boundary repair reuses `todo list --todo-id` +for lifecycle evidence so display truncation cannot keep a closed Turn in +recovery. It preserves closeout policy and adds no leaf RPC; it is not a completed +Stage 2B cutover. Future migration must retain crowded-inventory, provider-failure, +identity-conflict, and same-Turn no-spend recovery coverage. + For each completed transaction, replace migration-only characterization workers and Python implementation fixtures with native TS semantic/invariant tests plus one durable end-to-end adapter contract. Retain a characterization corpus only diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 4126299a4d..19925c8177 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -894,6 +894,13 @@ implementation fixture。只有旧 authority 仍可执行,或 versioned compat window 仍需 differential proof 时才保留 characterization corpus;引入时必须记录 删除触发条件。 +Prior-host-Turn recovery 保留为完整事务的后续迁移:receipt 选择、精确 Todo +lifecycle 读取、settlement 验证与 recovery/continuation 决策需要一起迁移,才能 +退出 Python coordinator。本次读取边界修复复用 `todo list --todo-id` 获取生命周期 +证据,避免展示截断让已关闭的 Turn 持续进入 recovery;关闭规则保持不变,不增加 +leaf RPC,也不将其计为已完成的 Stage 2B cutover。后续迁移需要保留大量无关 Todo、 +provider 失败、身份冲突及同 Turn 无扣额恢复的验证。 + 当前实现状态:Stage 1、bounded Stage 2A proof 与已交付的 Stage 2B cutover 已就位: - Turn settlement/commit:TypeScript 拥有 preflight authorization、ordered-prefix diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 11b371ce85..bb920aae25 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -17,6 +17,7 @@ teaches a reusable control-plane lesson. | `periodic_report_terminal_closeout_hook_gap` | Quota or status later proves validated no-follow-up, but no periodic-report candidate exists and monitor heartbeats remain quiet. | Last material refresh, final Todo completion receipt and settlement identity, post-writeback hook event kinds, terminal frontier projection, sidecar receipts, and external-effect count. | The hook ran only on the pre-completion refresh; the durable Todo completion transaction formed the terminal frontier afterward but did not dispatch post-writeback capability hooks. | Admit durable `todo_complete` as a typed post-writeback event, dispatch only after completion validation, persistence, and settlement readback succeed, rebuild the final frontier from committed state, and preserve replay-safe effect-free intent receipts. Keep ordinary completion and watch-only monitoring insufficient without validated stage closure. | | `pending_capability_intent_projection_gap` | A durable post-writeback sidecar says `intent_recorded`, but quota remains `monitor_quiet_skip` or `terminal_no_followup`; no governed executor wakes, so no local artifact or exact approval Todo appears. | Exact Goal/Agent sidecar, intent schema and authority, consumption receipt, quota interaction contract, generated-artifact count, approval-Todo count, and external-effect count. | The producer journal was durable but no read model projected unconsumed capability intents into quota arbitration. Terminal or quiet lifecycle state therefore hid required capability work forever. | Add a provider-neutral TypeScript-validated pending-intent interaction slot, let the opted-in capability read only exact eligible sidecars, and give the pending action precedence over quiet/terminal routes. Consume it through an idempotent local executor that freezes checked artifacts and one digest-bound user gate; keep external delivery unauthorized and suppress consumed intents on replay. | | `approved_capability_delivery_successor_gap` | A governed report or other capability payload is frozen and its exact user gate is approved, but quota returns quiet terminal/monitor state and no provider action runs. A later caller may also improvise a destination or default sender because the approval receipt contains no executable route. | Frozen generation/consumption receipt, completed gate decision and scope, linked agent Todo, required decision scopes before/after approval, Goal Channel binding, selected Todo, provider effect and exact sender/destination readback. | The consumer created only a user gate. Approval recorded authority but had no explicitly linked, typed agent successor for Todo/quota selection, so the external effect existed only in chat memory or caller convention. | Before the gate, create one blocked agent successor bound to the frozen generation, exact decision scope, provider capability, and safe write scope; link the gate with `unblocks_todo_id`. Approval must atomically consume only that scope and resume the successor. The provider then resolves route and sender from the durable project binding, rejects caller overrides/default fallbacks, and records success only after native effect readback. | +| `host_closeout_presentation_lookup_gap` | A prior heartbeat remains in closeout recovery after a successful blocked/deferred/completed Todo writeback; adding unrelated Todos changes recovery. | Receipt-bound Todo id, exact `todo list --todo-id` readback, compact quota visibility lanes, same-Turn retry and quota-spend count. | Recovery treated absence from a bounded presentation list as absence of a durable lifecycle transition. | Resolve the exact receipt-bound Todo through the existing provider-aware read path before evaluating closeout; preserve provider errors, identity binding and no-spend recovery. Cover crowded versus small inventories and real legacy/File/SQLite CLI writes; never raise display limits or fabricate settlement receipts. | | `turn_replay_recovery_semantic_gap` | A real Turn resumes from a safe Journal prefix, but `inspect-journal` presents `replay_legal=false` as if recovery were forbidden; scheduler-only or saved-Host-result recovery is especially misleading. | Journal integrity fields, replay decision, executor-adopted recovery decision, completed phase prefix, complete typed settlement identity, authoritative selected-Todo lineage, conditional Host Session Binding check, prepared-effect presence, and bounded recovery outcome. | Effect-free terminal replay and effectful executor recovery were collapsed into one user-facing decision even though the executor used separate status/phase rules; initial repairs also omitted either the settlement identity or its binding-to-Turn cross-check, allowing a drifted goal, agent, or canonical Todo to reach a later effect. | Keep replay legality and Journal consistency distinct. Make one typed recovery decision the source used by both executor and inspection; validate and cross-bind the canonical settlement goal, agent, Turn instance, binding, and effect id before authorization, including the selected Todo or adaptive primary Todo; project continue/resume phase/Host reinvocation/reason plus only participating checks, and persist a bounded planned-versus-actual audit. Cover identity and binding drift with zero-provider-call regressions. Preserve the existing prepared-effect readback owner and do not claim general exactly-once semantics. | | `managed_chat_resume_turn_identity_gap` | A managed Chat Session resumes after its adapter becomes unhealthy, returns to `ready`, and clears `active_turn_id`, but the interrupted Turn remains nonterminal and unowned. | Pre-resume Session snapshot, post-prepare persisted Session, adapter health, interrupted Turn status and error code, final Session state. | Resume preparation cleared the persisted active Turn reference before adapter recovery, then recovery re-read only the mutated Session and lost the pre-resume Turn identity. | Carry the pre-mutation Turn id through the fresh closed-state check, terminalize that Turn as `failed/server_restarted` before restoring the Session, and cover unhealthy-adapter resume with a focused regression while retaining the per-Session lifecycle lock. | | `dashboard_chat_stdio_encoding_gap` | Dashboard Chat completes ASCII turns but Codex app-server closes on non-ASCII input on Windows; direct stderr reports invalid UTF-8. | Failing multilingual request, app-server stderr, Python locale encoding, app-server `Popen` text-pipe options, and the same request under an explicit UTF-8 control. | The text-mode stdio pipe inherited the Windows system code page even though the app-server JSON-RPC transport requires UTF-8. | Set `encoding="utf-8"` explicitly on the app-server subprocess pipes and retain a focused launch-contract regression so host locale cannot change the protocol encoding. | From b4f37ace900da977dd11bfc9bc95b500db2b2563 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:28:52 +0800 Subject: [PATCH 3/3] test(quota): cover compact recovery lifecycle lookup Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../test_effect_turn_live_quota_decision.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/control_plane/test_effect_turn_live_quota_decision.py b/tests/control_plane/test_effect_turn_live_quota_decision.py index 752c24094d..91d15b7f63 100644 --- a/tests/control_plane/test_effect_turn_live_quota_decision.py +++ b/tests/control_plane/test_effect_turn_live_quota_decision.py @@ -273,6 +273,102 @@ def test_managed_turn_projects_prior_unsettled_heartbeat_recovery( assert contract["cli_channel"]["spend_after_validation"] is False +def test_recovery_reads_lifecycle_when_status_summary_omits_bound_todo( + tmp_path: Path, +) -> None: + runtime_root = tmp_path / "runtime" + registry_path = tmp_path / "registry.json" + state_path = tmp_path / "ACTIVE_GOAL_STATE.md" + agent_id = "codex-fixture" + todo_id = "todo_lifecycle_boundary" + prior_turn_id = "managed-prior-turn" + state_path.write_text( + "# Goal\n\n## Agent Todo\n\n" + "- [ ] [P1] Closed by an external lifecycle transition.\n" + f" \n" + "- [ ] [P1] Continue an unrelated visible item.\n" + " \n", + encoding="utf-8", + ) + registry_path.write_text( + json.dumps( + { + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": GOAL_ID, + "repo": str(tmp_path), + "state_file": str(state_path), + } + ], + } + ), + encoding="utf-8", + ) + event = build_rollout_event( + goal_id=GOAL_ID, + event_kind="quota_should_run", + agent_id=agent_id, + todo_id=todo_id, + run_id=prior_turn_id, + status="normal_run", + summary="managed heartbeat guard requires closeout", + details={ + "todo_id": todo_id, + "settlement_effect_id": f"{GOAL_ID}:{agent_id}:{todo_id}:{prior_turn_id}", + "closeout_required": True, + }, + ) + log_path = runtime_root / "goals" / GOAL_ID / "rollout-event-log.jsonl" + log_path.parent.mkdir(parents=True) + log_path.write_text(json.dumps(event) + "\n", encoding="utf-8") + + # This is the compact status shape that caused the regression: the bound + # blocked Todo is absent from every visible lane, while an unrelated item + # remains available for ordinary selection. + status = quota_status_payload( + goal_id=GOAL_ID, + status="active", + agent_todo_items=[ + { + "todo_id": "todo_visible", + "index": 2, + "text": "[P1] Continue an unrelated visible item.", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task", + } + ], + recommended_action="[P1] Continue an unrelated visible item.", + next_action="[P1] Continue an unrelated visible item.", + coordination={"registered_agents": [agent_id], "agent_model": "peer_v1"}, + claim_scope_agent_id=agent_id, + ) + packet = build_live_quota_should_run_decision( + status, + goal_id=GOAL_ID, + agent_id=agent_id, + available_capabilities=["shell"], + include_scheduler_detail=False, + codex_app_current_rrule=None, + registry_path=registry_path, + runtime_root=runtime_root, + route_source="loopx_turn_plan", + turn_instance_id="managed-current-turn", + scheduler_execution_context={ + "host_surface": "generic_cli", + "scheduler_owner": "agent_cli_loop", + "execution_mode": "interactive", + }, + ) + + assert packet["effective_action"] != "unsettled_host_turn_recovery" + assert packet["selected_todo"]["todo_id"] == "todo_visible" + + def test_action_selection_route_binding_fails_closed_on_malformed_prefix( tmp_path: Path, ) -> None: