From 8357ab3cc2a04a1a4d17d477c65f7d122ddb9e2d Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 02:40:11 +0800 Subject: [PATCH 1/5] fix(smokes): align public heartbeat prompts with shipped contracts Restore four public smokes that drifted from shipped behavior: - install-local and blocker-push asserted the pre-#4302 closeout line; the task body now says `guard; 2 stalls->replan`. - heartbeat-prompt measured the full prompt against the bare interface budget, ignoring the Reward Memory headroom #4333 reserves, and asserted closeout, brief and documentation wording that #4296 replaced. - capability-gate-projection imported three private helpers that #4254 moved into the typed TypeScript rule owner; it now reads the public gate. The Reward Memory contract is an opt-in Goal policy derived from the goal registry, so the smoke's in-process comparisons opt out explicitly instead of relying on the builder default that in-process callers share. Signed-off-by: song --- ...01-managed-turn-loop-dsh-vertical.zh-CN.md | 171 ++++++++++++++++++ examples/blocker-push-runtime-smoke.py | 2 +- .../capability-gate-projection-smoke.py | 66 ++++--- .../control_plane/heartbeat-prompt-smoke.py | 53 ++++-- .../heartbeat_prompt_fixtures.py | 28 ++- examples/install-local-smoke.py | 2 +- 6 files changed, 269 insertions(+), 53 deletions(-) create mode 100644 docs/plans/2026-09-01-managed-turn-loop-dsh-vertical.zh-CN.md 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/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( From 87acbc37f2f2303e79922b0c7d1f5ba4c100e02e Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 03:04:24 +0800 Subject: [PATCH 2/5] fix(agents): keep the resolved roster for capability observations resolve_agent_capabilities re-read the goal registry file to obtain the registered-agent roster, so a relative or unreadable registry path silently emptied it and the typed handler rejected the read-only observation as an unregistered agent. quota should-run then failed with an internal runtime error instead of returning a capability projection. The resolved identity already carries the authoritative roster; pass it through. Roster entries are agent ids, not capability tokens, so they keep agent-id normalization. Signed-off-by: song --- .../control_plane/agents/capability_memory.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/agents/capability_memory.py b/loopx/control_plane/agents/capability_memory.py index e51d2d24a0..3e1cc613af 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), @@ -133,6 +145,8 @@ def resolve_agent_capabilities( 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", From abd437deef10040ff3693343b1debf8e2955dd54 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 03:28:17 +0800 Subject: [PATCH 3/5] fix(smokes): classify new commands, extract over-budget CLI owners, align monitor identity Restore the remaining public smoke contracts that depend on module budgets, command classification and monitor-poll identity reporting: - agent-capabilities and handoff are registered but unclassified; both are command-specific help surfaces, so they join the existing help-only set. - project_lifecycle, quota and support_control each grew past the 1000-line budget. Extract three cohesive owners: the reward overlay registration, the honest quota failure/validation payloads, and the chat/dashboard pair. - todo-user-gate readmodel and monitor-poll writeback assertions predate the native presentation ordering and the identity-preserving rejection report. quota-plan, status-markdown and capability-gate also pass now that the capability observation no longer fails on an unresolved roster. Signed-off-by: song --- .../monitor-poll-writeback-smoke.py | 5 +- .../todo-user-gate-readmodel-smoke.py | 11 +- loopx/cli_commands/project_lifecycle.py | 59 +--- .../cli_commands/project_lifecycle_reward.py | 78 +++++ loopx/cli_commands/quota.py | 208 +------------- loopx/cli_commands/quota_failure_report.py | 272 ++++++++++++++++++ loopx/cli_commands/support_control.py | 134 +-------- loopx/cli_commands/support_control_chat.py | 156 ++++++++++ loopx/help_surface.py | 2 + 9 files changed, 538 insertions(+), 387 deletions(-) create mode 100644 loopx/cli_commands/project_lifecycle_reward.py create mode 100644 loopx/cli_commands/quota_failure_report.py create mode 100644 loopx/cli_commands/support_control_chat.py 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/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 32e28674ad..7487ec90ad 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -93,6 +93,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 +420,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..026a852eb9 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -85,6 +85,12 @@ ) from .quota_host_poll import attach_host_poll_receipt from .quota_monitor_poll import record_quota_monitor_poll_for_cli +from .quota_failure_report import ( + QUOTA_EVENT_KINDS, + quota_failure_payload, + quota_validation_failure_payload, + should_log_quota, +) from .quota_registration import ( register_quota_command as register_quota_command, # noqa: PLC0414 ) @@ -99,17 +105,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 +141,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]: @@ -891,20 +701,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..966a54535b --- /dev/null +++ b/loopx/cli_commands/quota_failure_report.py @@ -0,0 +1,272 @@ +"""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..fff8d2a5ab 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -108,6 +108,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 +319,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( diff --git a/loopx/cli_commands/support_control_chat.py b/loopx/cli_commands/support_control_chat.py new file mode 100644 index 0000000000..9107f247a7 --- /dev/null +++ b/loopx/cli_commands/support_control_chat.py @@ -0,0 +1,156 @@ +"""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 ..dashboard_launcher import launch_dashboard, replace_existing_loopx_chat +from ..kiro_cli_goal_mode import KIRO_CLI_BIN +from ..paths import default_public_scan_root +from .support_control_chat_endpoint import register_chat_endpoint_command +from .support_control_agent_runtime import register_agent_runtime_arguments + + +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/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", From 09f85b2f6da7f7fbcea832d922126ff4fd584ddb Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 03:31:31 +0800 Subject: [PATCH 4/5] fix(smokes): read the thin heartbeat contract from the task body The fresh-clone quickstart asserted top-level quota_guard_command and quota_spend_command fields that the thin agent-input projection intentionally omits: the guard command is embedded in the task body and the spend command arrives in the quota response's next_cli_actions. Signed-off-by: song --- examples/fresh-clone-quickstart-smoke.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 From 125e01dc26e59edff0392cab85bc20084269afd1 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 03:57:03 +0800 Subject: [PATCH 5/5] chore(cli): tidy imports after the owner extractions The three extracted CLI owners left their former modules with imports that only the moved code used. Remove them and let Ruff sort the remaining blocks; the pre-existing broad-exception handlers are unchanged. Signed-off-by: song --- loopx/cli_commands/project_lifecycle.py | 1 - loopx/cli_commands/quota.py | 26 +++++++------------ loopx/cli_commands/quota_failure_report.py | 5 ++-- loopx/cli_commands/support_control.py | 10 +++---- loopx/cli_commands/support_control_chat.py | 3 +-- .../control_plane/agents/capability_memory.py | 2 +- 6 files changed, 20 insertions(+), 27 deletions(-) diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 7487ec90ad..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, diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 026a852eb9..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,14 +75,14 @@ prepare_quota_command_context, validate_quota_command_context_request, ) -from .quota_host_poll import attach_host_poll_receipt -from .quota_monitor_poll import record_quota_monitor_poll_for_cli 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 ( register_quota_command as register_quota_command, # noqa: PLC0414 ) @@ -363,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 @@ -385,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( diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py index 966a54535b..a4751ffdb3 100644 --- a/loopx/cli_commands/quota_failure_report.py +++ b/loopx/cli_commands/quota_failure_report.py @@ -15,7 +15,9 @@ from ..control_plane.coordination.legacy_writer_fence import ( LegacyCoordinationWriterFenced, ) -from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable +from ..control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, +) from ..control_plane.quota.error_codes import ( HeartbeatReceiptIdentityConflictError, QuotaCommandValidationError, @@ -28,7 +30,6 @@ ) from ..file_lock import lock_timeout_error_fields - QUOTA_EVENT_KINDS = { "should-run": "quota_should_run", "monitor-poll": "quota_monitor_poll", diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index fff8d2a5ab..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, @@ -334,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: @@ -692,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 index 9107f247a7..fbbed1408a 100644 --- a/loopx/cli_commands/support_control_chat.py +++ b/loopx/cli_commands/support_control_chat.py @@ -11,11 +11,10 @@ from collections.abc import Callable from ..chat_server import DEFAULT_CHAT_HOST, DEFAULT_CHAT_PORT -from ..dashboard_launcher import launch_dashboard, replace_existing_loopx_chat from ..kiro_cli_goal_mode import KIRO_CLI_BIN from ..paths import default_public_scan_root -from .support_control_chat_endpoint import register_chat_endpoint_command 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( diff --git a/loopx/control_plane/agents/capability_memory.py b/loopx/control_plane/agents/capability_memory.py index 3e1cc613af..86327b90a4 100644 --- a/loopx/control_plane/agents/capability_memory.py +++ b/loopx/control_plane/agents/capability_memory.py @@ -137,8 +137,8 @@ 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: