Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions loopx/control_plane/quota/live_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 25 additions & 21 deletions loopx/control_plane/quota/unsettled_host_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions skills/loopx-self-repair/references/repair-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
108 changes: 108 additions & 0 deletions tests/control_plane/test_effect_turn_live_quota_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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" <!-- loopx:todo todo_id={todo_id} status=open "
"task_class=advancement_task -->\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",
Expand Down Expand Up @@ -261,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" <!-- loopx:todo todo_id={todo_id} status=blocked "
"task_class=advancement_task -->\n"
"- [ ] [P1] Continue an unrelated visible item.\n"
" <!-- loopx:todo todo_id=todo_visible status=open "
"task_class=advancement_task -->\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:
Expand Down
Loading