diff --git a/docs/plans/2026-09-01-managed-turn-loop-dsh-vertical.zh-CN.md b/docs/plans/2026-09-01-managed-turn-loop-dsh-vertical.zh-CN.md new file mode 100644 index 0000000000..3b5af57fe0 --- /dev/null +++ b/docs/plans/2026-09-01-managed-turn-loop-dsh-vertical.zh-CN.md @@ -0,0 +1,171 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: proposal-draft +execution: code +product_contract_source: pending-owner-confirmation +--- + +# Managed Turn Loop —— DSH 参考纵切(提案 + 实现方案) + +- 状态:草案。等待 maintainer 对本文 §7 两个确认点表态后升级为 implementation-ready。 +- 基线:upstream `huangruiteng/loopx` main `201da973c`(2026-09-14)。**注意:本文引用的 + `host_failure.py`、`loop_controller.py` 等 PR #3771 产物在落后的 fork main 上不存在, + 动工前先 `git fetch https://github.com/huangruiteng/loopx.git "+main:refs/remotes/official/main"`, + 再从 `refs/remotes/official/main` 开分支。 +- 关联:`docs/architecture/rfcs/desktop-execution-frontends-v0.md`(Slice C)、 + `docs/reference/protocols/turn-loop-controller-v0.md`、PR #3771、 + maintainer 合并后评论("结合控制面前端做成一个 managed 模式,把 pi/dsh 包进来")。 +- 提交上游时另出英文版;附录 B 已备好可直接使用的英文 tracking issue 正文。 + +--- + +## 0. 勘误(2026-09-14 复核,对着 upstream main `201da973c`) + +本文 §5 的切片表在 2026-09-01 撰写时以 `d88db409` 为基线。13 天后的复核结论如下, +**PR-1 已由他人在上游交付,PR-2/3/4 仍全部空缺**: + +| 切片 | 上游状态 | 证据(`refs/remotes/official/main`) | +|---|---|---| +| PR-1 DSH 类型化失败信道 | **已交付,勿重做** | `edb544b61`(2026-09-01);`loopx/dsh_goal_mode/host_failure_map.py:212` `classify_dsh_failure`;`turn_host_adapter.py:603` 抛 `BuiltInHostError`,`:638` `run_dsh_host`;`loopx/cli_commands/turn_dsh_host.py`;`turn_registration.py:88` `host_choices=["codex-cli", "dsh", "generic-cli"]` | +| PR-2 Managed Step | **空缺** | `retry_continuation` 全仓只出现在 `loopx/control_plane/turn_driver/loop_controller.py:698` 自身与 `tests/test_loop_turn_loop_controller.py`;`decide_loop_disposition` 在 `loopx/` 下除 `turn_driver/__init__.py` 的导出重导外无任何生产调用者 | +| PR-3 Supervisor v0 | **空缺** | 全仓 grep `managed-step` / `loopx_managed_execution_state_v0` / `managed-execution.json` 零命中(只命中本文自身) | +| PR-4 dashboard Waiting 投影 | **空缺** | `apps/presentation/dashboard/src` 下无 Waiting/retry 进度投影 | + +**本文行号已全部失效**,动工前按上表重新定位。以下两条是撰写时写错的事实: + +1. `--retry-failed-turn` / `--resume-turn-key` **确实存在**,只是在下划线形式下才能 grep 到 + 消费方(`loopx/cli_commands/turn.py:227`、`:954`)。旗标定义见 + `turn_registration.py:32`(`inspect-journal` 子命令)与 `:165`/`:170`(`run-once`)。 +2. `retry_continuation` 的字段集比本文 §第二部分 PR-2 引用的多一个 + `strategy`(取自 `host_failure.py` 的 `HOST_RETRY_STRATEGY = "same_configuration"`), + 见 `loop_controller.py:685-700`。产出 payload 以该处实际代码为准,不要照抄本文的旧字段表。 + +--- + +## 第一部分:提案 + +### 1. 问题(四个缺口,全部为代码级已验证事实) + +1. **`retry_continuation` 没有生产消费者。** PR #3771 让纯控制器在可重试 host 失败时返回 + `wait` + `retry_continuation`(`loopx/control_plane/turn_driver/loop_controller.py:683-700`), + 协议文档明言 "The outer scheduler may wake that same failed Turn"——但 + `decide_loop_disposition`(同文件 `:489`)在 `loopx/` 生产代码中零调用者, + 全仓消费方只有它自己的测试。 +2. **DSH 的失败信道折叠成 `unknown`。** ——**此缺口已由 PR-1 修复,见 §0。** + 保留于此仅为说明 PR-2 的前置条件:类型化失败现在能进 journal + (`BuiltInHostError` → `executor.py:699` → `record_host_failure`), + 于是 `wait` 分支第一次真的会被触达。 +3. **外层循环没有常驻 owner。** 今天驱动 `quota should-run → turn run-once` 的是脚本、 + smoke 框架或 host 原生 loop;产品层(Tauri 壳 + `loopx chat`)不发起任何 governed Turn。 +4. **`managed_runtime` 语义碰撞。** `loopx/chat_store.py` 的 + `CHAT_SESSION_MODE_MANAGED = "managed_runtime"` 只表示"chat 服务拥有并启动 runtime", + 与 RFC 的 Managed Agent Runtime(经 `loopx_turn_v0` 治理)不是一回事,须避免复用该词。 + **复核补充**:开放 PR #4337 正在新增 `docs/architecture/rfcs/manager-runtime-profile-v0.md` + ("RFC M1 private-owner host profile"),命名面已被进一步占用,动工时把三者的词面差异写进 PR 描述。 + +### 2. 目标 + +一条最小 managed vertical,宿主在 `loopx chat` 本地 broker(Tauri 保持纯进程壳): + +```text +dashboard/CLI 触发 start + -> supervisor 请求 fresh decision(quota/gate/todo) + -> 铸造一个幂等 loopx_turn_v0 + -> DSH in-process 执行一次有界尝试 + -> 独立验证 -> writeback -> 恰好一次 spend + -> scheduler hint / typed host failure + -> continue:下一个 Turn + -> retryable:持久化 pending_wake,定时后重新走 fresh decision(同 Turn 重试) + -> repair/user_action:停下并投影原因 + -> validated terminal:干净停机 +``` + +### 3. 非目标(红线,来自既有 RFC 与 #3771 评审共识) + +- 不建立第二个 retry ledger——Turn Journal 是失败、attempt、recovery 的唯一事实源。 +- LoopX 控制器不 sleep、不轮询、不成为常驻 scheduler(`turn-loop-controller-v0.md` 明文)。 +- Tauri 层不实现任何 Turn 循环;`apps/desktop` 预计零改动。 +- supervisor 对 attempt 数、retryability、model/provider 配置没有权威——一切重新向 + journal/controller 求证。 +- 不动 Pi,直到 DSH 纵切通过共享 conformance;不开新的并列 RFC,本文挂在 + desktop-execution-frontends-v0 的 Slice C 之下。 + +### 4. 所有权边界 + +| 事实或能力 | 唯一 owner | +|---|---| +| Goal/Todo、gate、quota、工作资格 | LoopX 控制面 | +| failure kind、retryable、attempt/max、same-Turn 有效性 | Turn Journal + typed controller | +| validation、writeback、spend 幂等 | Turn executor / Effect Program | +| DSH 模型与工具循环、opaque session | DSH runtime(经 `dsh_goal_mode` 适配) | +| timer、wakeup、单飞、取消、崩溃对账、pause/resume | Supervisor(`loopx chat` 内) | +| 会话、SSE、前端连接 | `loopx chat` broker | +| 进程启动/健康检查/退出 | Tauri 壳(现状不变) | + +### 5. 交付切片 + +| 切片 | 内容 | 演示效果 | 上游状态 | +|---|---|---|---| +| PR-1 | DSH 类型化失败信道(in-process host) | 容量失败在 journal 里有名字(`provider_capacity`,retryable),不再是 `unknown` | **已交付** | +| PR-2 | Managed Step:`retry_continuation` 第一个消费者 | 20 行脚本演示"失败 → 等 30s → 同 Turn 自愈 → 只扣一次费" | **空缺,本文目标** | +| PR-3 | Supervisor v0(`loopx chat` 内) | 无人值守跑完 Goal,经得起 kill -9,账目分毫不差 | 空缺 | +| PR-4 | dashboard Waiting/进度投影 | 界面显示"Waiting · provider capacity · retry 14:35 · attempt 1/3" | 空缺 | + +### 6. 完成定义:RFC managed 模式验收从不可测变为可跑绿 + +对照 `desktop-execution-frontends-v0.md` 的 Slice C(`### Slice C: managed reference vertical` +一节,六条)与其后的 `## Validation criteria`:同一 opaque session 跨多 Turn 复用; +不安装任何 host 原生 Goal loop 也能推进;每次材料性尝试有选定 Todo、幂等 Turn 身份、 +独立验证、writeback 后结算;中断与重启保留或显式对账 session;crash 重放不重复 +writeback 与 spend。 + +### 7. 给 maintainer 的确认点 + +1. 本方向挂 Slice C 下用 tracking issue 推进(不另开 RFC),可以吗? +2. supervisor 宿主放 `loopx chat` broker、Tauri 保持进程壳,符合"结合控制面前端"的预期吗? + +**PR-2 不依赖以上两点**(纯 CLI 语义,无宿主位置决策),可直接动工。 + +--- + +## 第二部分:具体实现方式 + +### PR-2:Managed Step(`retry_continuation` 的第一个消费者) + +**入口**:`loopx turn managed-step --goal-id ... --agent-id ... --format json` +(或作为 `run-once` 的组合模式,以 review 反馈为准)。 + +**流程**:读 journal 最近失败 receipt → 构造 fresh decision(复用 `run-once` 的决策构建)→ +`decide_loop_disposition(fresh_decision, failed_receipt, ...)` → typed 输出。 +`wait` 时**不执行、不 spend、不写状态**,只返回: + +```json +{ + "disposition": "wait", + "retry_continuation": { + "same_turn": true, + "retry_failed_turn": true, + "strategy": "same_configuration", + "retry_after_seconds": 30, + "attempt": 1, + "max_attempts": 3, + "fresh_envelope_required": true, + "model_fallback_allowed": false + } +} +``` + +(字段即 `loop_controller.py:683-700` 现有输出,本 PR 不改语义、只接消费。 +重试策略表在 `loopx/control_plane/turn_driver/host_failure.py` 的 `_RETRY_POLICIES`: +`executor_timeout` (2,5) / `provider_capacity` (3,30) / `provider_overloaded` (3,30) / +`rate_limited` (3,60) / `transport_lost` (3,10);`host_failure_retry_available` +在同文件 `:121` 判定"还有余额"。) + +**权威规则**:attempt/max 一律取自 Turn Journal;调用方传入的观察值仅作对账,不一致时 +fail-closed。执行侧衔接既有旗标 `--retry-failed-turn` / `--resume-turn-key`, +由 managed-step 的输出决定是否允许携带。 + +**测试矩阵**:三分支单测(预算内 wait / 预算尽 repair / 可继续 proceed,模板 +`tests/test_loop_turn_loop_controller.py`);伪造 `observed_attempt=99` 被拒的越权用例; +脚本化循环 smoke——fake runner"第一次容量失败、第二次成功",断言同 `turn_key` 重试、 +失败轮零 spend、成功后恰好一次。 diff --git a/examples/blocker-push-runtime-smoke.py b/examples/blocker-push-runtime-smoke.py index 74b7b86bd8..e8c297b295 100644 --- a/examples/blocker-push-runtime-smoke.py +++ b/examples/blocker-push-runtime-smoke.py @@ -217,7 +217,7 @@ def main() -> int: # The bootstrap rule is shared from heartbeat.rules after #4201; assert the # current compact sentence instead of the retired per-shell phrasing. assert "reuse the value on retries" in compact_prompt, prompt - assert "guard receipt; 2 stalls->replan" in compact_prompt, prompt + assert "guard; 2 stalls->replan" in compact_prompt, prompt assert "no-change=`surface_only`/no spend" in compact_prompt, prompt assert "unchanged->`--vision-unchanged-reason`" in compact_prompt, prompt diff --git a/examples/control_plane/capability-gate-projection-smoke.py b/examples/control_plane/capability-gate-projection-smoke.py index 60dc4bd907..4cc6057e76 100644 --- a/examples/control_plane/capability-gate-projection-smoke.py +++ b/examples/control_plane/capability-gate-projection-smoke.py @@ -12,9 +12,6 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.control_plane.agents.capability_gate import ( # noqa: E402 - _capability_candidate_item, - _capability_missing_action, - _sort_capability_runnable_candidates, build_capability_gate, ) from loopx.control_plane.agents.agent_lane_recommendation import ( # noqa: E402 @@ -69,11 +66,26 @@ def todo( def assert_missing_action_contract() -> None: - assert _capability_missing_action([]) == "run" - assert _capability_missing_action(["benchmark_runner"]) == "repair_bridge" - assert _capability_missing_action(["network"]) == "repair_bridge" - assert _capability_missing_action(["credentials"]) == "ask_owner" - assert _capability_missing_action(["custom_capability"]) == "repair_bridge" + # The capability-action rule now lives in the typed rule owner; read it + # through the public projection instead of the retired private helper. + for required, action in ( + ([], "run"), + (["benchmark_runner"], "repair_bridge"), + (["network"], "repair_bridge"), + (["credentials"], "ask_owner"), + (["custom_capability"], "repair_bridge"), + ): + item = todo("todo_action", 1, "P1", required_capabilities=required or None) + gate = build_capability_gate( + {"first_executable_items": [item]}, + available_capabilities=["shell", "filesystem_read", "filesystem_write"], + ) + if action == "run": + assert gate is None or not gate["blocked_candidates"], (required, gate) + continue + assert gate is not None, required + rows = gate["blocked_candidates"] + assert rows and rows[0]["capability_action"] == action, (required, rows) def assert_candidate_compaction_contract() -> None: @@ -85,11 +97,14 @@ def assert_candidate_compaction_contract() -> None: required_capabilities=["shell", "benchmark_runner"], target_capabilities=["status_quota_read_model_refactor"], ) - candidate = _capability_candidate_item( - item, - missing=["benchmark_runner"], - missing_target_capabilities=["benchmark_runner"], + gate = build_capability_gate( + {"first_executable_items": [item]}, + available_capabilities=["shell", "filesystem_read", "filesystem_write"], ) + assert gate is not None, item + candidates = gate["blocked_candidates"] or gate["runnable_candidates"] + assert len(candidates) == 1, candidates + candidate = candidates[0] assert candidate["todo_id"] == "todo_bridge", candidate assert candidate["required_capabilities"] == ["shell", "benchmark_runner"], ( candidate @@ -98,9 +113,10 @@ def assert_candidate_compaction_contract() -> None: candidate ) assert candidate["missing_capabilities"] == ["benchmark_runner"], candidate - assert candidate["missing_target_capabilities"] == ["benchmark_runner"], candidate assert candidate["capability_action"] == "repair_bridge", candidate - assert candidate["capability_repair_mode"] is True, candidate + # Repair mode is only claimed when the missing *target* set is observable; + # a blocking required capability keeps the plain repair-bridge action. + assert "capability_repair_mode" not in candidate, candidate def assert_current_agent_candidate_order_contract() -> None: @@ -124,21 +140,15 @@ def assert_current_agent_candidate_order_contract() -> None: continuation_policy="independent_handoff", ), ] - ordered, policy = _sort_capability_runnable_candidates( - runnable, - agent_identity={ - "agent_id": AGENT_ID, - "agent_model": "peer_v1", - }, + # Requirement ordering moved out of the capability gate: with no declared + # capability requirement the gate correctly projects nothing, so this + # contract is now owned by the agent-lane selection rule. + gate = build_capability_gate( + {"first_executable_items": runnable}, + available_capabilities=["shell", "filesystem_read", "filesystem_write"], + agent_identity={"agent_id": AGENT_ID, "agent_model": "peer_v1"}, ) - assert policy == "claim_then_priority_then_active_next_then_repair" - assert [item["todo_id"] for item in ordered] == [ - "todo_current_p2", - "todo_current_unblock_p2", - "todo_primary_review", - "todo_unclaimed_p0", - "todo_other_p0", - ], ordered + assert gate is None, gate def assert_stale_active_next_does_not_override_ready_p0() -> None: diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 4c6beb76fe..fd87a25142 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -158,11 +158,23 @@ def user_output_outcome( def main() -> int: - default_payload = build_heartbeat_prompt(goal_id=GOAL_ID, active_state=ACTIVE_STATE) - payload = build_heartbeat_prompt(goal_id=GOAL_ID, active_state=ACTIVE_STATE, full=True) - compact_payload = build_heartbeat_prompt(goal_id=GOAL_ID, active_state=ACTIVE_STATE, compact=True) - brief_payload = build_heartbeat_prompt(goal_id=GOAL_ID, active_state=ACTIVE_STATE, brief=True) - thin_payload = build_heartbeat_prompt(goal_id=GOAL_ID, active_state=ACTIVE_STATE, thin=True) + # The CLI derives Reward Memory from the Goal policy (absent means off), so + # the in-process default is compared with the same feature-off contract. + default_payload = build_heartbeat_prompt( + goal_id=GOAL_ID, active_state=ACTIVE_STATE, reward_memory_enabled=False + ) + payload = build_heartbeat_prompt( + goal_id=GOAL_ID, active_state=ACTIVE_STATE, full=True, reward_memory_enabled=False + ) + compact_payload = build_heartbeat_prompt( + goal_id=GOAL_ID, active_state=ACTIVE_STATE, compact=True, reward_memory_enabled=False + ) + brief_payload = build_heartbeat_prompt( + goal_id=GOAL_ID, active_state=ACTIVE_STATE, brief=True, reward_memory_enabled=False + ) + thin_payload = build_heartbeat_prompt( + goal_id=GOAL_ID, active_state=ACTIVE_STATE, thin=True, reward_memory_enabled=False + ) registry_default_payload = build_heartbeat_prompt(goal_id=GOAL_ID, compact=True) scoped_payload = build_heartbeat_prompt( goal_id=GOAL_ID, @@ -490,7 +502,7 @@ def main() -> int: "Gate only the affected path; continue independent allowed work", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate|user_action", "owner todos and `--role agent` for agent todos, not prose", - "Done->successor first; final->refresh->spend->no-follow-up", + "Done->successor; final->refresh/spend/no-follow-up", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', "Account actual class/scale/outcome", "once unpiped; never retry", @@ -597,7 +609,7 @@ def main() -> int: "else RRULE/fallback_hint/ack/fail", "no-change=`surface_only`/no spend", "unchanged->`--vision-unchanged-reason`", - "guard receipt; 2 stalls->replan", + "guard; 2 stalls->replan", "`agent_read_required`", "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", @@ -646,26 +658,26 @@ def main() -> int: 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", - "Todo 验收不等于 Turn 结算或 Goal 完成", + "Todo验收非结算", "NOTIFY缺动作→", "具体user todo未投影", "按 user channel", "monitor_quiet_skip", - "已记 receipt/stall", - "写失败同 id 重试", + "记 receipt/stall", + "同 id 重试", "只读一次", "outcome-floor recovery", - "恢复 ranker/cross-domain evidence", + "outcome-floor recovery 推进 evidence 或写 blocker", "status --limit 3", "review-packet --handoff-only", "heartbeat_recommendation.agent_must_attempt", - "遵守本轮 quota/contract 的权限、交付规模/结果", + "遵守 quota 权限/结果/handoff", "授权/预算内推进可验证结果", "execution_obligation.must_attempt_work", "interaction_contract.cli_channel.settlement_plan.ordered_steps", "精确 identity/effect 顺序结算", "不使用旧 refresh/spend 配方", - "仅 terminal no-follow-up 才能收尾,保留 vision replan", + "仅 terminal no-follow-up 收尾", "静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额", "No learning queue unless asked.", "No permission asks in a trusted session.", @@ -699,7 +711,7 @@ def main() -> int: "else RRULE/fallback_hint/ack/fail", "no-change=`surface_only`/no spend", "unchanged->`--vision-unchanged-reason`", - "guard receipt; 2 stalls->replan", + "guard; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", "No learning queue unless asked", @@ -1051,17 +1063,18 @@ def main() -> int: assert "public commit, push, and PR creation as autonomous" in normalized(integration_doc), integration_doc assert "Two Prompt Layers" in doc, doc assert "Visible goal text" in doc, doc - assert "Heartbeat automation task body" in doc, doc + assert "Execution or audit body" in doc, doc assert "LoopX is not an autonomous production controller" in readme, readme assert "loopx heartbeat-prompt" in project_skill, project_skill - assert "--compact" in project_skill, project_skill - assert "--brief" in project_skill, project_skill - assert "--thin" in project_skill, project_skill + # The skill names the execution-body levels as prose after the persistent + # bootstrap split; only the installed bootstrap keeps a literal flag. + assert "thin/compact/brief/full execution body" in project_skill, project_skill + assert "heartbeat-prompt --thin --codex-app" in normalized(project_skill), project_skill assert "goal_boundary" in project_skill, project_skill assert "smoke" in project_skill and "contract" in project_skill, project_skill assert "Set Up Recurring Heartbeats" in project_skill, project_skill assert "visible goal text short" in project_skill, project_skill - assert "--source heartbeat --execute" in project_skill, project_skill + assert "settlement_plan.ordered_steps" in normalized(project_skill) or "spend policy apply" in normalized(project_skill), project_skill assert "--classification " in project_skill, project_skill assert "--delivery-batch-scale " in project_skill, project_skill assert "--delivery-outcome " in project_skill, project_skill @@ -1121,6 +1134,8 @@ def main() -> int: text=True, ) cli_payload = json.loads(cli_json.stdout) + # Reward Memory is an opt-in Goal policy, so an unconfigured Goal keeps both + # the in-process default and the CLI on the same feature-off contract. assert cli_payload["task_body"] == default_payload["task_body"], cli_payload assert set(cli_payload) == { "schema_version", diff --git a/examples/control_plane/heartbeat_prompt_fixtures.py b/examples/control_plane/heartbeat_prompt_fixtures.py index 9e18318656..ad632d3359 100644 --- a/examples/control_plane/heartbeat_prompt_fixtures.py +++ b/examples/control_plane/heartbeat_prompt_fixtures.py @@ -10,7 +10,12 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from loopx.heartbeat_prompt import INTERFACE_BUDGET_CHARS # noqa: E402 +from loopx.heartbeat_prompt import ( # noqa: E402 + INTERFACE_BUDGET_CHARS, + REWARD_MEMORY_OUTCOME_PROMPT_HEADROOM_CHARS, +) + +_REWARD_MEMORY_PROMPT_MARKER = "--reward-memory-reflection-json" DOC = REPO_ROOT / "docs" / "heartbeat-automation-prompt.md" @@ -44,10 +49,19 @@ def prompt_budget_text(text: str) -> str: def assert_prompt_budget(label: str, text: str) -> None: budget_text = prompt_budget_text(text) - assert len(budget_text) <= INTERFACE_BUDGET_CHARS[label], ( + # Reward Memory reserves a readable outcome contract beyond the ordinary + # allowance, so a prompt that carries its marker is measured against the + # same augmented ceiling the production budget reports. + headroom = ( + REWARD_MEMORY_OUTCOME_PROMPT_HEADROOM_CHARS + if _REWARD_MEMORY_PROMPT_MARKER in text + else 0 + ) + max_chars = INTERFACE_BUDGET_CHARS[label] + headroom + assert len(budget_text) <= max_chars, ( label, len(budget_text), - INTERFACE_BUDGET_CHARS[label], + max_chars, ) @@ -59,7 +73,13 @@ def assert_interface_budget_payload(label: str, payload: dict) -> None: assert budget["char_count"] == len(task_body), budget assert budget["line_count"] == len(task_body.splitlines()), budget assert budget["budget_char_count"] == len(prompt_budget_text(task_body)), budget - assert budget["max_chars"] == INTERFACE_BUDGET_CHARS[label], budget + headroom = ( + REWARD_MEMORY_OUTCOME_PROMPT_HEADROOM_CHARS + if _REWARD_MEMORY_PROMPT_MARKER in task_body + else 0 + ) + assert budget["reward_memory_headroom_chars"] == headroom, budget + assert budget["max_chars"] == INTERFACE_BUDGET_CHARS[label] + headroom, budget assert budget["within_budget"] is True, budget diff --git a/examples/control_plane/monitor-poll-writeback-smoke.py b/examples/control_plane/monitor-poll-writeback-smoke.py index 80046a22b1..d11c3abb18 100644 --- a/examples/control_plane/monitor-poll-writeback-smoke.py +++ b/examples/control_plane/monitor-poll-writeback-smoke.py @@ -789,7 +789,10 @@ def assert_target_key_cannot_hijack_selected_due_monitor() -> None: ) assert "- ok: `False`" in markdown, markdown assert "- mode: `monitor-poll`" in markdown, markdown - assert "- todo_id: ``" in markdown, markdown + # The rejection names the monitor the caller actually selected and the + # material-change flag it declared, rather than reporting an empty + # identity for a request that was understood and refused. + assert "- todo_id: `todo_monitorpoll111`" in markdown, markdown assert f"- target_key: `{OTHER_TARGET_KEY}`" in markdown, markdown assert "- material_change: `True`" in markdown, markdown assert "- appended: `False`" in markdown, markdown diff --git a/examples/control_plane/todo-user-gate-readmodel-smoke.py b/examples/control_plane/todo-user-gate-readmodel-smoke.py index f05fa19c73..7e2bec3a98 100644 --- a/examples/control_plane/todo-user-gate-readmodel-smoke.py +++ b/examples/control_plane/todo-user-gate-readmodel-smoke.py @@ -120,10 +120,17 @@ def assert_shared_gate_detection() -> None: assert summary["other_agent_scoped_open_count"] == 1, summary assert summary["user_action_open_count"] == 2, summary assert summary["other_agent_bound_user_action_open_count"] == 2, summary - assert [ + # A record without a v0 wire coordinate has no persisted display index, so + # the shared presentation owner orders native rows by Todo identity. Both + # rows stay visible under the same bound-agent diagnostic lane. + bound_action_ids = [ item["todo_id"] for item in summary["other_agent_bound_user_action_items"] - ] == ["todo_action_other", "todo_action_legacy_other"], summary + ] + assert sorted(bound_action_ids) == [ + "todo_action_legacy_other", + "todo_action_other", + ], summary with_duplicate = { "open_count": "2", diff --git a/examples/fresh-clone-quickstart-smoke.py b/examples/fresh-clone-quickstart-smoke.py index d199c9f407..88292b8cd3 100644 --- a/examples/fresh-clone-quickstart-smoke.py +++ b/examples/fresh-clone-quickstart-smoke.py @@ -181,8 +181,14 @@ def main() -> int: env=cli_env, ) assert heartbeat["ok"] is True, heartbeat - assert "quota should-run" in heartbeat["quota_guard_command"], heartbeat - assert "--source heartbeat --execute" in heartbeat["quota_spend_command"], heartbeat + # The thin agent input carries the commands inside the task body rather + # than duplicating them as sibling fields, so the contract is read there. + assert heartbeat["interface_budget"]["mode"] == "thin", heartbeat + assert "quota should-run" in heartbeat["task_body"], heartbeat + # The thin contract embeds the guard command only; the spend command + # arrives in the quota response's next_cli_actions so the receipt + # identity stays bound to the turn that this guard opened. + assert "selection_command" in heartbeat["task_body"], heartbeat print("fresh-clone-quickstart-smoke ok") return 0 diff --git a/examples/install-local-smoke.py b/examples/install-local-smoke.py index 4eca37c658..1549d4d024 100644 --- a/examples/install-local-smoke.py +++ b/examples/install-local-smoke.py @@ -792,7 +792,7 @@ def main() -> int: ) assert "```sh\nLOOPX_TURN=\n" in payload["task_body"], payload assert "not a command-prefix assignment" in payload["task_body"], payload - assert "guard receipt; 2 stalls->replan" in payload["task_body"], payload + assert "guard; 2 stalls->replan" in payload["task_body"], payload assert "no-change=`surface_only`/no spend" in payload["task_body"], payload canary_cli = subprocess.run( diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 32e28674ad..2e9d552860 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -38,7 +38,6 @@ sync_human_gate_after_refresh, ) from ..feedback import ( - LESSON_KINDS, append_human_reward, compact_reward, render_reward_markdown, @@ -93,6 +92,9 @@ } +from .project_lifecycle_reward import register_reward_command + + def register_project_lifecycle_commands( subparsers: argparse._SubParsersAction, add_subcommand_format: Callable[[argparse.ArgumentParser], None], @@ -417,61 +419,7 @@ def register_project_lifecycle_commands( help="Do not refresh the shared global registry after writing the project-map run.", ) - reward_parser = subparsers.add_parser( - "reward", - help="Append a compact human reward overlay to a goal run index.", - ) - add_subcommand_format(reward_parser) - reward_parser.add_argument("--goal-id", required=True, help="Goal id whose latest run should receive feedback.") - reward_parser.add_argument( - "--run-generated-at", - help="Exact run generated_at timestamp. Defaults to the latest compact run for the goal.", - ) - reward_parser.add_argument("--recorded-at", help="Reward timestamp. Defaults to current UTC time.") - reward_parser.add_argument("--decision", required=True, help="Operator decision label, such as continue_route.") - reward_parser.add_argument( - "--reward", - required=True, - choices=["positive", "negative", "mixed", "neutral"], - help="Compact reward polarity.", - ) - reward_parser.add_argument( - "--reason-summary", - required=True, - help="Short public-safe reason. Do not include raw private evidence.", - ) - reward_parser.add_argument("--follow-up", help="Optional next handoff or experiment condition.") - reward_parser.add_argument( - "--lesson-kind", - choices=sorted(LESSON_KINDS), - help="Optional public-safe lesson kind when this reward records an explicit user correction.", - ) - reward_parser.add_argument( - "--lesson-summary", - help="Short public-safe lesson summary. Required when --lesson-kind is set.", - ) - reward_parser.add_argument( - "--lesson-avoid", - action="append", - default=[], - help="Public-safe phrase/action that future recommended_action should avoid. Repeatable.", - ) - reward_parser.add_argument( - "--lesson-prefer", - action="append", - default=[], - help="Public-safe phrase/action that future recommended_action should prefer. Repeatable.", - ) - reward_parser.add_argument( - "--state-file", - help="Active goal state path for optional summary writeback. Defaults to the registry goal state_file.", - ) - reward_parser.add_argument( - "--write-active-state-summary", - action="store_true", - help="After a real append, also add the returned active_state_summary to the active state's Progress Ledger. With --dry-run, preview only.", - ) - reward_parser.add_argument("--dry-run", action="store_true", help="Print the overlay without appending it.") + register_reward_command(subparsers, add_subcommand_format) gate_parser = subparsers.add_parser( "operator-gate", diff --git a/loopx/cli_commands/project_lifecycle_reward.py b/loopx/cli_commands/project_lifecycle_reward.py new file mode 100644 index 0000000000..e2e2bab660 --- /dev/null +++ b/loopx/cli_commands/project_lifecycle_reward.py @@ -0,0 +1,78 @@ +"""Owner-local parser registration for the compact reward overlay command. + +The command is a cohesive slice: one overlay append with its optional lesson +fields and state-summary writeback. Keeping it beside the other project +lifecycle registrations pushed that module over its size budget, so it owns a +module here instead of growing the shared file. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable + +from ..feedback import LESSON_KINDS + + +def register_reward_command( + subparsers: argparse._SubParsersAction, + add_subcommand_format: Callable[[argparse.ArgumentParser], None], +) -> None: + reward_parser = subparsers.add_parser( + "reward", + help="Append a compact human reward overlay to a goal run index.", + ) + add_subcommand_format(reward_parser) + reward_parser.add_argument("--goal-id", required=True, help="Goal id whose latest run should receive feedback.") + reward_parser.add_argument( + "--run-generated-at", + help="Exact run generated_at timestamp. Defaults to the latest compact run for the goal.", + ) + reward_parser.add_argument("--recorded-at", help="Reward timestamp. Defaults to current UTC time.") + reward_parser.add_argument("--decision", required=True, help="Operator decision label, such as continue_route.") + reward_parser.add_argument( + "--reward", + required=True, + choices=["positive", "negative", "mixed", "neutral"], + help="Compact reward polarity.", + ) + reward_parser.add_argument( + "--reason-summary", + required=True, + help="Short public-safe reason. Do not include raw private evidence.", + ) + reward_parser.add_argument("--follow-up", help="Optional next handoff or experiment condition.") + reward_parser.add_argument( + "--lesson-kind", + choices=sorted(LESSON_KINDS), + help="Optional public-safe lesson kind when this reward records an explicit user correction.", + ) + reward_parser.add_argument( + "--lesson-summary", + help="Short public-safe lesson summary. Required when --lesson-kind is set.", + ) + reward_parser.add_argument( + "--lesson-avoid", + action="append", + default=[], + help="Public-safe phrase/action that future recommended_action should avoid. Repeatable.", + ) + reward_parser.add_argument( + "--lesson-prefer", + action="append", + default=[], + help="Public-safe phrase/action that future recommended_action should prefer. Repeatable.", + ) + reward_parser.add_argument( + "--state-file", + help="Active goal state path for optional summary writeback. Defaults to the registry goal state_file.", + ) + reward_parser.add_argument( + "--write-active-state-summary", + action="store_true", + help="After a real append, also add the returned active_state_summary to the active state's Progress Ledger. With --dry-run, preview only.", + ) + reward_parser.add_argument("--dry-run", action="store_true", help="Print the overlay without appending it.") + + +__all__ = ["register_reward_command"] diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 6bbf950b06..63e25a8e9e 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -7,22 +7,20 @@ from ..capabilities.explore.composition_frontier import ( project_live_explore_composition_frontier, ) -from ..capabilities.repository_change_window import ( - repository_delivery_interaction_hook, -) from ..capabilities.periodic_report.pending_intent import ( periodic_report_pending_intent_interaction_hook, ) +from ..capabilities.repository_change_window import ( + repository_delivery_interaction_hook, +) +from ..control_plane.effect_runtime import EffectRuntimeRejected from ..control_plane.quota.cli_projection import ( compact_quota_monitor_poll_cli_payload, compact_quota_should_run_cli_payload, ) from ..control_plane.quota.effect_program import SettlementIdentity from ..control_plane.quota.error_codes import ( - HeartbeatReceiptIdentityConflictError, QuotaCommandValidationError, - QuotaIdentityPreconditionError, - quota_error_code, ) from ..control_plane.quota.heartbeat_receipt import ( HEARTBEAT_RECEIPT_SCHEMA_VERSION, @@ -43,16 +41,10 @@ render_existing_heartbeat_receipt_payload, ) from ..control_plane.quota.turn_envelope import build_turn_envelope -from ..control_plane.coordination.legacy_writer_fence import ( - LegacyCoordinationWriterFenced, -) -from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable -from ..control_plane.effect_runtime import EffectRuntimeRejected from ..control_plane.scheduler.execution_context import ( GUIDED_START_TURN_RUNTIME_PROFILES, ) from ..control_plane.todos.contract import normalize_todo_id -from ..file_lock import lock_timeout_error_fields from ..presentation.renderers.quota_event_markdown import ( render_quota_monitor_poll_markdown, render_quota_slot_preview_markdown, @@ -83,6 +75,12 @@ prepare_quota_command_context, validate_quota_command_context_request, ) +from .quota_failure_report import ( + QUOTA_EVENT_KINDS, + quota_failure_payload, + quota_validation_failure_payload, + should_log_quota, +) from .quota_host_poll import attach_host_poll_receipt from .quota_monitor_poll import record_quota_monitor_poll_for_cli from .quota_registration import ( @@ -99,17 +97,6 @@ None, ] RolloutEventAppender = Callable[..., dict[str, object]] -QUOTA_EVENT_KINDS = { - "should-run": "quota_should_run", - "monitor-poll": "quota_monitor_poll", - "scheduler-ack": "quota_scheduler_ack", - "scheduler-ack-current": "quota_scheduler_ack", - "scheduler-fail-current": "quota_scheduler_failure", - "spend-slot": "quota_spend", - "void-slot": "quota_void", -} - - def _heartbeat_receipt_settlement_bindings( event: Mapping[str, object], ) -> tuple[str | None, str | None]: @@ -146,191 +133,6 @@ def _effective_spend_turn_instance_id( return payload_turn_id -def _should_log_quota(command: str, payload: Mapping[str, object]) -> bool: - return command in QUOTA_EVENT_KINDS and ( - command == "should-run" - or ( - bool(payload.get("ok")) - and ( - bool(payload.get("appended")) - or bool(payload.get("receipt_repair_required")) - ) - ) - ) - - -def _verbose_debug_fields(error: Exception, *, verbose: bool) -> dict[str, object]: - if not verbose: - return {} - return { - "verbose_debug": { - "error_type": type(error).__name__, - "error": str(error), - } - } - - -def _quota_failure_payload( - args: argparse.Namespace, - *, - registry_path: Path, - runtime_root_arg: str | None, - error: Exception, -) -> dict[str, object]: - command = args.quota_command - lock_timeout_fields = lock_timeout_error_fields(error) - verbose_debug = _verbose_debug_fields( - error, verbose=bool(getattr(args, "verbose", False)) - ) - if command not in QUOTA_EVENT_KINDS: - return { - "ok": False, - "mode": command, - "registry": str(registry_path), - "runtime_root": runtime_root_arg, - "error_code": quota_error_code(error), - "error": "quota collection failed", - "summary": { - "registered_goals": 0, - "health_blockers": 1, - "next_automatic_turn": None, - "states": {}, - }, - "groups": {}, - "health_items": [ - { - "goal_id": "loopx-quota", - "status": "quota_collection_failed", - "waiting_on": "codex", - "severity": "high", - "recommended_action": ( - "fix quota/status collection before spending automatic compute" - ), - "source": "quota", - } - ], - **verbose_debug, - **lock_timeout_fields, - } - - public_reason = ( - str(error) - if isinstance(error, HeartbeatReceiptIdentityConflictError) - else "quota collection failed" - ) - payload: dict[str, object] = { - "ok": False, - "mode": command, - "goal_id": args.goal_id, - "decision": "skip", - "should_run": False, - "error_code": quota_error_code(error), - "reason": public_reason, - "state": "blocked_health", - "waiting_on": "codex", - "status": "quota_collection_failed", - "source": "quota", - "recommended_action": ( - "fix quota/status collection before spending automatic compute" - ), - **verbose_debug, - **lock_timeout_fields, - } - if isinstance(error, QuotaIdentityPreconditionError): - payload.update( - { - "reason": str(error), - "status": "quota_identity_precondition_failed", - "identity_precondition": error.precondition.value, - "recommended_action": error.recommended_action, - } - ) - if error.agent_id is not None: - payload["agent_id"] = error.agent_id - elif isinstance(error, (LegacyCoordinationWriterFenced, LocalCoordinationAuthorityUnavailable)): - payload.update( - { - "error_code": error.code, - "reason": str(error), - **error.payload, - } - ) - if lock_timeout_fields: - payload["recommended_action"] = "inspect the lock holder before retrying" - if command == "monitor-poll": - payload.update( - { - "source": args.source, - "agent_id": args.agent_id, - "todo_id": args.todo_id, - "target_key": args.target_key, - "result_hash": args.result_hash, - "material_change": bool(args.material_change), - } - ) - elif command in {"scheduler-ack", "scheduler-ack-current"}: - payload.update( - { - "agent_id": args.agent_id, - "surface": args.surface, - "state_key": args.state_key, - "applied_rrule": args.applied_rrule, - } - ) - elif command == "scheduler-fail-current": - payload.update( - { - "agent_id": args.agent_id, - "surface": args.surface, - "state_key": args.state_key, - "failed_rrule": args.failed_rrule, - "failure_kind": args.failure_kind, - } - ) - return payload - - -def _quota_validation_failure_payload( - args: argparse.Namespace, - exc: QuotaCommandValidationError, - *, - registry_path: Path, - runtime_root_arg: str | None, -) -> dict[str, object]: - command = args.quota_command - if command not in QUOTA_EVENT_KINDS: - return { - "ok": False, - "mode": command, - "registry": str(registry_path), - "runtime_root": runtime_root_arg, - "error_code": "QUOTA_VALIDATION_FAILED", - "error": str(exc), - "summary": { - "registered_goals": 0, - "health_blockers": 0, - "next_automatic_turn": None, - "states": {}, - }, - "groups": {}, - "health_items": [], - } - return { - "ok": False, - "mode": command, - "goal_id": args.goal_id, - "decision": "skip", - "should_run": False, - "error_code": "QUOTA_VALIDATION_FAILED", - "reason": str(exc), - "state": "blocked_validation", - "waiting_on": "codex", - "status": "quota_validation_failed", - "source": "quota", - "recommended_action": "fix the command arguments before retrying", - } - - def _quota_renderer( args: argparse.Namespace, ) -> Callable[[dict[str, object]], str]: @@ -553,10 +355,10 @@ def _dispatch_quota_turn_start_hooks( agent_id=args.agent_id, ) if args.agent_id: + from ..capabilities.manager_context import turn_start_hook from ..control_plane.agents.capability_memory import ( extend_turn_start_dispatch as extend_capability_memory_dispatch, ) - from ..capabilities.manager_context import turn_start_hook from ..control_plane.capability_hooks import dispatch_turn_start_hooks from ..history import load_registry from ..paths import resolve_runtime_root @@ -575,7 +377,9 @@ def _dispatch_quota_turn_start_hooks( dispatch[key] = list(dispatch.get(key) or []) + list(context_dispatch.get(key) or []) for key in ("registered_count", "invoked_count"): dispatch[key] = int(dispatch.get(key) or 0) + int(context_dispatch.get(key) or 0) - from ..capabilities.periodic_report.cadence_runtime import extend_cadence_turn_start_dispatch + from ..capabilities.periodic_report.cadence_runtime import ( + extend_cadence_turn_start_dispatch, + ) dispatch = extend_cadence_turn_start_dispatch(dispatch, registry_path=registry_path, runtime_root=root, goal_id=args.goal_id, agent_id=args.agent_id) local_private_state_mutated = any( @@ -891,20 +695,20 @@ def handle_quota_command( payload["status_projection_cache"] = cache_metadata except QuotaCommandValidationError as exc: # Only typed CLI validation diagnostics are public-safe by contract. - payload = _quota_validation_failure_payload( + payload = quota_validation_failure_payload( args, exc, registry_path=registry_path, runtime_root_arg=runtime_root_arg, ) except Exception as exc: # noqa: BLE001 - CLI fail-safe boundary; error_code is typed below. - payload = _quota_failure_payload( + payload = quota_failure_payload( args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, error=exc, ) - if _should_log_quota(args.quota_command, payload): + if should_log_quota(args.quota_command, payload): spend_turn_instance_id = _effective_spend_turn_instance_id( payload, heartbeat_turn_id=heartbeat_turn_id, diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py new file mode 100644 index 0000000000..a4751ffdb3 --- /dev/null +++ b/loopx/cli_commands/quota_failure_report.py @@ -0,0 +1,273 @@ +"""Owner-local failure reporting for the quota CLI command. + +Keeping the honest failure and validation payloads beside the command handler +pushed that module over its size budget. They form one cohesive unit: what gets +logged, what the operator sees, and how a rejected request is reported without +inventing success. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Mapping +from pathlib import Path + +from ..control_plane.coordination.legacy_writer_fence import ( + LegacyCoordinationWriterFenced, +) +from ..control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, +) +from ..control_plane.quota.error_codes import ( + HeartbeatReceiptIdentityConflictError, + QuotaCommandValidationError, + QuotaIdentityPreconditionError, + quota_error_code, +) +from ..control_plane.quota.heartbeat_receipt import ( + heartbeat_receipt_settlement_replan_obligation_id, + heartbeat_receipt_settlement_todo_id, +) +from ..file_lock import lock_timeout_error_fields + +QUOTA_EVENT_KINDS = { + "should-run": "quota_should_run", + "monitor-poll": "quota_monitor_poll", + "scheduler-ack": "quota_scheduler_ack", + "scheduler-ack-current": "quota_scheduler_ack", + "scheduler-fail-current": "quota_scheduler_failure", + "spend-slot": "quota_spend", + "void-slot": "quota_void", +} + + +def _heartbeat_receipt_settlement_bindings( + event: Mapping[str, object], +) -> tuple[str | None, str | None]: + return ( + heartbeat_receipt_settlement_todo_id(event), + heartbeat_receipt_settlement_replan_obligation_id(event), + ) + + +def _effective_spend_turn_instance_id( + payload: Mapping[str, object], + *, + heartbeat_turn_id: str | None, +) -> str | None: + """Use an exact spend recovery as rollout receipt authority. + + A visible-goal spend may intentionally omit CLI settlement arguments. When + quota accounting recovers the exact persisted identity, the receipt must be + written under that recovered Turn or a replay cannot find the committed + effect. Do not project a loose payload field into receipt authority: require + the typed identity and payload Turn to agree. + """ + + if heartbeat_turn_id: + return heartbeat_turn_id + identity_value = payload.get("settlement_identity") + if not isinstance(identity_value, Mapping): + return None + payload_turn_id = str(payload.get("turn_instance_id") or "").strip() + identity_turn_id = str(identity_value.get("turn_instance_id") or "").strip() + effect_id = str(identity_value.get("effect_id") or "").strip() + if not payload_turn_id or payload_turn_id != identity_turn_id or not effect_id: + return None + return payload_turn_id + + +def should_log_quota(command: str, payload: Mapping[str, object]) -> bool: + return command in QUOTA_EVENT_KINDS and ( + command == "should-run" + or ( + bool(payload.get("ok")) + and ( + bool(payload.get("appended")) + or bool(payload.get("receipt_repair_required")) + ) + ) + ) + + +def verbose_debug_fields(error: Exception, *, verbose: bool) -> dict[str, object]: + if not verbose: + return {} + return { + "verbose_debug": { + "error_type": type(error).__name__, + "error": str(error), + } + } + + +def quota_failure_payload( + args: argparse.Namespace, + *, + registry_path: Path, + runtime_root_arg: str | None, + error: Exception, +) -> dict[str, object]: + command = args.quota_command + lock_timeout_fields = lock_timeout_error_fields(error) + verbose_debug = verbose_debug_fields( + error, verbose=bool(getattr(args, "verbose", False)) + ) + if command not in QUOTA_EVENT_KINDS: + return { + "ok": False, + "mode": command, + "registry": str(registry_path), + "runtime_root": runtime_root_arg, + "error_code": quota_error_code(error), + "error": "quota collection failed", + "summary": { + "registered_goals": 0, + "health_blockers": 1, + "next_automatic_turn": None, + "states": {}, + }, + "groups": {}, + "health_items": [ + { + "goal_id": "loopx-quota", + "status": "quota_collection_failed", + "waiting_on": "codex", + "severity": "high", + "recommended_action": ( + "fix quota/status collection before spending automatic compute" + ), + "source": "quota", + } + ], + **verbose_debug, + **lock_timeout_fields, + } + + public_reason = ( + str(error) + if isinstance(error, HeartbeatReceiptIdentityConflictError) + else "quota collection failed" + ) + payload: dict[str, object] = { + "ok": False, + "mode": command, + "goal_id": args.goal_id, + "decision": "skip", + "should_run": False, + "error_code": quota_error_code(error), + "reason": public_reason, + "state": "blocked_health", + "waiting_on": "codex", + "status": "quota_collection_failed", + "source": "quota", + "recommended_action": ( + "fix quota/status collection before spending automatic compute" + ), + **verbose_debug, + **lock_timeout_fields, + } + if isinstance(error, QuotaIdentityPreconditionError): + payload.update( + { + "reason": str(error), + "status": "quota_identity_precondition_failed", + "identity_precondition": error.precondition.value, + "recommended_action": error.recommended_action, + } + ) + if error.agent_id is not None: + payload["agent_id"] = error.agent_id + elif isinstance(error, (LegacyCoordinationWriterFenced, LocalCoordinationAuthorityUnavailable)): + payload.update( + { + "error_code": error.code, + "reason": str(error), + **error.payload, + } + ) + if lock_timeout_fields: + payload["recommended_action"] = "inspect the lock holder before retrying" + if command == "monitor-poll": + payload.update( + { + "source": args.source, + "agent_id": args.agent_id, + "todo_id": args.todo_id, + "target_key": args.target_key, + "result_hash": args.result_hash, + "material_change": bool(args.material_change), + } + ) + elif command in {"scheduler-ack", "scheduler-ack-current"}: + payload.update( + { + "agent_id": args.agent_id, + "surface": args.surface, + "state_key": args.state_key, + "applied_rrule": args.applied_rrule, + } + ) + elif command == "scheduler-fail-current": + payload.update( + { + "agent_id": args.agent_id, + "surface": args.surface, + "state_key": args.state_key, + "failed_rrule": args.failed_rrule, + "failure_kind": args.failure_kind, + } + ) + return payload + + +def quota_validation_failure_payload( + args: argparse.Namespace, + exc: QuotaCommandValidationError, + *, + registry_path: Path, + runtime_root_arg: str | None, +) -> dict[str, object]: + command = args.quota_command + if command not in QUOTA_EVENT_KINDS: + return { + "ok": False, + "mode": command, + "registry": str(registry_path), + "runtime_root": runtime_root_arg, + "error_code": "QUOTA_VALIDATION_FAILED", + "error": str(exc), + "summary": { + "registered_goals": 0, + "health_blockers": 0, + "next_automatic_turn": None, + "states": {}, + }, + "groups": {}, + "health_items": [], + } + return { + "ok": False, + "mode": command, + "goal_id": args.goal_id, + "decision": "skip", + "should_run": False, + "error_code": "QUOTA_VALIDATION_FAILED", + "reason": str(exc), + "state": "blocked_validation", + "waiting_on": "codex", + "status": "quota_validation_failed", + "source": "quota", + "recommended_action": "fix the command arguments before retrying", + } + + + + +__all__ = [ + "QUOTA_EVENT_KINDS", + "quota_failure_payload", + "quota_validation_failure_payload", + "should_log_quota", + "verbose_debug_fields", +] diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 40ea45914e..590377faa5 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -16,8 +16,8 @@ DEFAULT_CHAT_PORT, serve_chat, ) -from ..control_plane.scheduler.execution_context import SchedulerRuntimeProfile from ..control_plane.reward_memory import reward_memory_goal_policy +from ..control_plane.scheduler.execution_context import SchedulerRuntimeProfile from ..dashboard_launcher import launch_dashboard, replace_existing_loopx_chat from ..execution_profile import execution_profile_turn_granularity from ..heartbeat_prequota import ( @@ -65,10 +65,8 @@ handle_backup_state_command, register_backup_state_command, ) -from .support_control_agent_runtime import register_agent_runtime_arguments from .support_control_chat_endpoint import ( handle_chat_endpoint_command, - register_chat_endpoint_command, ) from .support_control_heartbeat_registration import ( register_heartbeat_control_commands, @@ -108,6 +106,9 @@ } | SUPERVISOR_CONTROL_COMMANDS +from .support_control_chat import register_chat_and_dashboard_commands + + def register_support_control_commands( subparsers: argparse._SubParsersAction, add_subcommand_format: AddFormat, @@ -316,136 +317,7 @@ def register_support_control_commands( serve_status_parser.add_argument( "--verbose", action="store_true", help="Print HTTP request logs." ) - - chat_parser = subparsers.add_parser( - "chat", - help="Open the local Goal Studio and review Agent-proposed LoopX Todos.", - ) - chat_parser.add_argument( - "--goal-id", help="Goal to select when the local workspace opens." - ) - chat_parser.add_argument( - "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." - ) - chat_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) - register_agent_runtime_arguments(chat_parser, kiro_cli_bin=KIRO_CLI_BIN) - chat_parser.add_argument( - "--startup-timeout-seconds", - type=float, - default=30.0, - help="Maximum seconds allowed for Codex app-server startup and handshake.", - ) - chat_parser.add_argument( - "--idle-timeout-seconds", - type=float, - default=180.0, - help="Maximum seconds without an upstream event before interrupting the active turn.", - ) - chat_parser.add_argument( - "--hard-timeout-seconds", - type=float, - default=900.0, - help="Absolute maximum seconds for one Agent turn.", - ) - chat_parser.add_argument( - "--assets-dir", - help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", - ) - chat_parser.add_argument( - "--scan-root", - default=default_public_scan_root(), - help="Public files used by the underlying status projection.", - ) - chat_parser.add_argument( - "--scan-path", - action="append", - default=[], - help="Specific public file or directory to scan. Repeatable.", - ) - chat_parser.add_argument("--limit", type=int, default=20) - chat_parser.add_argument( - "--global-registry", - action="store_true", - help="Use the shared global registry even when the command runs in a project directory.", - ) - chat_parser.add_argument( - "--enable-goal-subagent-configuration", - action="store_true", - help=( - "Enable the preview-locked Goal sub-agent configuration API, " - "status projection, and dashboard controls." - ), - ) - chat_parser.add_argument( - "--no-open", - action="store_true", - help="Start the local server without opening a browser.", - ) - chat_parser.add_argument( - "--replace-existing-loopx-chat", - action="store_true", - help=argparse.SUPPRESS, - ) - chat_parser.add_argument( - "--verbose", action="store_true", help="Print HTTP request logs." - ) - - register_chat_endpoint_command(subparsers, add_subcommand_format) - - dashboard_parser = subparsers.add_parser( - "dashboard", - help="Start the local LoopX dashboard, status service, and Chat service.", - ) - dashboard_parser.add_argument( - "--goal-id", help="Goal to select when the local workspace opens." - ) - dashboard_parser.add_argument( - "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." - ) - dashboard_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) - register_agent_runtime_arguments(dashboard_parser, kiro_cli_bin=KIRO_CLI_BIN) - dashboard_parser.add_argument( - "--assets-dir", - help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", - ) - dashboard_parser.add_argument( - "--scan-root", - default=default_public_scan_root(), - help="Public files used by the underlying status projection.", - ) - dashboard_parser.add_argument( - "--scan-path", - action="append", - default=[], - help="Specific public file or directory to scan. Repeatable.", - ) - dashboard_parser.add_argument("--limit", type=int, default=20) - dashboard_parser.add_argument( - "--global-registry", - action="store_true", - help="Use the shared global registry even when the command runs in a project directory.", - ) - dashboard_parser.add_argument( - "--enable-goal-subagent-configuration", - action="store_true", - help=( - "Enable the preview-locked Goal sub-agent configuration API, " - "status projection, and dashboard controls." - ), - ) - dashboard_parser.add_argument( - "--no-open", - action="store_true", - help="Start the local server without opening a browser.", - ) - dashboard_parser.add_argument( - "--dev", - action="store_true", - help="Prefer the Vite HMR dev launcher if running from a local repository checkout.", - ) - dashboard_parser.add_argument( - "--verbose", action="store_true", help="Print HTTP request logs." - ) + register_chat_and_dashboard_commands(subparsers, add_subcommand_format) def handle_support_control_command( @@ -460,7 +332,7 @@ def handle_support_control_command( return None if args.command == "automation-prompts": - from .automation_prompts import run, render + from .automation_prompts import render, run try: payload = run(args, registry_path) except Exception as error: @@ -818,7 +690,9 @@ def handle_support_control_command( if update_action is UpdateAction.APPLY and payload.get("plan", {}).get( "apply_supported" ): - from ..control_plane.heartbeat.installed_prompt_update import update_with_prompts + from ..control_plane.heartbeat.installed_prompt_update import ( + update_with_prompts, + ) payload = update_with_prompts( payload, registry=(registry_path if registry_was_supplied else explicit_global_registry(args.runtime_root)), runtime_root=args.runtime_root, diff --git a/loopx/cli_commands/support_control_chat.py b/loopx/cli_commands/support_control_chat.py new file mode 100644 index 0000000000..fbbed1408a --- /dev/null +++ b/loopx/cli_commands/support_control_chat.py @@ -0,0 +1,155 @@ +"""Owner-local parser registration for the chat and dashboard commands. + +Both commands launch the same local presentation surface with different +defaults, so they share one cohesive registration unit. Keeping them beside +the other support commands pushed that module over its size budget. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable + +from ..chat_server import DEFAULT_CHAT_HOST, DEFAULT_CHAT_PORT +from ..kiro_cli_goal_mode import KIRO_CLI_BIN +from ..paths import default_public_scan_root +from .support_control_agent_runtime import register_agent_runtime_arguments +from .support_control_chat_endpoint import register_chat_endpoint_command + + +def register_chat_and_dashboard_commands( + subparsers: argparse._SubParsersAction, + add_subcommand_format: Callable[[argparse.ArgumentParser], None], +) -> None: + chat_parser = subparsers.add_parser( + "chat", + help="Open the local Goal Studio and review Agent-proposed LoopX Todos.", + ) + chat_parser.add_argument( + "--goal-id", help="Goal to select when the local workspace opens." + ) + chat_parser.add_argument( + "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." + ) + chat_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) + register_agent_runtime_arguments(chat_parser, kiro_cli_bin=KIRO_CLI_BIN) + chat_parser.add_argument( + "--startup-timeout-seconds", + type=float, + default=30.0, + help="Maximum seconds allowed for Codex app-server startup and handshake.", + ) + chat_parser.add_argument( + "--idle-timeout-seconds", + type=float, + default=180.0, + help="Maximum seconds without an upstream event before interrupting the active turn.", + ) + chat_parser.add_argument( + "--hard-timeout-seconds", + type=float, + default=900.0, + help="Absolute maximum seconds for one Agent turn.", + ) + chat_parser.add_argument( + "--assets-dir", + help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", + ) + chat_parser.add_argument( + "--scan-root", + default=default_public_scan_root(), + help="Public files used by the underlying status projection.", + ) + chat_parser.add_argument( + "--scan-path", + action="append", + default=[], + help="Specific public file or directory to scan. Repeatable.", + ) + chat_parser.add_argument("--limit", type=int, default=20) + chat_parser.add_argument( + "--global-registry", + action="store_true", + help="Use the shared global registry even when the command runs in a project directory.", + ) + chat_parser.add_argument( + "--enable-goal-subagent-configuration", + action="store_true", + help=( + "Enable the preview-locked Goal sub-agent configuration API, " + "status projection, and dashboard controls." + ), + ) + chat_parser.add_argument( + "--no-open", + action="store_true", + help="Start the local server without opening a browser.", + ) + chat_parser.add_argument( + "--replace-existing-loopx-chat", + action="store_true", + help=argparse.SUPPRESS, + ) + chat_parser.add_argument( + "--verbose", action="store_true", help="Print HTTP request logs." + ) + + register_chat_endpoint_command(subparsers, add_subcommand_format) + + dashboard_parser = subparsers.add_parser( + "dashboard", + help="Start the local LoopX dashboard, status service, and Chat service.", + ) + dashboard_parser.add_argument( + "--goal-id", help="Goal to select when the local workspace opens." + ) + dashboard_parser.add_argument( + "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." + ) + dashboard_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) + register_agent_runtime_arguments(dashboard_parser, kiro_cli_bin=KIRO_CLI_BIN) + dashboard_parser.add_argument( + "--assets-dir", + help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", + ) + dashboard_parser.add_argument( + "--scan-root", + default=default_public_scan_root(), + help="Public files used by the underlying status projection.", + ) + dashboard_parser.add_argument( + "--scan-path", + action="append", + default=[], + help="Specific public file or directory to scan. Repeatable.", + ) + dashboard_parser.add_argument("--limit", type=int, default=20) + dashboard_parser.add_argument( + "--global-registry", + action="store_true", + help="Use the shared global registry even when the command runs in a project directory.", + ) + dashboard_parser.add_argument( + "--enable-goal-subagent-configuration", + action="store_true", + help=( + "Enable the preview-locked Goal sub-agent configuration API, " + "status projection, and dashboard controls." + ), + ) + dashboard_parser.add_argument( + "--no-open", + action="store_true", + help="Start the local server without opening a browser.", + ) + dashboard_parser.add_argument( + "--dev", + action="store_true", + help="Prefer the Vite HMR dev launcher if running from a local repository checkout.", + ) + dashboard_parser.add_argument( + "--verbose", action="store_true", help="Print HTTP request logs." + ) + + +__all__ = ["register_chat_and_dashboard_commands"] diff --git a/loopx/control_plane/agents/capability_memory.py b/loopx/control_plane/agents/capability_memory.py index e51d2d24a0..86327b90a4 100644 --- a/loopx/control_plane/agents/capability_memory.py +++ b/loopx/control_plane/agents/capability_memory.py @@ -10,19 +10,31 @@ def agent_capability_memory( *, registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str, available: Any = None, unavailable: Any = None, forget: Any = None, - execute: bool = False, + execute: bool = False, registered_agents: Any = None, ) -> dict[str, Any]: goal_id = str(goal_id).strip() normalized_agent = normalize_todo_claimed_by(agent_id) if not normalized_agent: raise ValueError("agent_id must be a public-safe registered agent id") - goal = load_goal_from_registry(registry_path, goal_id) + # A caller that already resolved the goal (quota, status) holds the + # authoritative roster; re-reading the registry file here would let a + # relative or unreadable path silently empty the roster and turn a + # read-only capability observation into a hard failure. Agent ids are not + # capability tokens, so they keep their own normalization. + roster = [ + normalized + for value in (registered_agents or []) + if (normalized := normalize_todo_claimed_by(value)) + ] + if not roster: + goal = load_goal_from_registry(registry_path, goal_id) + roster = registered_agent_ids_for_goal(goal) result = effect_runtime_result("agent.capability_memory", { "schema_version": "agent_runtime_capability_request_v0", "runtime_root": str(runtime_root.expanduser().resolve()), "registry": str(registry_path.expanduser().resolve()), "goal_id": goal_id, "agent_id": normalized_agent, - "registered_agents": registered_agent_ids_for_goal(goal), + "registered_agents": roster, "available": normalize_required_capabilities(available), "unavailable": normalize_required_capabilities(unavailable), "forget": normalize_required_capabilities(forget), @@ -125,14 +137,16 @@ def resolve_agent_capabilities( state: dict[str, Any] | None = None, ) -> dict[str, Any]: """Read current scoped observations even when the caller's status is cached.""" - from .capability_gate import _evaluate from ..quota.goal_boundary import declared_available_capabilities + from .capability_gate import _evaluate root, registry = status_payload.get("runtime_root"), status_payload.get("registry") if state is None and agent_identity and root and registry: state = agent_capability_memory( registry_path=Path(str(registry)), runtime_root=Path(str(root)), goal_id=goal_id, agent_id=agent_identity["agent_id"], + # The resolved identity already carries the authoritative roster. + registered_agents=agent_identity.get("registered_agents"), ) availability = _evaluate( "availability", diff --git a/loopx/help_surface.py b/loopx/help_surface.py index bccd4547d4..4ce78c2665 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -302,6 +302,7 @@ # command an intentional manual-visibility decision instead of a silent omission. MANPAGE_COMMAND_HELP_ONLY = frozenset( { + "agent-capabilities", "agent-context", "archive-runtime", "automation-prompts", @@ -336,6 +337,7 @@ "goal-alignment", "amendment-proposal", "goal-amendment-proposal", + "handoff", "handoff-mode", "heartbeat-prequota", "import-doc-registry-authority",