diff --git a/docs/architecture/rfcs/agent-session-execution-modes-v0.md b/docs/architecture/rfcs/agent-session-execution-modes-v0.md index cef12d4dae..7b8b3daad4 100644 --- a/docs/architecture/rfcs/agent-session-execution-modes-v0.md +++ b/docs/architecture/rfcs/agent-session-execution-modes-v0.md @@ -314,6 +314,97 @@ This is a delivery priority, not a default migration. The Reuse the existing profile editor and session projections; do not add a manager-only creation service, task ledger or scheduling loop. +### Pause, resume and automatic dispatch boundaries + +A wakeup is an observation opportunity, not execution or resume authority. Use +existing Goal activation, quota, Todo dependency and host binding owners; do not +add a parallel pause ledger. Apply the following precedence before a new model +request or host continuation: + +| Current fact | Permitted handling | Resume boundary | +|---|---|---| +| Explicit Goal stop, zero compute, or explicit host pause | Disarm automatic continuation and cancel owned timers. Repeated idle/results must not poll the model or repair/replan around the stop. | Resume the stopped owner explicitly, then obtain fresh Core admission. A new chat message or increased quota alone does not clear a separate host pause. | +| User/permission gate on selected work | Block that path. Keep independently admitted work available; notifications follow the existing reminder policy. | The owning gate is resolved; reselect and recheck affected work. A notification is not gate clearance. | +| External dependency or temporary quota exhaustion | Use the existing bounded event/wait/backoff contract without model calls on unchanged checks. | Current source-qualified evidence or the budget window permits fresh admission; neither overrides an explicit stop. | +| Unknown effect or interrupted work | Reconcile the original operation and preserve owed settlement. Stop new effects while authority is unknown. | Original identity/receipt recovery and current authority checks succeed. | +| Validated terminal no-follow-up | End recurring polling. Completing one Todo is not this terminal fact. | An explicit new/resumed operation, or a separately qualified new-work event, obtains current admission. No unconditional standby polling. | + +`should_run=false` cannot be overridden by `scheduler_hint.action=run_now`. +`stop_until_explicit_resume` takes precedence over a contradictory runnable hint. +Capture the current binding generation when probing; pause/resume must invalidate +pre-pause results even when the Goal/session id is unchanged. Cancelled timer +callbacks must not consume a replacement timer or dispatch after resume. Reuse +existing provider serialization/generation fences rather than another scheduler. + +Pause scope must be visible: a host pause stops that bound executor, a Goal stop +blocks that Goal's new work, and a dependency gate blocks only affected paths. +Stop new dispatch at the owning boundary; do not claim already-submitted tools +were cancelled or abandon original writeback/debit obligations. Measure in-flight +stop latency separately from idle dispatch suppression. + +**Implementation checkpoint.** The Pi adapter now latches Core stop hints and +advances its existing persisted generation on host pause/resume. The OpenCode +bridge gates automatic native resume on Core admission, serializes local binding +updates, fences in-flight results across pause/resume, and latches permission +rejection. Local stop intent is fenced before awaiting native pause/block IO; +an unavailable host stop stays an error while local continuation remains disarmed. +OpenCode 2 gates the initial task as well as continuation, retains an +unstarted objective in its private worker state, and exits on Core stop/validated +closure; `--force-resume` only re-arms the host and cannot bypass Core. These are +provider adapter changes consuming existing decisions, not a new shared policy +owner. OpenCode 2's inactivity hold is not proof of explicit user pause. + +Codex has separate transport and scheduler boundaries. In managed Goal Chat, +Stop and cold recovery re-read `thread/goal/get` after requesting pause; an ACK +without a non-active readback closes the owned transport and remains a failure. +A native turn-start event arriving during Stop is retained only for interrupt, +not queued-input delivery or resume-context steering. Explicit resume retains +the objective and cumulative native usage; LoopX mode rebuilds current context. +For Codex App, full/compact/brief/thin generated heartbeat bodies share the same +one-attempt pause and result-verification clause. An unavailable or unconfirmed +host stop ends the turn without claiming success, retrying, applying an RRULE +fallback or doing new work/spend. The existing external CLI scheduler separately +consumes Core's local stop directive before launching its configured wake command. +None of these paths grants automatic resume from another wake. + +Qualification uses the production adapters with controlled host/probe seams and +filesystem binding/CLI tests, Codex Goal Chat controller/store recovery, and +generated Agent-input checks. It does not establish installed vendor/model +adoption or cancellation latency. App-owned heartbeats remain host-owned: Core +emits the existing one-attempt stop instruction and requires host result readback; +without a host pre-dispatch hook, the first wake may already invoke a model. Do +not report this as zero model wakeups or confirmed scheduler shutdown. + +Decisive cases: hard pause plus repeated idle/events; pause during successful and +failed probes; pause→resume before an old result returns; cancelled callback after +resume; denied admission with a runnable hint; restart before initial dispatch; +continued external wait; unresolved Core stop after host resume; validated closure +without standby. Read back binding, dispatched requests and remaining timers, +then independently verify any original in-flight settlement. +Codex cases also cover pause ACK without effect, unavailable/disappeared readback, +late start events, cold recovery refusal, and stop obligations across prompt modes. + +### Wake, current context and correction form one chain + +These stages implement roadmap S4/S7 and consume the existing Goal Vision and +Stride owners; none is another scheduler or Goal authority. + +| Stage | Actual owner and handoff | Required counterexample | +|---|---|---| +| Before dispatch | `external_scheduler_worker` consumes the Core local stop directive first, then a strict boolean `should_run`. Existing monitor/reset-token/backoff rules retain waits; a changed signal requires current admission. | A string `"false"`, integer, missing or otherwise malformed admission cannot launch the configured wake command, even with `run_now`. Stop still exits without polling or a final repair probe. | +| Current instruction | The stable heartbeat entry reads a fresh guard. Typed `interaction_contract` projects current work sources, scoped blockers and pending full Goal/acceptance reads; `turn_envelope` and the Codex Host consume that same observation. Compose a bounded working brief of evidenced change, blocker, next work, acceptance and stops. | Same Todo identity with changed requirements must deliver the new condition. Missing sources deny dependent work; absent comparison evidence means unknown, not unchanged. A display summary cannot replace a full requirement or source revision. | +| Correction and review | Use local operation recovery, selected-work correction and existing `replan_action_packet.planning_guidance`. Widen only for relevant Goal/path/dependency/acceptance evidence. Core may require a due review; retaining the path is a valid qualified outcome. | Quiet monitor receipts alone cannot trigger a prompt-owned two-stall replan or self-pause. A decision receipt alone cannot prove that later work adopted it or improved the outcome. | + +The current patch removes legacy heartbeat/skill count rules and the fixed +candidate-count audit, preserves Core stall/periodic-review policies, and adds +the correction/followthrough guidance to the existing typed owner. Agent-authored +working briefs are source-grounded instructions, not a new persisted summary, +automatic language-model summarizer or authority record. The full Goal document +remains a progressive read; bounded transport does not permit truncating stops. +App-owned wake suppression still depends on a qualified host pre-dispatch hook. +Arbitrary provider-result events and live correction benefit remain the Stride +§7.5/§7.6 qualification, not inferred from synthetic dispatch/context tests. + ### Model selection within Agent creation and attachment This is a proposed refinement of the reusable Agent operations above, not a diff --git a/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md b/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md index bd71f1f614..17c7a274b2 100644 --- a/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md @@ -318,6 +318,75 @@ root,都不同于注册身份数和展示上限。不要只为展示这些观 假设、创建响应不确定、卸载与身份不存在的区别,以及恢复时授权撤销。用真实本地和 云端 adapter 读回验收;名单长度和角色文案不是验收证据。 +### 暂停、恢复与自动下发边界 + +唤醒只提供观察机会,不授予执行或恢复权限。复用现有 Goal activation、quota、Todo +依赖和 host binding owner,不另建暂停账本。新模型请求或宿主续跑前按以下边界判断: + +| 当前事实 | 允许的处理 | 恢复边界 | +|---|---|---| +| 显式 Goal 停止、compute 为零或显式宿主暂停 | 解除自动续跑并取消自有计时器。重复 idle/结果事件不得调用模型、修复或重规划绕过停止。 | 明确恢复被停止的 owner,再取得新 Core 准入。普通聊天或单独增加配额不解除另一层宿主暂停。 | +| 所选工作存在用户/权限门槛 | 阻塞该路径;独立且已获准工作仍可推进,通知遵循现有提醒策略。 | 所属 gate 实际解除,重新选择并核验受影响工作;通知不等于解除。 | +| 外部依赖或临时额度耗尽 | 沿现有有界事件/等待/退避契约,无变化检查不调用模型。 | 当前来源验证的证据或预算窗口允许新准入;都不能覆盖显式停止。 | +| 效果未知或工作中断 | 沿原操作核对,保留应做结算;权限未知时停止新效果。 | 原身份/回执恢复成功,并通过当前权限检查。 | +| 已验证 terminal no-follow-up | 结束周期轮询;完成一个 Todo 不等于此终态。 | 明确的新/恢复操作,或单独资格化的新工作事件,获得当前准入;不无条件待机轮询。 | + +`should_run=false` 不能被 `scheduler_hint.action=run_now` 覆盖; +`stop_until_explicit_resume` 优先于矛盾的可运行提示。检查时捕获当前 binding generation; +即使 Goal/session id 未变,暂停→恢复也必须使暂停前结果失效。被取消的 timer callback +不能消费替代计时器或在恢复后下发。复用 provider 的序列化/generation fence,不增加调度器。 + +暂停范围必须可读回:host pause 停止绑定执行者;Goal stop 阻止该 Goal 新工作;依赖 gate +只阻塞受影响路径。在所属边界停止新下发,不声称已提交工具已被取消,也不丢弃原 writeback/ +debit 义务。在途停止延迟与 idle 下发抑制分开测量。 + +**实现检查点。** Pi 消费 Core stop hint 后持久解除自动续跑,宿主暂停/恢复递增既有 +generation。OpenCode bridge 在 Core 准入后才自动恢复原生 Goal,串行更新本地 binding, +隔离暂停/恢复之间的旧检查结果,权限拒绝后保持暂停。等待原生 pause/block IO 之前先 +阻断本地续跑;宿主停止失败仍报错,本地保持解除状态,不冒充在途请求已取消。 +OpenCode 2 的首次正文与续跑均经过 +准入,未下发正文保留在私有 worker state,Core stop/已验证终态让 worker 退出; +`--force-resume` 仅重新启用宿主,不能绕过 Core。这里是消费既有决策的 provider 适配修改, +不是新的共享策略 owner。OpenCode 2 的无活动 hold 不能证明用户明确暂停。 + +Codex 分为宿主传输与调度两个边界。managed Goal Chat 的 Stop 与冷恢复,在请求暂停后 +重新读取 `thread/goal/get`;ACK 后未读到非 active 状态就关闭自有连接并保留失败。 +Stop 期间迟到的原生 turn-start 事件只用于中断对应回合,不再投递排队消息或恢复上下文。 +显式恢复沿用目标和累计原生用量;LoopX 模式重新构建当前上下文。Codex App 的 +full/compact/brief/thin 心跳正文共用一次暂停与结果核验条款;宿主停止不可用或 +未确认时结束本轮,不声称成功、不重试、不退回 RRULE 修改,也不做新工作/扣费。 +既有外部 CLI scheduler 则在启动所配置的 wake 命令前消费 Core local stop 指令。 +这些路径都不从另一次 wake 推断恢复授权。 + +资格化使用生产适配器及受控 host/probe、文件 binding/CLI、Codex Goal Chat controller/ +store 恢复及生成 Agent 输入测试,不证明已安装厂商/模型 +采用或取消延迟。App 心跳仍由宿主拥有:Core 返回既有的一次停止指令并要求结果读回; +宿主没有下发前 hook 时,第一次 wake 可能已经调用模型,不能称为零模型唤醒或已确认停调度。 + +Codex 另覆盖暂停 ACK 无效果、读回不可用/目标消失、迟到 start 事件、冷恢复拒绝,以及 +各 prompt 模式是否保留停止义务。 + +决定性场景:硬暂停后重复 idle/事件;成功和失败检查过程中暂停;旧结果返回前暂停→恢复; +恢复后迟到的取消 callback;拒绝准入与可运行提示矛盾;首次下发前重启;正常外部等待; +host resume 后 Core 仍停止;已验证终态不待机轮询。读回 binding、实际请求与剩余 timer, +原在途工作结算另外验证。 + +### 唤醒、当前上下文与纠偏构成同一条链 + +这三步对齐路线图 S4/S7,消费已有 Goal Vision 与 Stride owner,不新增调度器或 Goal 权威。 + +| 环节 | 实际 owner 与交接 | 必须验证的反例 | +|---|---|---| +| 下发前 | `external_scheduler_worker` 先消费 Core 本地停止指令,再严格检查布尔型 `should_run`。现有 monitor/reset-token/退避规则保留等待,变化信号仍需当前准入。 | 字符串 `"false"`、数字、缺失或其他错误准入值不能启动 wake 命令,`run_now` 也不能覆盖;停止直接退出,不轮询或做最后修复探测。 | +| 当前指令 | 稳定心跳入口读取新 guard;类型化 `interaction_contract` 投影当前工作来源、范围内阻塞与尚需完整读取的 Goal/验收,`turn_envelope` 和 Codex Host 消费同一次观察。形成有界工作摘要:有证据的变化、阻塞、下一步、验收与停止条件。 | 同一 Todo 身份的要求变化必须带入新条件;来源缺失禁止依赖工作,缺少对照证据是 unknown,不能当作无变化。展示摘要不替代完整要求或来源版本。 | +| 纠偏与复核 | 复用操作恢复、所选工作纠偏及 `replan_action_packet.planning_guidance`。仅在 Goal/路线/依赖/验收证据支持时扩大重规划;Core 到期复核可以保留原路线。 | 安静 monitor 回执不能触发模板自设的“两次停滞重规划/暂停”;决定回执不证明后续工作已采纳或结果已改善。 | + +当前补丁移除了心跳/技能里的旧计数规则与固定候选数审查,保留 Core 停滞/周期复核策略, +在既有类型化 owner 补充局部纠偏及后续效果核验。工作摘要由 Agent 基于来源整理,不新增 +持久摘要、自动模型总结器或权威记录;完整 Goal 仍按渐进读取交付,有界传输不能截断停止条件。 +App 自有心跳的模型唤醒抑制仍需要宿主下发前钩子。通用 provider 结果事件和真实纠偏收益 +仍归 Stride §7.5/§7.6 资格化,不能由模拟调度/上下文测试推定完成。 + ### 状态模型与 schema 绑定是模式归属的单元。其规范字段: diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index e7c0060274..74c0fc98a0 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -413,6 +413,13 @@ Todo owns the bounded postcondition. Replan owns direction change. A stride controller may recommend split, merge, continue, settle, or escalate, but it must not close a Todo, fabricate evidence, or clear a replan obligation. +The [wake/context/correction chain](agent-session-execution-modes-v0.md#wake-current-context-and-correction-form-one-chain) +keeps prompt prose subordinate to these owners. Local correction preserves +unaffected work; widening requires relevant current evidence. A due review may +retain the path, and later work/outcome readback qualifies correction benefit. +Heartbeat templates no longer add independent two-stall replan/self-pause rules. +This does not remove Core stall triggers or change configured review cadence. + Research goals may also consume the typed frontier defined by the Research Exploration RFC. A composition experiment can be one delivery slice; the stride model does not create another research graph. @@ -426,6 +433,13 @@ silently convert a proposal into authority. ### 7.5 Long-running work and decision checkpoints (proposed refinement) +Pause and wake eligibility precede this observation loop. Follow the +[session pause/resume boundary](agent-session-execution-modes-v0.md#pause-resume-and-automatic-dispatch-boundaries): +explicit stops disarm continuation; dependency waits retain bounded observation; +validated no-follow-up ends polling. A result event or scheduler timer cannot +clear a stop, and a pre-pause admission cannot survive resume. Qualify actual +request/timer readback separately from prompt guidance and in-flight settlement. + This 2026-10-04 refinement is a proposal for M3/M5, not shipped adaptive scheduling. It separates an **experiment boundary**, a **model decision checkpoint**, and an **accountable Turn boundary**. One experiment can span diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md index ab0e3d782a..64b5c488dc 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md @@ -364,6 +364,11 @@ host 先采用新的 execution path。 ### 7.3 与 Todo 和 replan 集成 +[唤醒/上下文/纠偏链](agent-session-execution-modes-v0.zh-CN.md#唤醒当前上下文与纠偏构成同一条链) +使提示文案遵循现有 owner:局部纠偏保留无关工作,扩大范围需要当前相关证据;到期复核 +可以维持路线,纠偏收益需后续工作/结果读回验证。心跳模板不再自设“两次停滞就重规划/ +暂停”,Core 停滞触发及配置的复核节奏不变。 + Todo 拥有 bounded postcondition,replan 拥有 direction change。Stride controller 可以建议 split、merge、continue、settle 或 escalate,但不能关闭 Todo、伪造 evidence 或清除 replan obligation。 @@ -379,6 +384,12 @@ policy 继续拥有 permission 权限。Supervisor、dashboard 与 scheduler pro ### 7.5 长作业与决策 checkpoint(修订提案) +暂停与唤醒资格判断先于本节观察循环。遵循 +[会话暂停/恢复边界](agent-session-execution-modes-v0.zh-CN.md#暂停恢复与自动下发边界): +显式停止解除续跑,依赖等待保留有界观察,已验证 no-follow-up 结束轮询。 +结果事件或计时器不能解除停止,暂停前准入不能沿用到恢复后。实际请求/timer 读回、 +prompt 指导和原在途结算分别资格化。 + 这项 2026-10-04 修订属于 M3/M5 提案,不代表 adaptive scheduling 已交付。 需要分开**实验边界**、**模型决策 checkpoint**和**可记账 Turn 边界**。 一个实验可以跨多个 Turn;每个已获准且需要记账的 Turn 仍以自己的 identity diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 5dc18f8f82..4e7ca9666f 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -136,6 +136,19 @@ qualifies that bounded recovery path; per-call hard budgets, live billing, Deskt Automations and multi-Agent acceptance remain separate. This closes no G1 or fleet qualification gate. +Automatic dispatch now has a concrete provider checkpoint in the +[session pause/resume boundary](agent-session-execution-modes-v0.md#pause-resume-and-automatic-dispatch-boundaries): +Pi and OpenCode fence pre-pause checks; OpenCode 2 gates initial dispatch; +Core stop and validated closure disarm provider continuation. Codex Goal Chat +verifies pause readback and rejects late input delivery; Codex App prompt modes +preserve one-attempt stop/verification and unresolved-stop handling. Controlled adapter, +filesystem and CLI qualification remain separate from installed host stop latency +and App-owned heartbeat shutdown. Existing dependency waits retain their owner. +The same chain now rejects malformed external-worker admission, delivers current +source context, and routes correction through the existing typed replan owner; +prompt wake counts no longer authorize replan or self-pause. Source delivery and +recorded decisions still do not establish model adoption or correction benefit. + ## 3. Portfolio Milestones, Resource Ordering and Completion S streams describe ongoing ownership, G milestones qualify a product combination, and R cards specify the current core implementation slices. These are cross-references, not a runtime state machine. Progress is evidence-gated rather than date-promised. Changes in capacity or business priority update canonical Todos rather than assuming every stream starts simultaneously. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 97df2ba71c..54114a327e 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -102,6 +102,16 @@ Direction M1 仍为纯 candidate builder。恢复当前依据后的真实采用 S6/S8 的有界来源读取切片提供[现有 Ego Page 的只读 MCP adapter](../../integrations/ego-source-reader.md)。它返回受限 origin 与确切 URL 下的渲染文本、图片索引,以及按序号读取的单张渲染图片区域;不创建会话、材料 authority 或浏览器服务。原生 Bot 已验证单图内容消费;原渠道回复须独立验收。单图不证明全部图片、完整原文或引用原帖已读。来源捕获、验证墙、截断、未加载图片与可选 provider 设置入口仍保留各自的验收边界,不以文本或单图提取关闭整个材料工作流。 +自动下发现有具体 provider 检查点,见 +[会话暂停/恢复边界](agent-session-execution-modes-v0.zh-CN.md#暂停恢复与自动下发边界): +Pi 与 OpenCode 隔离暂停前检查,OpenCode 2 首次下发也经过准入,Core stop 与已验证终态 +解除 provider 自动续跑。Codex Goal Chat 核验暂停读回并拒绝迟到输入投递;Codex App +各 prompt 模式保留一次停止、核验及未确认结果的处理。受控适配器、文件与 CLI 资格化不等于安装态停止延迟或 App 心跳 +已关闭;既有依赖等待仍由原 owner 处理。 +同一链路拒绝外部 worker 的错误准入值、交付当前来源上下文,并沿既有类型化 replan owner +处理纠偏;提示中的唤醒计数不再授权重规划或自行暂停。来源交付与决定记录仍不证明模型 +实际采纳或纠偏收益。 + ## 3. 组合里程碑、资源次序与完成定义 S 工作流表示长期责任,G 里程碑表示一次可验收的产品组合,R 卡表示当前主线实现切片。三者互相引用,不新增运行状态机。未列日期表示按证据晋级;可用资源和业务优先级改变时更新 canonical Todo,而不是假定全部同期开工。 diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index a0ad467af2..c9f43eb893 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -104,8 +104,9 @@ the variable available to argument expansion in Bash/zsh. Reuse the same value on retries. Native Goal entry remains host-specific and does not inherit this heartbeat bootstrap. -Thin's presentation regression ceiling is 3,000 characters (previously 2,500); -compact remains 6,500. These are measured regression budgets, not external host +Thin's presentation regression ceiling is 3,200 characters; compact is 6,700. +The stop/readback repair adds 117/127 characters respectively; the supported +320-character scope and compact scoped fixtures retain their full obligations. These are measured regression budgets, not external host limits or execution quota. Keep admission, continuation, scope and authority instructions readable rather than abbreviating them to fit a historical number. Peer workspace and claim/lease requirements come from the current quota contract @@ -155,6 +156,25 @@ not active host Goal objectives or benchmark prompts already pinned to a run. An installed runtime supplies updated dynamic contracts on later reads; upgrading it does not retroactively remove old text from an existing Goal. +Current instructions follow one chain: fresh guard → typed `work_context` and +pending `required_reads` → full quota or `TurnEnvelope` → Host. Use these sources +for a bounded working brief of evidenced progress/change, current blockers, +next work, acceptance and stops. Missing comparison evidence is unknown, not +unchanged. Preserve complete requirement reads and original settlement identity; +do not freeze a progress summary into the recurring automation or call another +model merely to rewrite it. A changed source requires the existing re-entry. +This is context delivery and Agent guidance, not proof of adoption or improvement. + +**Behavior change:** generated full/compact/thin bodies and the managed project +skill no longer invent two-stall replan/self-pause thresholds or a fixed count of +alternative candidates. All modes defer to current Core obligations and explicit +stop boundaries. Existing Core stall detection and periodic-review cadence remain; +local correction preserves valid work, a due review may retain its path, and +broader changes need relevant current evidence. The generic external worker also +rejects non-boolean admission before its configured wake command; a terminal +directive still wins. App-owned automations require the existing Host stop/update +and readback path, so this does not promise a pre-model App dispatch hook. + Claude Code's MCP-backed `loop.md` follows the same work-sizing rule and current quota contract, without a fixed one-segment limit or empty-Todo-list completion shortcut. Its `complete_task` tool already owns the ordered writeback/spend @@ -546,9 +566,9 @@ If the result says should_run=true: Check `delivery_batch_scale`, `delivery_outcome`, `post_handoff_outcome_gap_streak`, and `handoff_delivery_contract`; for repeated-small or surface-only loops, obey the contract. -2. Run a short steering audit before choosing work: list at least three - plausible next-action candidates across different P0/P1/P2 lanes when - useful; if the same topic has consumed several recent delivery slices, apply +2. Run a short steering audit when new evidence or a projected review warrants + changing the selected work; compare relevant alternatives without a fixed + candidate count. If the same topic has consumed several recent delivery slices, apply a continuation check and state why continuing still wins; keep compute quota separate from focus quota; record any losing high-value candidate that should not be forgotten. Include a product bottleneck lens: ask whether the core @@ -557,22 +577,16 @@ If the result says should_run=true: concrete bottleneck candidate when it should outrank the nearest local TODO. Plan/top todo/route changes need todo/Next Action writeback or no-writeback rationale. -3. Run the no-progress self-repair check before choosing delivery work. First - obey any machine-readable `autonomous_replan_obligation` or - `execution_obligation.must_attempt_work=true` returned by - `quota should-run`; that hard contract overrides a quiet no-op even when the - heartbeat prompt is short. Inspect recent active-state progress and public - run history for consecutive eligible heartbeat turns. Count a turn as - no-progress only when it produced no substantive artifact, no adapter or - implementation progress, no new gate or user decision, no new validation - signal, and only repeated status/brief-check/compact-checkpoint state edits. - Treat `quota_monitor_poll` events as no-spend stall evidence for this guard. - If 2 consecutive eligible heartbeats are no-progress loops, run one bounded - self-repair/replan segment before another quiet no-op. Delete or pause the - automation only when that repair path is itself stuck for 2 more eligible - turns, do not append a quota spend for the self-cancel turn, and return - NOTIFY explaining that the automation was cancelled because it was spinning - without progress. +3. Run the no-progress self-repair check against current evidence. Obey + `autonomous_replan_obligation`, `replan_action_packet` and + `execution_obligation.must_attempt_work` from the guard. Preserve ongoing + valid work; retry only after reconciling ambiguous effects. Repair the affected + path first; broader changes require current goal/path/dependency/acceptance + evidence. A due review can retain the path. At the next review check actual + adoption and outcomes, not just the plan. Wake counts do not authorize replan + or self-pause; stopping follows explicit instructions or the current Core/host + boundary, with original settlement still owed. Future-monitor liveness + receipts do not establish stalled work; Core owns eligible stall detection. 4. Choose scope-bounded work toward a verifiable result. Size by task, evidence and risk, not calls, files or wake cadence. Related implementation, research, tests, docs and writeback may form one coherent effort; a focused correction @@ -655,9 +669,16 @@ after a final quota/replan check confirms repeated unchanged polls. App-hosted heartbeats should search/use `automation_update` when available. If `scheduler_hint.action=stop_until_explicit_resume` and `scheduler_hint.app_automation.host_action=pause_or_delete_current_heartbeat`: in -that terminal case, call `automation_update` once to pause the current -heartbeat (delete only if pause is unavailable), verify the host result, spend -no quota, and end the turn without a scheduler ACK. Otherwise call it only when +that stop case, call `automation_update` once to pause the current heartbeat, +verify the host result, spend no quota, and end the turn without a scheduler ACK. +Full, compact, brief and thin generated bodies retain this same stop clause. +If the tool is unavailable or the result does not confirm the current heartbeat +stopped, leave that outcome unresolved; do not claim success, retry, change the +RRULE as fallback, or perform new work/spend. Output still follows the user +notification contract. An App-owned wake may already have invoked the model +before it reaches this guard. Regenerating a body does not update an existing +installed automation: apply the new body through the existing host update flow, +retain its identity, and read back its prompt/status. Otherwise call it only when `scheduler_hint.app_automation.stateful_backoff.apply_needed=true` and `scheduler_hint.app_automation.recommended_rrule` is present. After a successful RRULE update, run `loopx` with @@ -743,9 +764,8 @@ instruction/evidence/todo/stale source/safe handoff exists. Check `post_handoff_outcome_gap_streak`, and `handoff_delivery_contract`; for repeated-small or surface-only loops, obey the contract. Then obey any machine-readable `autonomous_replan_obligation` or -`execution_obligation.must_attempt_work=true`; if 2 consecutive eligible -heartbeats are no-progress loops, run bounded self-repair/replan before another -quiet no-op. Do one bounded verifiable progress batch when a real boundary +`execution_obligation.must_attempt_work=true`; follow the projected repair/replan +obligation without inventing a wake-count threshold or self-pause rule. Do one bounded verifiable progress batch when a real boundary exists: implementation, validation, docs, and state writeback may belong in the same batch. Do not stop at the first tiny substep when the validation/writeback boundary is already clear. Validate it, write back changed files / validation / @@ -810,9 +830,10 @@ For every automatic heartbeat turn, the agent-facing checklist is: 10. Check `delivery_batch_scale`, `delivery_outcome`, `post_handoff_outcome_gap_streak`, and `handoff_delivery_contract`; for repeated-small or surface-only loops, obey the contract. -11. Start bounded self-repair/replan if 2 consecutive eligible turns are only - repeated no-progress status loops. Cancel or pause instead of spending only - if that repair path is itself stuck for 2 more eligible turns. +11. Follow current Core repair/replan obligations. Correct the affected work + first; widen only on relevant evidence and check subsequent adoption/results. + A due review may keep the route. Wake counts grant no replan or self-pause + authority; explicit/Core/host stops and original settlement still apply. 12. Ordinary completion links a successor before settlement. Final no-follow-up completion happens only after the accountable refresh and matching spend receipt. 13. Plans/top todos/route changes need LoopX todo / Next Action diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index ae92288f07..3c8772e2b7 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -51,14 +51,37 @@ for the richer generator packet; neither is the recurring Agent hot path. The heartbeat envelope ceiling covers the unbound and representative agent/scope-bound Codex App thin fixtures. Its 5,400-character JSON ceiling remains independent of -the **3,000-character thin task body**, 4,000-character native Goal body, +the **3,200-character thin task body**, 4,000-character native Goal body, structural limits, and emitted CLI ceilings. These are presentation regression budgets, not external host limits, token counts, execution quota, or permission. + +All scoped heartbeat envelope fixtures now measure that same shipped Agent +projection, rather than generator diagnostics. On the unchanged two-scope fixture, +the shared stop/readback clause changes Agent JSON from 3,182 to 3,299 characters; +the 5,400 JSON ceiling stays unchanged. Separately, the compact +scoped body grows from 7,086 to 7,213 normalized characters, including the fixed +640-character Reward Memory allowance, exceeding its previous 7,140 limit. +The compact base allowance becomes 6,700 (7,340 with that optional contract), +preserving the exact heartbeat target, single attempt, verified result and +unconfirmed-stop handling instead of deleting those obligations. The real thin CLI +fixture with a supported 320-character scope grows from 2,992 to 3,109; its body +ceiling becomes 3,200. Full/brief/native Goal body limits, key counts and execution +budgets do not change. The broader full-scoped fixture already +exceeded its limit before this repair and remains an overflow diagnostic. + +同一 scoped fixture 的 Agent JSON 从 3,182 增至 3,299 字符,仍沿用 5,400 上限; +不再把生成器诊断误算为 Agent 输入。compact 正文从 7,086 增至 7,213(均含可选契约的 +640 字符余量),超过旧 7,140;基础展示预算仅从 6,500 调至 6,700,保留停止对象、 +单次尝试、结果确认与未确认处理。真实 thin CLI 的 320 字符范围输入从 2,992 增至 3,109,正文上限调至 3,200; +full/brief/native Goal、字段数和执行预算不变;原已超限的 full +大范围 fixture 仍如实报告超限。 + Do not abbreviate admission, continuation, scope, safety or settlement instructions solely to fit a historical character count, or copy dynamic quota decisions into the static prompt. -The thin body ceiling rises from 2,500 to 3,000 to retain readable peer guidance. +The earlier readable-peer qualification raised the thin body ceiling from 2,500 +to 3,000; those historical measurements follow. On the same real CLI fixture with a host scope and three runtime capabilities, latest main measured 2,515 normalized body characters, the earlier compressed PR measured 2,495, and readable guidance measures 2,632. With the supported @@ -78,7 +101,67 @@ missing or altered authority wording, other surfaces, and subsequent edits after this block is in the base use the ordinary growth budget. Absolute emitted ceilings, line limits and semantic anchors remain enforced. -thin 正文回归预算从 2,500 调至 3,000,保留完整的准入、接续与权限说明。同一真实 +The pause/correction qualification measures the same small/crowded/multi-Agent +thin CLI fixtures at `a59aed703` as 3,070 JSON / 4,998 Markdown characters, +the pause-only branch as 3,187 / 5,115, and the correction refinement as +3,224 / 5,152; all remain 65 Markdown lines. The prior 5,100 Markdown ceiling +already fails on pause-only instructions. Retain verified-stop and Core-owned +correction clauses and raise only that human diagnostic ceiling to 5,250; +the 3,400 JSON ceiling, body, line, key and execution budgets stay unchanged. +The ordinary generated full body shrinks by 215 characters; compact/brief/thin +grow by 88/46/37 on the matched public fixture. No second summary or model call +is added. Replan advice gains 363 text bytes for local scope and outcome checks; +its separate advice allocation becomes 1,800 bytes, preserving the 8 KiB envelope +warning and any already-over-budget fixture instead of truncating requirements. + +暂停/纠偏的同一三场景 thin CLI 从 main 的 3,070/4,998 字符,经过暂停补丁的 +3,187/5,115,变为 3,224/5,152(JSON/Markdown);Markdown 均为 65 行。 +仅人工诊断输出上限由 5,100 调至 5,250,机器 JSON/正文/行数/执行预算不变。 +重规划指导增加 363 字节,独立指导预算为 1,800;8 KiB 总包告警及原有超限仍保留。 + +The same qualification also covers emitted `--compact` Markdown, independently +of the task-body and thin-output limits. On the unchanged native mode fixture, +base `3e611660a` emits 11,315 characters and the pause/correction head +`84e00bac1` emits 11,530, failing the old 11,500 ceiling; both have 145 lines. +The 215-character growth carries the current-heartbeat single stop attempt, +confirmation and unresolved-stop handling, plus evidence-scoped correction and +original settlement obligations. These clauses guide different decisions and +are not removable duplicate diagnostics. Preserve the complete text and raise +only this Markdown regression ceiling to 11,750, leaving 220 characters of +headroom (the base had 185) for bounded wording variation. JSON grows from +12,658 to 12,873 under the unchanged 13,500 ceiling; the 155-line cap, semantic +anchors, fixture population, growth checks and execution limits remain intact. +This qualifies readable output size, not installed host behavior or model adoption. + +The base/head differential independently detects the same instruction rollout: +work-context text adds 208 characters (one receipt character may vary), optional +replan advice adds 363, and thin/brief/compact heartbeat output adds 154/163/215. +The existing probe records complete historical/current clauses and grants a +one-time allowance only on their named carriers: 224 characters for context, +384 for replan advice, or 224 for heartbeat text. Context and replan allowances +combine only when both transitions are observed. JSON bytes/compact characters +follow the same bounded allowance; line limits and absolute ceilings do not +change. Missing or contradictory observations, already-upgraded bases, other +surfaces and growth beyond the allowance retain ordinary failure behavior. +This evidence does not qualify arbitrary future wording or runtime authority. + +同一资格化还须独立覆盖 `--compact` 的完整 Markdown 输出:不改原生 mode 夹具时, +基线 `3e611660a` 为 11,315 字符,暂停/纠偏头 `84e00bac1` 为 11,530, +确实超过旧 11,500 上限;两者均为 145 行。新增 215 字符承载当前心跳单次停止、 +结果确认、未确认处理、证据限定的局部纠偏及原结算义务,各自支持不同决定, +不能作为重复诊断删除。保留全文,仅将该 Markdown 回归上限调至 11,750, +留 220 字符余量供有界措辞变化(基线余量为 185)。JSON 从 12,658 增至 +12,873,仍遵守原 13,500 上限;155 行、语义锚点、夹具规模、增长检查和执行限制 +不变。这只资格化可读输出尺寸,不证明安装态行为或模型采纳。 + +base/head 差异门独立测得工作上下文增加 208 字符(回执可能差 1 字符)、可选 +replan 指引增加 363、thin/brief/compact 输出增加 154/163/215。既有 probe +记录完整的新旧条款,仅在对应输出表面首次迁移时分别允许 224、384 或 224 字符; +上下文与 replan 余量只有同时观察到两项迁移才相加。字节及 compact JSON 使用同一 +有界余量,行数和绝对上限不变。缺失/矛盾观察、已升级基线、其他表面或超额增长 +仍失败;这不资格化未来任意措辞,也不改变运行时权限。 + +此前 thin 正文回归预算从 2,500 调至 3,000,保留完整的准入、接续与权限说明。同一真实 CLI、host scope 和三项 runtime 能力下,最新 main/此前压缩版本/清晰版本分别为 2,515/2,495/2,632 字符;320 字符 scope 时为 2,805/2,785/2,922,新预算余量 78 字符。单 peer 与双 peer 结果相同。计量只替换 Goal 与 state 路径,不缩短身份、 diff --git a/docs/reference/goal-chat-continuation.md b/docs/reference/goal-chat-continuation.md index 85a26f544c..8cf757f886 100644 --- a/docs/reference/goal-chat-continuation.md +++ b/docs/reference/goal-chat-continuation.md @@ -149,6 +149,12 @@ messages; images use the ordinary conversation after pausing. in-flight requests can overshoot it and member usage is accounted separately. - Browser refresh reconnects to the existing local run. After service loss, reconnect restores and pauses the original native thread before resuming. + Stop and recovery verify non-active Goal state with a fresh host read after + requesting pause. If readback fails, disappears or remains active, the owned + transport is closed and the operation fails; an ACK is not a pause receipt. + Late native turn-start events are used only to interrupt that turn, never to + inject queued messages or resume context after Stop. Already-submitted effects + retain their original reconciliation and settlement obligations. The Chat hard timeout remains in force; this is not an unattended daemon. - When a delegated result is first accepted, the Chat service continues the lead once through the same admission as `resume`. The wake belongs to the @@ -213,6 +219,9 @@ For a disposable mixed-team setup, use the 升级底层线程;已有未结束的原生 Goal 时拒绝替换。以后恢复都沿用该线程。 服务重启先恢复并暂停原线程;浏览器刷新不会另起运行。运行仍有硬超时,尚非 无人值守 daemon。配置文件在未结束的 Goal 中保持摘要绑定,改变后需先协调处理。 +请求暂停后会重新读取宿主 Goal,确认已非 active;读回失败、目标消失或仍 active 时 +关闭自有连接并返回失败,ACK 不算暂停回执。Stop 后迟到的 start 事件仅用于中断对应 +回合,不再注入排队消息或恢复上下文;已提交效果仍保留原操作的对账和结算义务。 成员结果首次被接受时,Chat 服务按与 `resume` 相同的准入规则继续协调员一次。 唤醒绑定在「启动该操作的回合所属会话」上:同一 Goal 与身份下的其他会话不会被选为 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index a04c9100d3..2a71929543 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -329,6 +329,11 @@ instructions and an optional sixth stride instruction through full/compact quota Prefer a discriminating probe, keep negative evidence scoped to tested conditions and proxies scoped to their coverage, and reuse still-applicable evidence when no new uncertainty warrants repetition. + Correct within the affected work first; widen replanning only when current + Goal, path, dependency or acceptance evidence warrants it. A due review may + retain the path rather than force a pivot. At the next review check actual + subsequent work and authoritative outcomes; recording the decision alone + proves neither adoption nor improvement. - **Only with `evidence_conditioned_v1`:** Review the previous path decision against subsequent work and authoritative outcomes. When local tuning or repeated validation leaves the goal gap unexplained, compare a bounded alternative mechanism with continuation or @@ -343,9 +348,21 @@ TypeScript strategy component adds the sixth and the followthrough read model; identity, evidence selection and guidance. Unknown versions fail before mutation. This is Agent judgment advice, not a new machine-enforced acceptance gate, permission, adaptive scheduler or automatic route actuator. Typed semantic -outcomes and non-replan Turns are unchanged. Tests prove delivery and isolation; +outcomes and non-replan admission/settlement rules are unchanged. Tests prove delivery and isolation; model adoption and utility require matched experiments. +Heartbeat templates consume this owner; they must not invent a separate +two-stall replan or self-pause policy. Operation-local recovery first reconciles +ambiguous effects. Local work correction preserves unaffected Todos and the +original accountable Turn; a material scope/direction change still uses the +existing Vision/ACK/successor contracts. The prompt and skill now remove those +legacy count rules; Core stall detection and configured periodic reviews remain. + +心跳模板不再另设“两次停滞就重规划、再两次就暂停”的规则。操作恢复先核对不明 +副作用;局部纠偏保留无关工作和原 Turn 结算,扩大范围仍走 Vision/ACK/successor。 +到期复核可以维持原路线。纠偏写入、后续工作采纳、结果改善分别验证;Core 原有的 +停滞识别和周期复核不变。 + 共享 replan action packet 保留五条默认指引,可选策略增加第六条,并在完整/精简 quota 与 Turn envelope 中保留: diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index 02902e0266..0536ad0f4e 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -131,6 +131,7 @@ def _receipt_row( ), "next_action_basis_count": semantics.next_action_basis_count(payload), "authoring_inputs": semantics.authoring_input_observations(text), + "continuation_instructions": semantics.continuation_instruction_observations(text), } diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 705e86b2fe..2ca9675a61 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -588,7 +588,7 @@ def main() -> int: "current quota claim/lease and workspace contract plus repository rules", "Read agent_channel.work_context and required_reads (or envelope) before work", "Size by task/evidence/risk within authority/budget, not ops/files/wakes", - "calls/writeback ≠ completion; obey stop/replan", + "calls/writeback ≠ completion. Correct locally; obey projected replan/stop, never wake counts", "Task-scoped coordination grants no authority over other agents", "Keep scope in this prompt, not todo metadata", "Normal turns use CLI `interaction_contract`; use `loopx-project` for " @@ -603,12 +603,12 @@ def main() -> int: "Missing NOTIFY action:", "user Todo unprojected", "Observed capabilities -> `--available-capability`; never user gates", - "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", - "else RRULE/projected-fallback_hint/ack/fail", + "host_action=pause_or_delete_current_heartbeat: automation_update pause current heartbeat once", + "Else RRULE/projected-fallback_hint/ack/fail", "Exact monitor settlement=no refresh/spend", "Admitted work: settlement_plan even if artifacts unchanged", "unchanged->truthful --vision-unchanged-reason", - "guard; 2 stalls->replan", + "re-enter guard", "`agent_read_required`", "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", @@ -706,12 +706,12 @@ def main() -> int: "Due/peer work is not a user prompt", "Missing NOTIFY action:", "user Todo unprojected", - "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", - "else RRULE/projected-fallback_hint/ack/fail", + "host_action=pause_or_delete_current_heartbeat: automation_update pause current heartbeat once", + "Else RRULE/projected-fallback_hint/ack/fail", "Exact monitor settlement=no refresh/spend", "Admitted work: settlement_plan even if artifacts unchanged", "unchanged->truthful --vision-unchanged-reason", - "guard; 2 stalls->replan", + "re-enter guard", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", "No learning queue unless asked", @@ -822,19 +822,19 @@ def main() -> int: "return quiet `DONT_NOTIFY`: do not run, edit, or spend", "Check `delivery_batch_scale`, `delivery_outcome`,", "repeated-small or surface-only loops", - "Run a short steering audit before choosing work", - "list at least three plausible next-action candidates across different P0/P1/P2 lanes", + "Run a short steering audit when new evidence or a projected review warrants", + "compare relevant alternatives without a fixed candidate count", "apply a continuation check", "keep compute quota separate from focus quota", "Include a product bottleneck lens", "user experience, agent capability, evidence quality, adapter readiness, or priority-rule gaps", "promote one concrete bottleneck candidate when it should outrank the nearest local TODO", - "Run the no-progress self-repair check before choosing delivery work", + "Run the no-progress self-repair check against current evidence", "autonomous_replan_obligation", - "execution_obligation.must_attempt_work=true", - "2 consecutive eligible heartbeats are no-progress loops", - "self-cancel turn", - "repair path is", + "must_attempt_work=true", + "replan_action_packet", + "Wake counts do not authorize replan or self-pause", + "with original settlement still owed", "Choose scope-bounded work toward a verifiable result", "a focused correction can also be sufficient", "obey budgets, explicit stops, settlement and replan requirements", @@ -933,8 +933,8 @@ def main() -> int: "return quiet `DONT_NOTIFY`: do not run, edit, or spend", "Check `delivery_batch_scale`, `delivery_outcome`,", "repeated-small or surface-only loops", - "Run a short steering audit before choosing work", - "list at least three plausible next-action candidates across different P0/P1/P2 lanes", + "Run a short steering audit when new evidence or a projected review warrants", + "compare relevant alternatives without a fixed candidate count", "apply a continuation check", "keep compute quota separate from focus quota", "Include a product bottleneck lens", @@ -942,13 +942,13 @@ def main() -> int: "promote one concrete bottleneck candidate when it should outrank the nearest local TODO", "Run the no-progress self-repair check", "autonomous_replan_obligation", - "execution_obligation.must_attempt_work=true", - "2 consecutive eligible heartbeats are no-progress loops", - "self-cancel turn", - "repair path is", + "must_attempt_work=true", + "replan_action_packet", + "Wake counts do not authorize replan or self-pause", + "with original settlement still owed", "Read agent_channel.work_context and required_reads (or envelope) before work", "Size by task/evidence/risk within authority/budget, not ops/files/wakes", - "calls/writeback ≠ completion; obey stop/replan", + "calls/writeback ≠ completion. Correct locally; obey projected replan/stop, never wake counts", "a focused correction may suffice", "Stay inside `goal_boundary` when present", "Follow user authority and repository rules", @@ -1005,9 +1005,9 @@ def main() -> int: "must_attempt_work=true", "recommended_mode=run_first_read_only_map", "recommended_mode=mapped_noop_if_unchanged", - "Run a short steering audit before choosing work", + "Run a short steering audit when new evidence or a projected review warrants", "Include a product bottleneck lens", - "Run the no-progress self-repair check before choosing delivery work", + "Run the no-progress self-repair check against current evidence", "Choose scope-bounded work toward a verifiable result", "Public-safe repo publication is not an operator gate by itself", "Run validation proportionate to the change and risk", @@ -1089,7 +1089,7 @@ def main() -> int: assert "Never default or upgrade a smaller/preparatory turn" in normalized(project_skill), project_skill assert "do not infer scale/outcome from the classification name" in normalized(project_skill), project_skill assert "no-progress self-repair guard" in project_skill, project_skill - assert "2 consecutive stalled turns" in normalized(project_skill), project_skill + assert "Wake counts alone never authorize replan or self-pause" in normalized(project_skill), project_skill assert "one idempotent receipt on every heartbeat" in normalized(project_skill), project_skill assert "retry with the same turn id" in normalized(project_skill), project_skill assert "unchanged monitor-only polls are liveness-preserving no-ops" in normalized(project_skill), project_skill diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index c79586ae04..c1e1a424c3 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -49,8 +49,8 @@ "owner": "heartbeat automation", "consumer": "wake and route one bounded turn", "cold_path": "quota should-run, status, or review-packet --handoff-only", - # Includes generator metadata and scoped commands, not just task_body. - # The independent 3,000-character thin body cap is checked separately. + # The shipped Agent projection, excluding generator-only diagnostics. + # The independent 3,200-character thin body cap is checked separately. "max_json_chars": 5_400, "max_nested_keys": 40, "max_top_level_keys": 30, @@ -430,18 +430,18 @@ def main() -> int: runtime_profile="codex_app_heartbeat", ) ) - # Real automations normally bind an agent; the unbound fixture alone - # misses repeated identity/scope arguments in the generator envelope. + # Real automations normally bind an agent. Measure the same shipped + # projection for every fixture, including its identity/scope guidance. for binding in ( {"agent_id": "worker-a"}, {"agent_id": "worker-a", "agent_scopes": ["implementation", "review"]}, ): - assert_surface("heartbeat_prompt_json", build_heartbeat_prompt( + assert_surface("heartbeat_prompt_json", project_heartbeat_agent_input(build_heartbeat_prompt( goal_id=GOAL_ID, thin=True, runtime_profile="codex_app_heartbeat", **binding, - )) + ))) assert quota_payload["should_run"] is True, quota_payload reset_policy = quota_payload["scheduler_hint"]["reset_policy"] diff --git a/loopx/chat_codex_goal.py b/loopx/chat_codex_goal.py index 2d64fc2430..36b8599ec3 100644 --- a/loopx/chat_codex_goal.py +++ b/loopx/chat_codex_goal.py @@ -113,19 +113,30 @@ def read(self) -> dict[str, Any] | None: return raw def pause(self) -> None: - """Fence activation before pausing; stop the in-flight native turn too.""" + """Fence activation, confirm host pause, and stop the native turn.""" self.stopped.set() with self.mutation_lock: - current = self.read() - if current and current["status"] == NativeGoalStatus.ACTIVE: - self.session._request( - "thread/goal/set", - { - "threadId": self.session.thread_id, - "status": "paused", - }, - ) - self.session.interrupt() + try: + current = self.read() + if current and current["status"] == NativeGoalStatus.ACTIVE: + self.session._request( + "thread/goal/set", + { + "threadId": self.session.thread_id, + "status": "paused", + }, + ) + current = self.read() + if current is None or current["status"] == NativeGoalStatus.ACTIVE: + raise self.session._runtime_error( + "Codex native Goal pause could not be confirmed." + ) + self.session.interrupt() + except Exception: + # Stop and cold recovery also call pause directly. A successful + # set ACK alone must not leave this owned transport running. + self.session.close() + raise @staticmethod def compact(goal: dict[str, Any] | None) -> dict[str, Any]: @@ -258,6 +269,11 @@ def _observe(self, emit: Callable[[str, dict[str, Any]], None]) -> dict[str, Any goal = self.read() last_facts = None while True: + if self.stopped.is_set(): + # Join a concurrent Stop before reporting its outcome. The + # cached pre-Stop status is not an authoritative pause receipt. + self.pause() + goal = self.read() if goal is None: raise self.session._runtime_error("The active native Goal disappeared.") facts = self.compact(goal) @@ -285,6 +301,13 @@ def _observe(self, emit: Callable[[str, dict[str, Any]], None]) -> dict[str, Any event = self.session._next_event( deadline=deadline if deadline is not None else time.monotonic() + self.session.idle_timeout_sec ) + if self.stopped.is_set(): + # A start already submitted by the host may arrive after Stop. + # Retain its exact id for interrupt, never for input delivery. + if (event.get("method") == "turn/started" + and _event_thread_id(event) == self.session.thread_id): + self.session.current_turn_id = _event_turn_id(event) + continue if self.session._check_server_gate(event): continue if _event_thread_id(event) not in {"", self.session.thread_id}: @@ -297,14 +320,18 @@ def _observe(self, emit: Callable[[str, dict[str, Any]], None]) -> dict[str, Any continue turn_id = _event_turn_id(event) if method == "turn/started" and turn_id: - current_turn = self.session.current_turn_id = turn_id - delta_items.clear() - emit("turn.started", {"upstream_turn_id": turn_id}) - if self.resume_context: - context, self.resume_context = self.resume_context, None - self.session.steer(context, expected_turn_id=turn_id) - if self.turn_start_handler: - self.turn_start_handler(turn_id) + with self.mutation_lock: + if self.stopped.is_set(): + self.session.current_turn_id = turn_id + continue + current_turn = self.session.current_turn_id = turn_id + delta_items.clear() + emit("turn.started", {"upstream_turn_id": turn_id}) + if self.resume_context: + context, self.resume_context = self.resume_context, None + self.session.steer(context, expected_turn_id=turn_id) + if self.turn_start_handler: + self.turn_start_handler(turn_id) if turn_id and turn_id != current_turn: continue if method == "item/agentMessage/delta": diff --git a/loopx/control_plane/heartbeat/budget.py b/loopx/control_plane/heartbeat/budget.py index 68652afa8c..3e9c91ebbd 100644 --- a/loopx/control_plane/heartbeat/budget.py +++ b/loopx/control_plane/heartbeat/budget.py @@ -11,9 +11,9 @@ # them solely to fit a historical character ceiling. INTERFACE_BUDGET_CHARS = { "full": 12_000, - "compact": 6_500, + "compact": 6_700, "brief": 4_300, - "thin": 3_000, + "thin": 3_200, "visible_goal": 4_000, } NATIVE_GOAL_HOST_MAX_CHARS = INTERFACE_BUDGET_CHARS["visible_goal"] diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 8d727485fb..c23df0cd54 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -8,7 +8,8 @@ SCOPE_BOUNDED_WORK_RULE = ( "Read agent_channel.work_context and required_reads (or envelope) before work. " "Size by task/evidence/risk within authority/budget, not ops/files/wakes; " - "calls/writeback ≠ completion; obey stop/replan." + "calls/writeback ≠ completion. Correct locally; obey projected replan/stop, " + "never wake counts." ) USER_TODO_FINAL_MESSAGE_RULE = ( @@ -51,25 +52,27 @@ "digest/evidence attestation + refresh/spend readback. Else awaiting/zero " "provider calls; no raw/private content." ) +SCHEDULER_STOP_RULE = ( + "host_action=pause_or_delete_current_heartbeat: automation_update pause current " + "heartbeat once; verify stopped; unconfirmed/unavailable: never claim stopped; " + "end; no work/spend/retry/RRULE fallback. " +) SCHEDULER_HINT_APPLICATION_RULE = ( "Bootstrap/min interval is a floor; follow current backoff. " - "`scheduler_hint` no-spend. host_action=pause_or_delete_current_heartbeat -> " - "automation_update stop once, verify, end; else apply_needed -> RRULE via " + + SCHEDULER_STOP_RULE + "Else apply_needed -> RRULE via " "automation_update; unavailable -> use fallback_hint.cli_args only when projected " "(SQLite/app API " "bypass - fallback only), then ack; further failure -> failure_hint; " - "ack_needed -> ack." + "ack_needed -> ack. No spend." ) SCHEDULER_HINT_COMPACT_RULE = ( "Bootstrap/min interval is a floor; follow current backoff. " - "host_action=pause_or_delete_current_heartbeat: automation_update stop; " - "else RRULE apply via automation_update, projected fallback_hint when unavailable, " + + SCHEDULER_STOP_RULE + "Else RRULE apply via automation_update, projected fallback_hint when unavailable, " "then ack/fail. No spend." ) SCHEDULER_HINT_THIN_RULE = ( "Bootstrap/min=floor; backoff. " - "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend); " - "else RRULE/projected-fallback_hint/ack/fail." + + SCHEDULER_STOP_RULE + "Else RRULE/projected-fallback_hint/ack/fail." ) RUNTIME_CAPABILITY_PROJECTION_THIN_RULE = ( "Observed capabilities -> `--available-capability`; never user gates." diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 2467fb9225..b87a461406 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -112,6 +112,8 @@ def render_heartbeat_task_body( {USER_TODO_FINAL_MESSAGE_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} +{SCHEDULER_HINT_APPLICATION_RULE} App automation cadence changes are host +scheduling updates only; they never consume quota or authorize delivery work. If the result says `should_run=false`: @@ -149,8 +151,6 @@ def render_heartbeat_task_body( - Otherwise, do not do implementation work, adapter work, file edits, research, or project exploration in this turn. Return a quiet heartbeat `DONT_NOTIFY` response with the skip reason. - {SCHEDULER_HINT_APPLICATION_RULE} App automation cadence changes are host - scheduling updates only; they never consume quota or authorize delivery work. If the result says `should_run=true`: @@ -201,9 +201,9 @@ def render_heartbeat_task_body( Check `delivery_batch_scale`, `delivery_outcome`, `post_handoff_outcome_gap_streak`, and `handoff_delivery_contract`; for repeated-small or surface-only loops, obey the contract. -2. Run a short steering audit before choosing work: list at least three - plausible next-action candidates across different P0/P1/P2 lanes when - useful; if the same topic has consumed several recent delivery slices, apply +2. Run a short steering audit when new evidence or a projected review warrants + changing the selected work; compare relevant alternatives without a fixed + candidate count. If the same topic has consumed several recent delivery slices, apply a continuation check and state why continuing still wins; keep compute quota separate from focus quota; record any losing high-value candidate that should not be forgotten. Include a product bottleneck lens: ask whether the core @@ -211,15 +211,11 @@ def render_heartbeat_task_body( evidence quality, adapter readiness, or priority-rule gaps, and promote one concrete bottleneck candidate when it should outrank the nearest local TODO. Plan/top todo/route changes need todo/Next Action writeback or no-writeback rationale. -3. Run the no-progress self-repair check before choosing delivery work. Obey - any machine-readable `autonomous_replan_obligation` or - `execution_obligation.must_attempt_work=true` from `quota should-run`; that - hard contract overrides a quiet no-op. Count a turn as no-progress only when - it produced no substantive artifact, adapter/implementation progress, - gate/user decision, or validation signal. If 2 consecutive eligible - heartbeats are no-progress loops, run one bounded self-repair/replan segment - before another quiet no-op. Delete/pause only when that repair path is stuck - for 2 more eligible turns; no spend for the self-cancel turn. +3. Run the no-progress self-repair check against current evidence. Obey + `autonomous_replan_obligation`, `replan_action_packet` and + `execution_obligation.must_attempt_work` from the guard. Preserve valid work; + reconcile ambiguous effects before retry. Wake counts do not authorize replan + or self-pause; obey explicit/Core/host stops, with original settlement still owed. 4. {SCOPE_BOUNDED_WORK_RULE} Related work can form a coherent effort; a focused correction may suffice. 5. Execute that scoped work. Stay inside `goal_boundary` when present. @@ -318,11 +314,11 @@ def render_brief_heartbeat_task_body( {HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} +{SCHEDULER_HINT_THIN_RULE} `should_run=false`: follow user channel; `monitor_quiet_skip` records receipt/stall. Wait: one read-only poll; only new evidence permits writeback/spend; retry the same id. -{SCHEDULER_HINT_THIN_RULE} `agent_read_required`: drain/read/triage before work; settle/ACK. `should_run=true`: read compact, `status --limit 3`, and @@ -430,12 +426,12 @@ def render_compact_heartbeat_task_body( Check `delivery_batch_scale`, `delivery_outcome`, `post_handoff_outcome_gap_streak`, `handoff_delivery_contract`; obey repeated-small/surface-loop contracts. -5. Run steering audit: compare P0/P1/P2, continuation checks, +5. Run steering audit on new evidence/projected review: relevant P0/P1/P2 alternatives, continuation checks, compute/focus quota, bottleneck lens. -6. no-progress self-repair: obey `autonomous_replan_obligation` or - `execution_obligation.must_attempt_work=true`; after 2 eligible stall - heartbeats with only status/brief checks, replan before quiet no-op. - Pause/delete only if repair stays stuck 2 more turns. +6. no-progress self-repair: follow `autonomous_replan_obligation` and + `replan_action_packet`; preserve valid work; reconcile ambiguous effects before + retry. No wake-count replan or self-pause; explicit/Core/host stops and original + settlement still apply. 7. {SCOPE_BOUNDED_WORK_RULE} {HOST_LOOP_SAFETY_RULE} 8. Validate; write files/validation/critic/next action to active state; @@ -696,7 +692,7 @@ def render_thin_heartbeat_task_body( Writeback needs outcome and vision; unchanged reason requires an existing current evidence-linked vision. Missing vision after writeback -> same-Turn checkpoint-context then evidence; stale -> reread. Done -> complete Todo; waiting -> monitor and -successor. Two stalls -> replan. P0 blocked -> safe P1/P2; quiet polls do not spend. +successor. P0 blocked -> safe P1/P2; quiet polls do not spend. `agent_read_required`: read/triage before work, settle/ACK. {reward_memory_rule} {policy_tail}""" @@ -717,7 +713,7 @@ def render_thin_heartbeat_task_body( {RUNTIME_CAPABILITY_PROJECTION_THIN_RULE} {SCHEDULER_HINT_THIN_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} -Done->todo/rationale; wait->monitor+successor/work; guard; 2 stalls->replan. +Done->todo/rationale; wait->monitor+successor/work; re-enter guard. `agent_read_required`: drain/read/triage before work; settle/ACK. P0 blocked: safe P1/P2; monitor quiet/no-spend. diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index d0726109fd..147e3aa618 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -298,9 +298,9 @@ class CliOutputCommandClassification: ), markdown_anchor="# Heartbeat Automation Prompt", max_chars={ - "small": {"json": 3_400, "markdown": 5_100}, - "crowded": {"json": 3_400, "markdown": 5_100}, - "multi_agent": {"json": 3_400, "markdown": 5_100}, + "small": {"json": 3_400, "markdown": 5_250}, + "crowded": {"json": 3_400, "markdown": 5_250}, + "multi_agent": {"json": 3_400, "markdown": 5_250}, }, max_lines={ "small": {"json": 18, "markdown": 72}, @@ -538,7 +538,7 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("task_body", "quota_guard_command", "interface_budget"), markdown_anchor="# Heartbeat Automation Prompt", - max_chars={"json": 13_500, "markdown": 11_500}, + max_chars={"json": 13_500, "markdown": 11_750}, max_lines={"json": 58, "markdown": 155}, ), CliOutputModeVariantSpec( diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index e0230216ea..c5b53539f9 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -757,6 +757,45 @@ def _authoring_input_migration( return limits, [reason + "; bounded one-time growth"] if limits else [] +def _continuation_instruction_migration( + base: dict[str, Any], candidate: dict[str, Any], +) -> tuple[dict[Metric, int], list[str]]: + """Qualify only reviewed instruction transitions on their actual carriers.""" + surface = str(base["row_id"]).split("/")[1] + before = base.get("continuation_instructions") or {} + after = candidate.get("continuation_instructions") or {} + + def migrated(clause: str) -> bool: + return (before.get(f"{clause}_before") is True + and before.get(f"{clause}_after") is False + and after.get(f"{clause}_before") is False + and after.get(f"{clause}_after") is True) + + chars = 0 + if surface in { + "quota_should_run", "loopx_turn_plan", "quota_should_run_all_detail", + "quota_should_run_goal_boundary_detail", "quota_should_run_scheduler_detail", + "quota_should_run_todo_summary_detail", "quota_should_run_turn_envelope", + "quota_should_run_user_todo_summary_detail", "quota_should_run_vision_detail", + }: + # Same-fixture context +208, with one character of receipt metadata + # variation; optional replan guidance +363. Do not loosen line budgets. + if migrated("context"): + chars += 224 + if migrated("replan"): + chars += 384 + elif surface in {"heartbeat_prompt_thin", "heartbeat_prompt_brief", "heartbeat_prompt_compact"}: + # Complete stop/correction rollout: thin +154, brief +163, compact +215. + if migrated("stop"): + chars = 224 + if not chars: + return {}, [] + return { + "chars": chars, "utf8_bytes": chars, "lines": 0, + "compact_payload_chars": chars if base.get("format") == "json" else 0, + }, ["reviewed continuation instructions migrated; bounded one-time growth"] + + def _compare_row_growth( base: dict[str, Any], candidate: dict[str, Any], *, migration: _SchemaMigrationState, @@ -777,6 +816,8 @@ def _compare_row_growth( review_signals.extend(fence_signals) authoring_allowance, authoring_signals = _authoring_input_migration(base, candidate) review_signals.extend(authoring_signals) + continuation_allowance, continuation_signals = _continuation_instruction_migration(base, candidate) + review_signals.extend(continuation_signals) deltas: dict[str, int | None] = {} allowances: dict[str, int | None] = {} for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): @@ -818,6 +859,7 @@ def _compare_row_growth( projection_allowance.get(metric, 0), fence_allowance.get(metric, 0), authoring_allowance.get(metric, 0), + continuation_allowance.get(metric, 0), ) # Thin installed prompts contain bilingual lifecycle instructions. A # small character-level clarification can cost three bytes per CJK diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index 494537766a..db9209990f 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -75,6 +75,78 @@ def heartbeat_user_language_prompt_revision(text: str) -> str | None: return "heartbeat_user_language_v1" if any(rule in text for rule in rules) else None +def continuation_instruction_observations(text: str) -> dict[str, bool]: + """Observe complete reviewed clauses for output migration, never admission. + + Keep historical text independent of production constants: a later wording + change must not silently inherit this one-time size qualification. + """ + context_prefix = ( + "Read current sources and pending required_reads before work. " + "Do not repeat this guard's fulfilled pre-work reads. " + ) + context_suffix = ( + "Recheck freshness before later actions; unavailable or changed sources need " + "recovery and a fresh guard. Context grants no authority." + ) + brief = ( + "Use a bounded working brief of evidenced progress/change, current blockers, next work, acceptance and stops. " + "Missing comparison evidence is unknown, not unchanged; summaries cannot replace full requirements. " + ) + correction = ( + "Correct within the affected work first; widen replanning only when current goal, path, dependency " + "or acceptance evidence warrants it. A due review can retain the path; it does not force a pivot. " + "At the next review, check whether the correction reached subsequent work and authoritative outcomes; " + "a recorded decision alone proves neither adoption nor improvement." + ) + replan = ( + "Compare expected goal/information gain with full marginal cost. " + "Before widening repeated validation, check that the changed intervention reaches the " + "executed path and current artifact. Prefer a probe that distinguishes remaining explanations; " + "scope negative results to tested conditions and local proxies to their coverage. " + "Reuse still-applicable evidence instead of repeating checks without a new uncertainty." + ) + stop = ( + "host_action=pause_or_delete_current_heartbeat: automation_update pause current " + "heartbeat once; verify stopped; unconfirmed/unavailable: never claim stopped; " + "end; no work/spend/retry/RRULE fallback." + ) + old_stops = ( + "host_action=pause_or_delete_current_heartbeat: automation_update stop; ", + "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend); ", + ) + # Decode JSON string escaping before observing exact clauses. For Markdown + # the emitted instruction is already readable. No runtime state is inferred. + try: + payload = json.loads(text) + except json.JSONDecodeError: + strings = [text] + else: + strings = [] + + def collect(value: Any) -> None: + if isinstance(value, str): + strings.append(value) + elif isinstance(value, dict): + for child in value.values(): + collect(child) + elif isinstance(value, list): + for child in value: + collect(child) + + collect(payload) + def contains(clause: str) -> bool: + return any(clause in value for value in strings) + return { + "context_before": contains(context_prefix + context_suffix), + "context_after": contains(context_prefix + brief + context_suffix), + "replan_before": contains(replan) and not contains(replan + " " + correction), + "replan_after": contains(replan + " " + correction), + "stop_before": any(contains(clause) for clause in old_stops), + "stop_after": contains(stop), + } + + def heartbeat_peer_admission_prompt_revision(text: str) -> str | None: """Attribute readable peer guidance for a one-time CLI budget transition. diff --git a/loopx/control_plane/work_items/interaction_contract.ts b/loopx/control_plane/work_items/interaction_contract.ts index f1c79ab7e2..f331b50742 100644 --- a/loopx/control_plane/work_items/interaction_contract.ts +++ b/loopx/control_plane/work_items/interaction_contract.ts @@ -426,5 +426,8 @@ export function projectInteractionWorkContext(request: JsonObject): JsonObject { ...(sources.length ? {sources} : {}), ...(unavailable ? {unavailable_context: unavailable} : {}), ...(includeUserTodos ? {user_todos: users} : {}), ...(failures.length ? {failures} : {}), - instruction: "Read current sources and pending required_reads before work. Do not repeat this guard's fulfilled pre-work reads. Recheck freshness before later actions; unavailable or changed sources need recovery and a fresh guard. Context grants no authority."}}; + instruction: "Read current sources and pending required_reads before work. Do not repeat this guard's fulfilled pre-work reads. " + + "Use a bounded working brief of evidenced progress/change, current blockers, next work, acceptance and stops. " + + "Missing comparison evidence is unknown, not unchanged; summaries cannot replace full requirements. " + + "Recheck freshness before later actions; unavailable or changed sources need recovery and a fresh guard. Context grants no authority."}}; } diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 262c014170..26ff4c4d67 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -49,7 +49,11 @@ const REPLAN_PLANNING_GUIDANCE = [ "Before widening repeated validation, check that the changed intervention reaches the " + "executed path and current artifact. Prefer a probe that distinguishes remaining explanations; " + "scope negative results to tested conditions and local proxies to their coverage. " + - "Reuse still-applicable evidence instead of repeating checks without a new uncertainty.", + "Reuse still-applicable evidence instead of repeating checks without a new uncertainty. " + + "Correct within the affected work first; widen replanning only when current goal, path, dependency " + + "or acceptance evidence warrants it. A due review can retain the path; it does not force a pivot. " + + "At the next review, check whether the correction reached subsequent work and authoritative outcomes; " + + "a recorded decision alone proves neither adoption nor improvement.", ]; function object(value: unknown): JsonObject { diff --git a/loopx/opencode2_goal_mode/README.md b/loopx/opencode2_goal_mode/README.md index 90dca06a6b..895ed73442 100644 --- a/loopx/opencode2_goal_mode/README.md +++ b/loopx/opencode2_goal_mode/README.md @@ -42,20 +42,33 @@ loopx opencode2-goal-worker \ The loop is: ```text -prompt session -> wait for the assistant turn to complete - -> loopx quota should-run (generic_cli profile, host poll receipt) - -> run_now: continue with a quota-gated continuation prompt +loopx quota should-run (generic_cli profile, host poll receipt) + -> admitted run_now: initial task or continuation -> await turn completion -> wait: backoff ladder, no model calls during quiet waits - -> terminal_no_followup: visible closure notice, then standby polling - (the worker stays attached and resumes automatically when new work - appears instead of pausing after one task) + -> hard stop / validated terminal_no_followup: persist pause, worker exits -> unchanged-poll limit: visible synthetic pause notice, worker exits - -> goal_not_found: the worker exits; the goal is no longer registered + -> goal_not_found: worker exits ``` -User messages never pause the loop. The model answers the message and the -worker keeps gating continuation through quota as usual. A stalled session, -turn wait budget, turn budget, or duration budget still pauses visibly. +The first task now requires admission too. Validated terminal closure ends +polling instead of entering the previous five-minute standby loop. Ordinary +external waits retain backoff; they do not grant execution. An unstarted task +body is retained privately across restart so resume does not replace the original +objective with a generic continuation. + +Stop the Goal through its existing Goal lifecycle controls (or set compute to +zero) to prevent the next dispatch. Resume the Core owner, then rerun the same +worker command with `--force-resume`; this only re-arms the worker and still +requires fresh Core admission. Read back `loopx quota should-run --goal-id + --agent-id ` and the worker result (`paused_state` when still +stopped). Preserve the original `--task-body` for legacy unstarted bindings. + +Ordinary messages do not grant resume. The worker does not infer user intent +from message text. Its inactivity hold observes an outstanding host turn and +is not confirmation of explicit user pause. Core stop is checked before new +dispatch; it does not cancel an already-submitted host turn. Use the host's own +interrupt control for that, and reconcile the original effects/settlement. +Stall, turn wait, turn and duration budgets retain their existing limits. ## State diff --git a/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs b/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs index d3c08d1ee9..bdf20f1557 100644 --- a/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs +++ b/loopx/opencode2_goal_mode/opencode2-goal-worker.mjs @@ -53,12 +53,6 @@ export function continuationPrompt(directory) { export const CONTINUATION_PROMPT = continuationPrompt() -export const STANDBY_MINUTES = 5 - -const TERMINAL_NOTICE = [ - "LoopX validated terminal closure for the current work.", - "The worker stays attached and will resume automatically when new work appears.", -].join(" ") const LIMIT_NOTICE = [ "LoopX stopped this goal after the unchanged-poll limit.", "No new work was scheduled; review LoopX todos and resume explicitly when ready.", @@ -225,10 +219,9 @@ export function isTerminalNoFollowup(decision) { export function shouldRunNow(decision) { - // The scheduler hint is authoritative: a should_run=true decision that the - // scheduler classifies as a backoff (for example waiting for the user) - // must not prompt the model. - return decision?.scheduler_hint?.action === "run_now" + // Scheduling never grants execution when Core denies admission. + return decision?.ok !== false && decision?.should_run === true && + decision?.scheduler_hint?.action === "run_now" } @@ -619,6 +612,7 @@ async function runWithLease({ ? availableCapabilities.map(String) : [], sessionID: sessionId ? String(sessionId) : "", + taskBody: taskBody || "", autoResume: true, phase: "starting", schedulerToken: "", @@ -671,18 +665,6 @@ async function runWithLease({ return { kind: "session_deleted", sessionID } } - if (state.turnCount === 0 && taskBody) { - await transport.sendPrompt(sessionID, taskBody) - state = await stateStore.write(goalId, { - ...state, - turnCount: 1, - pendingTurn: true, - lastPromptAt: Date.now(), - pendingTurnActivityAt: Date.now(), - phase: "working", - }) - await log("info", "prompted the session with the task body", { goalId, sessionID }) - } while (true) { if (Date.now() - startedAt > maxDurationMs) { @@ -782,12 +764,18 @@ async function runWithLease({ let decision try { decision = await quotaProbe(state, { directory: directoryNow }) + const current = await stateStore.read(goalId) + if (!current || current.autoResume === false) return { kind: "paused_state" } + state = current state = await stateStore.write(goalId, { ...state, probeRetryCount: 0, lastPollAt: new Date().toISOString(), }) } catch (error) { + const current = await stateStore.read(goalId) + if (!current || current.autoResume === false) return { kind: "paused_state" } + state = current const plan = retryPlan(decision, state) state = await stateStore.write(goalId, { ...state, @@ -815,32 +803,19 @@ async function runWithLease({ return { kind: "goal_gone" } } - if (isTerminalNoFollowup(decision)) { - // Validated closure means the CURRENT work is done, not that the loop - // should end. Stand by and keep polling so new work resumes the - // session automatically instead of pausing after one task. - const standbyPolls = Number(state.standbyPolls || 0) + 1 + const stopped = decision?.scheduler_hint?.action === "stop_until_explicit_resume" + if (stopped || isTerminalNoFollowup(decision)) { state = await stateStore.write(goalId, { ...state, - phase: "standby", - schedulerToken: "", - unchangedPolls: 0, - standbyPolls, - }) - if (standbyPolls === 1) { - await transport.sendSynthetic(sessionID, TERMINAL_NOTICE) - } - await log("info", "LoopX validated terminal closure; standing by for new work", { - goalId, - sessionID, - standbyPolls, + autoResume: false, + phase: "paused", + pausedReason: stopped ? "stop_until_explicit_resume" : "terminal_no_followup", }) - await sleepImpl(STANDBY_MINUTES * 60_000) - continue + await log("info", "Core stopped automatic continuation; explicit resume requires fresh admission", { goalId, sessionID }) + return { kind: "paused_state" } } if (shouldRunNow(decision)) { - const wasStandby = state.phase === "standby" state = await stateStore.write(goalId, { ...state, phase: "working", @@ -848,10 +823,13 @@ async function runWithLease({ unchangedPolls: 0, standbyPolls: 0, }) - if (wasStandby) { - await log("info", "new work appeared; resuming from standby", { goalId, sessionID }) + const initialBody = state.taskBody || taskBody + // A paused initial dispatch must retain its original objective across restart. + // Legacy bindings may supply it explicitly; never substitute a generic continue. + if (state.turnCount === 0 && !initialBody && !sessionId) { + throw new Error("unstarted worker requires its original --task-body or an existing --session-id") } - await transport.sendPrompt(sessionID, continuationPrompt(directoryNow)) + await transport.sendPrompt(sessionID, state.turnCount === 0 && initialBody ? initialBody : continuationPrompt(directoryNow)) state = await stateStore.write(goalId, { ...state, pendingTurn: true, diff --git a/loopx/opencode_goal_mode/README.md b/loopx/opencode_goal_mode/README.md index 384b9139e3..f23446f381 100644 --- a/loopx/opencode_goal_mode/README.md +++ b/loopx/opencode_goal_mode/README.md @@ -43,18 +43,29 @@ Run `/loopx `, then activate the returned host packet through OpenCode idle event -> loopx quota should-run -> run_now: continue once -> wait: schedule a bounded local recheck without a model call - -> user input: answer it, resume the goal, keep gating through quota + -> user input: answer it; automatic native resume requires fresh Core admission + -> explicit pause / permission denial: stop automatic continuation -> unchanged-poll limit: pause the visible goal and stop the timer - -> terminal_no_followup: standby polling instead of completing; new work - resumes the goal automatically + -> terminal_no_followup: stop the timer and disarm continuation ``` -User messages and permission replies never pause the loop. The bridge stops -scheduled timer wakes while the user's message is being answered, probes a -goal resume on the next idle event, and continues quota-gated operation as -usual. Validated terminal closure no longer completes the goal: the bridge -keeps the goal active in standby and re-checks quota every five minutes, so -finishing one task does not end the loop. +Use `/goal pause` to pause this session and `/goal resume` to re-arm it. Core +Goal stop or zero compute must also be resolved through its owning Goal/quota +settings; host resume does not grant work. Idle events and ordinary chat do not +clear an explicit pause. Permission denial or host errors also disarm the bridge. +An interrupted tool's result/settlement remains the original operation's duty. +The bridge disarms its local continuation before awaiting a pause/block tool or +pause-command hook. A slow or failed native stop cannot admit another idle +continuation. Native errors remain visible; local disarming is not proof that +the host cancelled an already-submitted request. + +This changes the previous default for validated terminal closure: the bridge +stops rather than polling every five minutes. A recurring Goal that still has +future work should project the existing wait/monitor contract, not terminal +no-follow-up. Pause→resume invalidates an earlier in-flight quota result; +subsequent continuation requires a new probe. Read back host `goal_status` and +`loopx quota should-run --goal-id --agent-id ` separately: +host state alone is not Core admission. Bindings are private per-session JSON files under `$LOOPX_OPENCODE_STATE_DIR`, or `$XDG_STATE_HOME/loopx/opencode` by default, @@ -69,12 +80,12 @@ whose bridge died mid-wait. Probe failures retry with a bounded exponential backoff (3 minutes up to 30) and reset on success. A per-session lock file prevents two OpenCode processes from evaluating the same session at once. -A user message sets `userMessagePending` in the binding so the next idle -evaluation resumes the wrapped goal (which may pause itself on user input) -before continuing quota gating; explicit pause tools still record a typed -`lastPausedReason` and `/goal resume` clears it. The unchanged-poll limit no -longer stops the timer silently: the bridge pauses the visible OpenCode goal -so the stop is visible in the session and in LoopX state. +A user message sets `userMessagePending` so the next admitted idle evaluation +can resume the wrapped goal, which may pause itself while answering. This does +not clear `autoResume=false`. Local binding writes are serialized with lifecycle +revision checks; a stale probe cannot overwrite a newer pause/resume transition. +The unchanged-poll limit pauses the visible goal rather than silently dropping +the timer. Host stop failure is not reported as confirmed native cancellation. The wrapped goal plugin also persists private restart state under the active project's `.opencode/goals/` directory. Add `.opencode/goals/` to the project diff --git a/loopx/opencode_goal_mode/goal-bridge-runtime.mjs b/loopx/opencode_goal_mode/goal-bridge-runtime.mjs index c360b8fbf5..d4bfcaccd2 100644 --- a/loopx/opencode_goal_mode/goal-bridge-runtime.mjs +++ b/loopx/opencode_goal_mode/goal-bridge-runtime.mjs @@ -12,7 +12,6 @@ const TERMINAL_STATE_SCHEMA_VERSION = "goal_terminal_state_v0" const SOURCE_COMPLETENESS_SCHEMA_VERSION = "goal_terminal_source_completeness_v0" const DEFAULT_RETRY_MINUTES = 3 const MAX_RETRY_MINUTES = 30 -const STANDBY_MINUTES = 5 const BINDING_LOCK_STALE_MS = 90_000 const LOOPX_GOAL_LIMITS = { maxTurns: 10000, @@ -282,7 +281,8 @@ export function isTerminalNoFollowup(decision) { function shouldRunNow(decision) { - return decision?.scheduler_hint?.action === "run_now" || decision?.should_run === true + return decision?.ok !== false && decision?.should_run === true && + decision?.scheduler_hint?.action !== "stop_until_explicit_resume" } @@ -356,6 +356,20 @@ export function createLoopxGoalPlugin({ const timers = new Map() const evaluations = new Map() const pendingGoalCommands = new Map() + // Fence in-flight probes across pause/resume, including an ABA transition. + // Writes share one queue so an older snapshot cannot overwrite a pause. + const revisions = new Map() + const bindingWrites = new Map() + const revision = id => revisions.get(id) || 0 + const invalidate = id => revisions.set(id, revision(id) + 1) + const serializeBinding = (id, operation) => { + const previous = bindingWrites.get(id) || Promise.resolve() + const next = previous.catch(() => {}).then(operation) + bindingWrites.set(id, next) + return next.finally(() => { + if (bindingWrites.get(id) === next) bindingWrites.delete(id) + }) + } // Tracks sessions whose persisted base goal is already active in this process. const initializedSessions = new Set() let disposed = false @@ -377,16 +391,21 @@ export function createLoopxGoalPlugin({ } const detachBinding = async (sessionID) => { - await bindingStore.remove(sessionID) + invalidate(sessionID) + await serializeBinding(sessionID, () => bindingStore.remove(sessionID)) cancelScheduled(sessionID) initializedSessions.delete(sessionID) } const readBinding = async (sessionID) => bindingStore.read(sessionID) - const updateBinding = async (sessionID, changes) => { - const current = await readBinding(sessionID) - if (!current) return null - return bindingStore.write(sessionID, { ...current, ...changes }) + const updateBinding = (sessionID, changes, expectedRevision) => { + if (Object.hasOwn(changes, "autoResume") && expectedRevision === undefined) invalidate(sessionID) + return serializeBinding(sessionID, async () => { + if (expectedRevision !== undefined && revision(sessionID) !== expectedRevision) return null + const current = await readBinding(sessionID) + if (!current || (expectedRevision !== undefined && revision(sessionID) !== expectedRevision)) return null + return bindingStore.write(sessionID, { ...current, ...changes }) + }) } const auditor = async ({ sessionID }) => { @@ -421,6 +440,7 @@ export function createLoopxGoalPlugin({ if (disposed) return cancelScheduled(sessionID) const timer = setTimer(async () => { + if (timers.get(sessionID) !== timer) return timers.delete(sessionID) if (disposed) return try { @@ -440,6 +460,7 @@ export function createLoopxGoalPlugin({ const evaluateIdleOnce = async (sessionID, input) => { if (disposed) return + const expectedRevision = revision(sessionID) let binding try { binding = await readBinding(sessionID) @@ -471,55 +492,41 @@ export function createLoopxGoalPlugin({ return } try { - await evaluateIdleLocked(sessionID, input, binding) + await evaluateIdleLocked(sessionID, input, binding, expectedRevision) } finally { await lock.release() } } - const evaluateIdleLocked = async (sessionID, input, initialBinding) => { + const evaluateIdleLocked = async (sessionID, input, initialBinding, expectedRevision) => { + const current = () => !disposed && revision(sessionID) === expectedRevision + if (!current()) return let binding = initialBinding - if (binding.userMessagePending) { - await bindingStore.write(sessionID, { ...binding, userMessagePending: false }) - if (typeof baseGoalResume?.execute === "function") { - try { - await executeTool(baseGoalResume, {}, toolContext(context, sessionID)) - await log("info", "resumed the OpenCode goal after user input", { - goalId: binding.goalId, - sessionID, - }) - } catch (error) { - await log("warn", "OpenCode goal resume probe failed after user input", { - goalId: binding.goalId, - sessionID, - error: error?.message || String(error), - }) - } - } - } let decision try { decision = await quotaProbe(binding, { directory: context.directory }) + if (!current()) return if (Number(binding.probeRetryCount || 0) > 0) { - await updateBinding(sessionID, { probeRetryCount: 0 }) + await updateBinding(sessionID, { probeRetryCount: 0 }, expectedRevision) } } catch (error) { + if (!current()) return const plan = probeRetryPlan(binding) - await updateBinding(sessionID, { probeRetryCount: plan.probeRetryCount }) + await updateBinding(sessionID, { probeRetryCount: plan.probeRetryCount }, expectedRevision) await log("warn", "LoopX quota probe failed closed; scheduling a bounded retry", { goalId: binding.goalId, sessionID, error: error?.message || String(error), retryMinutes: plan.minutes, }) - scheduleEvaluation(sessionID, plan.minutes) + if (current()) scheduleEvaluation(sessionID, plan.minutes) return } - if (disposed) return + if (!current()) return const currentBinding = await readBinding(sessionID) if ( - !currentBinding || + !current() || !currentBinding || currentBinding.autoResume === false || currentBinding.goalId !== binding.goalId ) { @@ -528,53 +535,51 @@ export function createLoopxGoalPlugin({ } binding = currentBinding - if (isTerminalNoFollowup(decision)) { - // Validated closure means the current work is done, not that the loop - // should end. Stand by and keep the goal active so new work resumes - // automatically instead of pausing after one task. + const stopped = decision?.scheduler_hint?.action === "stop_until_explicit_resume" + if (stopped || isTerminalNoFollowup(decision)) { cancelScheduled(sessionID) - const standbyPolls = Number(binding.standbyPolls || 0) + 1 - await bindingStore.write(sessionID, { - ...binding, - phase: "standby", - standbyPolls, - schedulerToken: "", - unchangedPolls: 0, - }) - await log("info", "LoopX validated terminal closure; standing by for new work", { - goalId: binding.goalId, - sessionID, - standbyPolls, - }) - scheduleEvaluation(sessionID, STANDBY_MINUTES) + await updateBinding(sessionID, { + autoResume: false, + userMessagePending: false, + lastPausedReason: stopped ? "stop_until_explicit_resume" : "terminal_no_followup", + }, expectedRevision) + if (!current()) return + if (typeof baseGoalPause?.execute === "function") { + await executeTool(baseGoalPause, {}, toolContext(context, sessionID)) + } return } if (shouldRunNow(decision)) { cancelScheduled(sessionID) let pendingResume = Boolean(binding.userMessagePending) - await bindingStore.write(sessionID, { - ...binding, + const committed = await updateBinding(sessionID, { phase: "working", standbyPolls: 0, schedulerToken: "", unchangedPolls: 0, probeRetryCount: 0, userMessagePending: false, - }) + }, expectedRevision) + if (!committed || !current()) return if (pendingResume || !initializedSessions.has(sessionID)) { - initializedSessions.add(sessionID) try { - await executeTool(baseGoalResume, {}, toolContext(context, sessionID)) + const resumed = await executeTool(baseGoalResume, {}, toolContext(context, sessionID)) + if (!toolResultSucceeded(resumed)) throw new Error("native Goal resume was not confirmed") + if (!current()) return + initializedSessions.add(sessionID) } catch (error) { + if (!current()) return + await updateBinding(sessionID, { autoResume: false, lastPausedReason: "goal_resume_unconfirmed" }, expectedRevision) await log("warn", "OpenCode goal resume probe failed before continuation", { goalId: binding.goalId, sessionID, error: error?.message || String(error), }) + return } } - if (disposed) return + if (!current()) return await baseEvent?.(input) return } @@ -585,7 +590,8 @@ export function createLoopxGoalPlugin({ await updateBinding(sessionID, { autoResume: false, lastPausedReason: "unchanged_poll_limit", - }) + }, expectedRevision) + if (!current()) return if (typeof baseGoalPause?.execute === "function") { try { await executeTool(baseGoalPause, {}, toolContext(context, sessionID)) @@ -608,11 +614,11 @@ export function createLoopxGoalPlugin({ } return } - await bindingStore.write(sessionID, { - ...binding, + const committed = await updateBinding(sessionID, { schedulerToken: wait.schedulerToken, unchangedPolls: wait.unchangedPolls, - }) + }, expectedRevision) + if (!committed || !current()) return scheduleEvaluation(sessionID, wait.minutes) } @@ -661,7 +667,8 @@ export function createLoopxGoalPlugin({ toolCtx, ) if (!toolResultSucceeded(result)) return result - const binding = await bindingStore.write(sessionID, { + invalidate(sessionID) + const binding = await serializeBinding(sessionID, () => bindingStore.write(sessionID, { directory: context.directory, goalId: String(args.goalId), agentId: args.agentId ? String(args.agentId) : "", @@ -672,7 +679,7 @@ export function createLoopxGoalPlugin({ autoResume: true, schedulerToken: "", unchangedPolls: 0, - }) + })) initializedSessions.add(sessionID) cancelScheduled(sessionID) return JSON.stringify({ @@ -687,15 +694,22 @@ export function createLoopxGoalPlugin({ const tools = { ...baseTools, loopx_goal_activate: activateTool } - const wrapTool = (name, after) => { + const pauseBinding = async (sessionID, reason) => { + cancelScheduled(sessionID) + await updateBinding(sessionID, { autoResume: false, lastPausedReason: reason }) + } + const wrapTool = (name, after, before) => { const original = tools[name] if (!original || typeof original.execute !== "function") return tools[name] = { ...original, execute: async (args, toolCtx) => { - const result = await original.execute(args, toolCtx) const sessionID = toolCtx?.sessionID || toolCtx?.session_id || toolCtx?.session?.id - if (sessionID) await after(sessionID, args || {}, result) + // Local stop intent fences pending admission before waiting for host IO. + // Host failure remains a failure; it must not re-arm our own driver. + if (sessionID) await before?.(sessionID, args || {}) + const result = await original.execute(args, toolCtx) + if (sessionID) await after?.(sessionID, args || {}, result) return result }, } @@ -720,15 +734,7 @@ export function createLoopxGoalPlugin({ } }) for (const name of ["goal_pause", "goal_block"]) { - wrapTool(name, async (sessionID, _args, result) => { - if (toolResultSucceeded(result)) { - await updateBinding(sessionID, { - autoResume: false, - lastPausedReason: name === "goal_pause" ? "goal_pause" : "goal_block", - }) - cancelScheduled(sessionID) - } - }) + wrapTool(name, null, sessionID => pauseBinding(sessionID, name)) } wrapTool("update_goal", async (sessionID, args, result) => { if ( @@ -747,16 +753,15 @@ export function createLoopxGoalPlugin({ const status = String(args.status || "").trim().toLowerCase() if (status === "complete") { await detachBinding(sessionID) - } else if (status === "paused" || status === "blocked") { - await updateBinding(sessionID, { - autoResume: false, - lastPausedReason: status === "paused" ? "goal_status_paused" : "goal_status_blocked", - }) - cancelScheduled(sessionID) } else if (status === "resumed") { await updateBinding(sessionID, { autoResume: true, lastPausedReason: "" }) initializedSessions.add(sessionID) } + }, async (sessionID, args) => { + const status = String(args.status || "").trim().toLowerCase() + if (status === "paused" || status === "blocked") { + await pauseBinding(sessionID, status === "paused" ? "goal_status_paused" : "goal_status_blocked") + } }) return { @@ -774,6 +779,9 @@ export function createLoopxGoalPlugin({ await base.config?.(config) }, "command.execute.before": async (input, output) => { + if (input?.command === "goal" && input?.sessionID && commandKind(String(input.arguments || "").trim()) === "pause") { + await pauseBinding(input.sessionID, "goal_pause") + } await baseCommand?.(input, output) if (input?.command !== "goal" || !input?.sessionID) return const sessionID = input.sessionID @@ -785,12 +793,6 @@ export function createLoopxGoalPlugin({ } else if (kind === "resume") { await updateBinding(sessionID, { autoResume: true, lastPausedReason: "" }) initializedSessions.add(sessionID) - } else if (kind === "pause") { - await updateBinding(sessionID, { - autoResume: false, - lastPausedReason: "goal_pause", - }) - cancelScheduled(sessionID) } }, "chat.message": async (input, output) => { @@ -818,10 +820,7 @@ export function createLoopxGoalPlugin({ // so it cannot fire mid-answer, and remember that the goal needs a // resume probe on the next idle event (the wrapped goal plugin may // pause its goal when the user chats). - await bindingStore.write(sessionID, { - ...binding, - userMessagePending: true, - }) + await updateBinding(sessionID, { userMessagePending: true }) cancelScheduled(sessionID) } await baseChatMessage?.(input, output) @@ -835,7 +834,9 @@ export function createLoopxGoalPlugin({ pendingGoalCommands.delete(sessionID) } else if (sessionID && shouldPauseForHostEvent(event)) { await updateBinding(sessionID, { - userMessagePending: true, + autoResume: false, + lastPausedReason: event.type, + userMessagePending: false, }) cancelScheduled(sessionID) } diff --git a/loopx/pi_goal_mode/README.md b/loopx/pi_goal_mode/README.md index 6e666880ca..c6b752b8d5 100644 --- a/loopx/pi_goal_mode/README.md +++ b/loopx/pi_goal_mode/README.md @@ -155,3 +155,15 @@ is atomically disposed: every timer is cancelled and an in-flight quota probe that returns afterwards stops at the disposed guard, so the old session can never inject a follow-up or reschedule past the reload / session-replacement boundary. + +## Pause and resume boundaries + +An explicit host abort or Core `stop_until_explicit_resume` disarms automatic +continuation and cancels its timer. Idle events and ordinary prompts do not +re-arm it. Resolve the Core Goal/quota stop, then use `/loopx resume`; the next +idle evaluation must obtain fresh admission. Pause/resume advances the existing +binding generation, so a late pre-pause result cannot send a task afterward. +Use `/loopx status` and `loopx quota should-run --goal-id --agent-id +` to distinguish host binding from Core permission. Validated +no-follow-up stops; dependency waits retain bounded checks without model calls. +Already-started tools and original settlement are not cancelled by this gate. diff --git a/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs b/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs index 3ee2422ba6..04c49480f7 100644 --- a/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs +++ b/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs @@ -695,7 +695,8 @@ export function isTerminalNoFollowup(decision) { export function shouldRunNow(decision) { const hint = decision?.scheduler_hint - return hint?.action === "run_now" || decision?.should_run === true + return decision?.ok !== false && decision?.should_run === true && + hint?.action !== "stop_until_explicit_resume" } export function waitPlan(decision, binding) { @@ -810,6 +811,7 @@ export function createGoalLoop(options) { cancelScheduled(key) const scheduledEpoch = epoch const timer = setTimer(async () => { + if (timers.get(key) !== timer) return timers.delete(key) if (disposed || epoch !== scheduledEpoch) return try { @@ -885,6 +887,13 @@ export function createGoalLoop(options) { } const applyDecision = async (nextDecision, allowFinalCheck = true) => { + if (nextDecision?.scheduler_hint?.action === "stop_until_explicit_resume" && !isTerminalNoFollowup(nextDecision)) { + const committed = await store.write(key, { autoResume: false }, expected) + if (!committed || disposed || epoch !== instanceEpoch) return + cancelScheduled(key) + services.notify(`LoopX goal ${current.goalId} paused; resolve its Core stop condition, then run /loopx resume.`, "info") + return + } if (isTerminalNoFollowup(nextDecision)) { // Commit through the store's compare-and-swap: if the same session // activated a new goal while this write was in-flight, the commit is @@ -1088,7 +1097,7 @@ export function createGoalLoop(options) { if (!binding || binding.terminal) return if (String(prompt || "") !== binding.lastInjectedPrompt) { const expected = { generation: binding.generation || 0, goalId: binding.goalId } - const committed = await services.store.write(key, { autoResume: false }, expected) + const committed = await services.store.write(key, { autoResume: false, generation: (binding.generation || 0) + 1 }, expected) if (!committed) return if (disposed || epoch !== instanceEpoch) return cancelScheduled(key) @@ -1108,7 +1117,7 @@ export function createGoalLoop(options) { if (disposed || epoch !== instanceEpoch) return null if (!binding || binding.terminal) return binding const expected = { generation: binding.generation || 0, goalId: binding.goalId } - const committed = await services.store.write(key, { autoResume: false }, expected) + const committed = await services.store.write(key, { autoResume: false, generation: (binding.generation || 0) + 1 }, expected) if (!committed || disposed || epoch !== instanceEpoch) return committed cancelScheduled(key) services.notify( @@ -1121,7 +1130,12 @@ export function createGoalLoop(options) { if (disposed) return null const services = contexts.get(key) if (!services) return null - const binding = await services.store.write(key, { autoResume: true, terminal: false }) + const current = await services.store.read(key) + if (!current || disposed) return null + const binding = await services.store.write(key, + { autoResume: true, terminal: false, generation: (current.generation || 0) + 1 }, + { generation: current.generation || 0, goalId: current.goalId }) + if (!binding) return null if (disposed) return null cancelScheduled(key) services.notify(`LoopX goal ${binding.goalId} auto-continuation resumed.`, "info") diff --git a/scripts/external_scheduler_worker.py b/scripts/external_scheduler_worker.py index 1a5355bea0..480bbf1d2f 100644 --- a/scripts/external_scheduler_worker.py +++ b/scripts/external_scheduler_worker.py @@ -101,14 +101,14 @@ def parse_tick(payload: dict[str, Any]) -> TickDecision: action = str(hint.get("action") or "").strip() cadence_class = str(hint.get("cadence_class") or "").strip() reason = str(hint.get("reason") or payload.get("state") or "").strip() - should_run = bool(payload.get("should_run")) + should_run = payload.get("should_run") if _local_scheduler_directive(hint) == LOCAL_SCHEDULER_STOP_DIRECTIVE: # Terminal packets intentionally omit cold-path cadence detail: no # further wake is legal, so an interval cannot affect the decision. # The producer-owned directive covers every stop action without a # second consumer-side action vocabulary. return TickDecision( - should_run=should_run, + should_run=False, action=action, cadence_class=cadence_class, reason=reason, @@ -121,6 +121,12 @@ def parse_tick(payload: dict[str, Any]) -> TickDecision: reset_token=_extract_reset_token(payload), terminal=True, ) + # Consume the Core boolean, never Python truthiness (e.g. "false" or 1). + # The terminal directive above wins even over a malformed admission value. + if payload.get("ok") is False: + raise ValueError("quota decision failed; refusing model wake") + if not isinstance(should_run, bool): + raise ValueError("quota should_run must be a boolean; refusing model wake") local = _extract_local_scheduler(payload) progression_values = local.get("example_progression_minutes") diff --git a/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index 4f0ab76188..c5099c9e91 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -642,8 +642,9 @@ refresh state if needed. Already mapped goals should follow owner evidence, agent todo, stale source, or safe handoff, return a quiet no-op without another dry-run, file edit, or quota spend. -The generated task body also carries a no-progress self-repair guard. More -importantly, `quota should-run` may expose a hard +The generated task body also carries a no-progress self-repair guard. Its +thresholds and obligations come from Core, not counts invented by the prompt. +`quota should-run` may expose a hard `autonomous_replan_obligation` / `execution_obligation.must_attempt_work=true` contract when active state or public run history shows repeated typed no-progress turns. Quiet future-monitor heartbeat receipts are liveness evidence only; @@ -652,10 +653,15 @@ concrete Todo or target identity at its declared threshold. Obey that machine contract before another quiet no-op: run one bounded self-repair/replan batch through implementation, validation, and writeback when that boundary is clear, then spend once. Do not stop at the first -tiny substep when the repair has an obvious validation boundary. Cancel or -pause the heartbeat automation only after 2 consecutive stalled turns (the same -repair path stuck for 2 more eligible turns); explain the no-progress loop with -`NOTIFY`, and skip quota spend for that self-cancel turn. +tiny substep when the repair has an obvious validation boundary. Correct the +affected work first; broader replanning needs current goal, path, dependency or +acceptance evidence. A due review can retain the path. Check subsequent work +and authoritative outcomes before claiming that a correction helped. Wake +counts alone never authorize replan or self-pause. Follow explicit stops and +current Core/host stop boundaries, preserve owed settlement, and use the current +notification policy. Form a bounded working brief from current sources: +evidenced progress/change, blockers, next work, acceptance and stops. Missing +comparison evidence is unknown; a summary cannot replace full requirements. The same generated task body also makes routine public commit, push, and PR creation autonomous after validation plus a clean public/private boundary scan. Do not reintroduce a user gate for public-safe publication itself. diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index e6bb62d149..31524113c7 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -99,6 +99,72 @@ def test_authoring_growth_is_bounded_one_time_and_surface_scoped( assert _compare_row(base, inflated)["failures"] +@pytest.mark.parametrize("surface,fmt,clauses,growth", [ + ("quota_should_run", "json", ["context"], 208), + ("loopx_turn_plan", "json", ["context"], 209), + ("quota_should_run", "json", ["context", "replan"], 571), + ("quota_should_run_vision_detail", "markdown", ["context"], 208), + ("heartbeat_prompt_compact", "markdown", ["stop"], 215), + ("heartbeat_prompt_brief", "markdown", ["stop"], 163), + ("heartbeat_prompt_thin", "json", ["stop"], 154), +]) +def test_continuation_growth_requires_observed_transition_and_bounds(surface, fmt, clauses, growth): + from loopx.control_plane.testing.cli_output_differential import _compare_row + + old = {key: value for clause in clauses + for key, value in [(f"{clause}_before", True), (f"{clause}_after", False)]} + new = {key: not value for key, value in old.items()} + base = _row(row_id=f"surface/{surface}/small/{fmt}", format=fmt, + chars=1000, utf8_bytes=1000, lines=20, compact_payload_chars=800, + continuation_instructions=old) + candidate = {**base, "continuation_instructions": new, + "chars": 1000 + growth, "utf8_bytes": 1000 + growth, + "compact_payload_chars": 800 + growth if fmt == "json" else 800} + result = _compare_row(base, candidate) + assert not result["failures"] + assert result["review_signals"] + for observations in [new, {}, {**old, f"{clauses[0]}_after": True}]: + assert _compare_row({**base, "continuation_instructions": observations}, candidate)["failures"] + for observations in [old, {}, {**new, f"{clauses[0]}_before": True}]: + assert _compare_row(base, {**candidate, "continuation_instructions": observations})["failures"] + assert _compare_row( + {**base, "row_id": f"surface/status/small/{fmt}"}, + {**candidate, "row_id": f"surface/status/small/{fmt}"}, + )["failures"] + for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): + assert _compare_row(base, {**candidate, metric: base[metric] + result["allowances"][metric] + 1})["failures"] + + +def test_continuation_observation_retains_every_stop_and_context_clause(): + from loopx.control_plane.testing.cli_output_semantics import continuation_instruction_observations + + stop = ( + "host_action=pause_or_delete_current_heartbeat: automation_update pause current " + "heartbeat once; verify stopped; unconfirmed/unavailable: never claim stopped; " + "end; no work/spend/retry/RRULE fallback." + ) + prefix = ("Read current sources and pending required_reads before work. " + "Do not repeat this guard's fulfilled pre-work reads. ") + suffix = ("Recheck freshness before later actions; unavailable or changed sources need " + "recovery and a fresh guard. Context grants no authority.") + brief = ("Use a bounded working brief of evidenced progress/change, current blockers, next work, acceptance and stops. " + "Missing comparison evidence is unknown, not unchanged; summaries cannot replace full requirements. ") + for render in [lambda s: s, lambda s: json.dumps({"task_body": s})]: + assert continuation_instruction_observations(render(stop))["stop_after"] + for clause in ["current heartbeat", "once; ", "verify stopped; ", "never claim stopped; ", + "end; ", "work/", "spend/", "retry/", "RRULE fallback"]: + assert not continuation_instruction_observations(render(stop.replace(clause, "")))["stop_after"] + old = continuation_instruction_observations(render(prefix + suffix)) + new = continuation_instruction_observations(render(prefix + brief + suffix)) + assert old["context_before"] and not old["context_after"] + assert new["context_after"] and not new["context_before"] + for clause in ["pending required_reads", "Do not repeat", "evidenced progress/change", "current blockers", + "acceptance and stops", "unknown, not unchanged", "cannot replace full requirements", + "fresh guard", "grants no authority"]: + assert not continuation_instruction_observations( + render((prefix + brief + suffix).replace(clause, "")))["context_after"] + + def test_authoring_observations_require_complete_structured_or_rendered_guidance(): from loopx.control_plane.testing.cli_output_semantics import authoring_input_observations diff --git a/tests/control_plane/test_heartbeat_agent_input.py b/tests/control_plane/test_heartbeat_agent_input.py index 2c04b00abf..4a87194bd5 100644 --- a/tests/control_plane/test_heartbeat_agent_input.py +++ b/tests/control_plane/test_heartbeat_agent_input.py @@ -14,6 +14,17 @@ ) +@pytest.mark.parametrize("mode", ["full", "compact", "brief", "thin"]) +def test_heartbeat_correction_consumes_core_without_inventing_stall_or_stop_thresholds(mode): + body = build_heartbeat_prompt(goal_id="correction-boundary", agent_id="agent-a", + runtime_profile="codex_app_heartbeat", **{mode: True})["task_body"] + assert "obey projected replan/stop, never wake counts" in body + assert "agent_channel.work_context" in body + assert "host_action=pause_or_delete_current_heartbeat" in body + for obsolete in ("2 consecutive eligible", "2 more", "Two stalls ->", "2 stalls->", "list at least three"): + assert obsolete not in body + + @pytest.mark.parametrize("mode", ["full", "compact", "brief", "thin"]) @pytest.mark.parametrize("agents", [["agent-a"], ["agent-a", "agent-b"]]) def test_peer_prompt_defers_workspace_and_lease_requirements_to_current_contract(mode, agents): @@ -54,7 +65,7 @@ def test_thin_cli_preserves_readable_admission_and_authority_with_host_scope(tmp packet = json.loads(subprocess.check_output(command, text=True)) assert packet["ok"] is True budget = packet["interface_budget"] - assert budget["max_chars"] == 3000 + assert budget["max_chars"] == 3200 assert budget["budget_char_count"] <= budget["max_chars"] assert budget["within_budget"] is True body = packet["task_body"] diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index 0869a3ed00..11e448f830 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -14,6 +14,7 @@ agent_profile_scopes, normalize_agent_scopes, ) +from loopx.control_plane.heartbeat.agent_input import project_heartbeat_agent_input from loopx.control_plane.heartbeat.budget import ( REWARD_MEMORY_OUTCOME_PROMPT_HEADROOM_CHARS, build_interface_budget, @@ -67,7 +68,7 @@ def test_interface_budget_uses_visible_goal_mode() -> None: assert budget["within_budget"] is True -@pytest.mark.parametrize("mode,limit", [("thin", 3000), ("visible_goal", 4000)]) +@pytest.mark.parametrize("mode,limit", [("thin", 3200), ("visible_goal", 4000)]) def test_prompt_body_limit_remains_independent_of_json_envelope(mode: str, limit: int) -> None: for size in (limit, limit + 1): budget = build_interface_budget( @@ -91,7 +92,7 @@ def test_heartbeat_envelope_and_body_overflow_are_both_rejected() -> None: runtime_profile="codex_app_heartbeat", agent_id="worker-a", agent_scopes=["implementation", "review"], ) - check("heartbeat_prompt_json", payload) + check("heartbeat_prompt_json", project_heartbeat_agent_input(payload)) # Check the envelope boundary independently of the real prompt's remaining # headroom: adding a metadata key can already put a valid prompt over budget. envelope = {"interface_budget": payload["interface_budget"], "extra": ""} @@ -258,7 +259,7 @@ def test_brief_diagnostics_keep_only_the_selected_prompt_command() -> None: @pytest.mark.parametrize( ("mode", "base_budget"), - [("full", 12000), ("compact", 6500), ("brief", 4300), ("thin", 3000)], + [("full", 12000), ("compact", 6700), ("brief", 4300), ("thin", 3200)], ) def test_reward_memory_prompt_headroom_is_fixed_and_feature_scoped( mode: str, base_budget: int @@ -300,6 +301,22 @@ def test_sizing_guidance_survives_prompt_compaction(mode: str) -> None: assert "--fine-grained" not in payload["quota_guard_command"] +@pytest.mark.parametrize("mode", ["full", "compact", "brief", "thin"]) +def test_codex_app_stop_obligations_survive_prompt_compaction(mode: str) -> None: + payload = build_heartbeat_prompt( + goal_id="pause-fixture", runtime_profile="codex_app_heartbeat", **{mode: True} + ) + body = payload["task_body"] + # Agent-consumed prose is behavior: compact modes must preserve the same + # target, attempt limit, readback, failure and no-delivery obligations. + assert "pause_or_delete_current_heartbeat" in body + assert "pause current heartbeat once" in body + assert "verify stopped" in body + assert "unconfirmed/unavailable: never claim stopped" in body + assert "end; no work/spend/retry/RRULE fallback" in body + assert payload["interface_budget"]["within_budget"] is True + + @pytest.mark.parametrize("mode", ["full", "compact", "brief", "thin"]) def test_reward_memory_outcome_gate_survives_app_prompt_compaction(mode: str) -> None: payload = build_heartbeat_prompt( diff --git a/tests/control_plane/test_replan_host_context_projection.py b/tests/control_plane/test_replan_host_context_projection.py index 282cd02092..8100e52af7 100644 --- a/tests/control_plane/test_replan_host_context_projection.py +++ b/tests/control_plane/test_replan_host_context_projection.py @@ -239,6 +239,11 @@ def test_replan_guidance_survives_cli_and_host_compaction_without_new_authority( assert "probe that distinguishes remaining explanations" in guidance[4] assert "scope negative results to tested conditions and local proxies to their coverage" in guidance[4] assert "Reuse still-applicable evidence" in guidance[4] + assert "Correct within the affected work first" in guidance[4] + assert "goal, path, dependency or acceptance evidence" in guidance[4] + assert "A due review can retain the path" in guidance[4] + assert "subsequent work and authoritative outcomes" in guidance[4] + assert "neither adoption nor improvement" in guidance[4] compact = compact_quota_should_run_cli_payload(source) envelope = build_turn_envelope(source) for packet in (compact, envelope): @@ -253,12 +258,14 @@ def test_replan_guidance_survives_cli_and_host_compaction_without_new_authority( assert turn_envelope_action_signature_document(envelope) == signature signature["replan_action_packet"].pop("planning_guidance") assert signature == quota_action_signature_document(baseline) - # Preserve an existing budget warning. The original three clauses consume - # 670 bytes; objective/evidence guidance adds about 660 bytes. Retain all - # decision obligations with bounded headroom instead of compressing them. + # Preserve the existing 8 KiB envelope warning, including the already-large + # vision fixture. The five advice clauses now cost 1,772 bytes on both + # fixtures (363 text bytes added for local correction/followthrough). This is a + # bounded advice allocation, not permission to truncate authority or raise + # the envelope/transport/compute budget. assert envelope["compaction"]["within_budget"] == old_envelope["compaction"]["within_budget"] assert (envelope["compaction"]["envelope_json_bytes"] - - old_envelope["compaction"]["envelope_json_bytes"]) <= 1500 + - old_envelope["compaction"]["envelope_json_bytes"]) <= 1800 envelope["replan_action_packet"].pop("planning_guidance") assert envelope["replan_action_packet"] == old_envelope["replan_action_packet"] diff --git a/tests/control_plane/test_selected_work_requirements.py b/tests/control_plane/test_selected_work_requirements.py index 4105ce56a4..8ff455f560 100644 --- a/tests/control_plane/test_selected_work_requirements.py +++ b/tests/control_plane/test_selected_work_requirements.py @@ -175,6 +175,9 @@ def guard(*extra: str) -> dict: assert "required_reads" not in full["interaction_contract"]["cli_channel"] context = channel["work_context"] assert context["complete"] and not context.get("failures", []) + assert "progress/change, current blockers, next work, acceptance and stops" in context["instruction"] + assert "unknown, not unchanged" in context["instruction"] + assert "summaries cannot replace full requirements" in context["instruction"] reads = context["sources"] assert [read["source"] for read in reads] == ["selected_todo"] assert not any(read["source"] == "goal_state" for read in reads) @@ -233,6 +236,14 @@ def read_detail() -> dict: assert fresh["selected_todo"]["todo_id"] == "todo_cursor_work" fresh_reads = fresh["interaction_contract"]["agent_channel"]["work_context"]["sources"] assert fresh_reads[-1]["content"]["todo"]["text"] == replacement + fresh_envelope = guard("--turn-envelope") + fresh_plan = build_loopx_turn_plan(fresh_envelope, host="codex-cli", execution_mode="isolated-headless", + turn_instance_id="requirements-next-turn") + fresh_prompt = _prompt(build_loopx_turn_host_request(fresh_plan)) + assert replacement in fresh_prompt + assert "unknown, not unchanged" in fresh_prompt + # The old observation remains an observation, not a rewritten current brief. + assert reads[-1]["content"]["todo"]["text"] == expected missing_tokens = ["todo_missing" if token == "todo_cursor_work" else token for token in tokens] missing = subprocess.run([sys.executable, "-m", "loopx.cli", *missing_tokens[1:]], capture_output=True, text=True, check=True) diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 0271830208..05dd38c91f 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -318,6 +318,9 @@ test("planning advice cannot discharge a replan or widen source-specific exits", assert.match(guidance[4], /Compare expected goal\/information gain with full marginal cost/); assert.match(guidance[4], /executed path and current artifact/); assert.match(guidance[4], /scope negative results to tested conditions/); + assert.match(guidance[4], /Correct within the affected work first/); + assert.match(guidance[4], /A due review can retain the path/); + assert.match(guidance[4], /neither adoption nor improvement/); const refusal = projectReplanSemantics({operation: "qualify", obligation: source, planning_guidance: projection.planning_guidance}); assert.equal(refusal.accepted, false); diff --git a/tests/opencode2_goal_worker.test.mjs b/tests/opencode2_goal_worker.test.mjs index ab9128dd07..35ba0687b9 100644 --- a/tests/opencode2_goal_worker.test.mjs +++ b/tests/opencode2_goal_worker.test.mjs @@ -329,7 +329,7 @@ test("retryPlan backs off exponentially with a ceiling", () => { test("decision helpers recognize terminal closure and run_now", () => { assert.equal(isTerminalNoFollowup(terminalDecision()), true) - assert.equal(shouldRunNow({ scheduler_hint: { action: "run_now" } }), true) + assert.equal(shouldRunNow({ should_run: true, scheduler_hint: { action: "run_now" } }), true) assert.equal(shouldRunNow({ should_run: true }), false) assert.equal(shouldRunNow({ should_run: false }), false) assert.equal( @@ -339,7 +339,7 @@ test("decision helpers recognize terminal closure and run_now", () => { }) -test("worker drives a full loop: task prompt, run_now continuation, standby, then goal gone", async () => { +test("worker admits initial and continuation prompts then stops at validated closure", async () => { const stateStore = memoryStateStore() const transport = fakeTransport() const sleepMs = [] @@ -348,6 +348,7 @@ test("worker drives a full loop: task prompt, run_now continuation, standby, the stateStore, sleepMs, decisions: [ + { should_run: true, scheduler_hint: { action: "run_now" } }, { should_run: true, scheduler_hint: { action: "run_now" } }, terminalDecision(), goalNotFoundDecision(), @@ -359,13 +360,14 @@ test("worker drives a full loop: task prompt, run_now continuation, standby, the taskBody: "Ship the task.", ...overrides, }) - assert.equal(result.kind, "goal_gone") + assert.equal(result.kind, "paused_state") assert.deepEqual(transport.calls.prompts, ["Ship the task.", continuationPrompt("/workspace")]) - assert.ok(transport.calls.synthetics.some((text) => text.includes("stays attached"))) + assert.equal(transport.calls.synthetics.length, 0) const state = await stateStore.read("goal-e2e") - assert.equal(state.phase, "goal_gone") + assert.equal(state.phase, "paused") assert.equal(state.turnCount, 2) - assert.ok(sleepMs.includes("slept")) + assert.equal(sleepMs.length, 0) + assert.equal(state.autoResume, false) }) @@ -380,12 +382,12 @@ test("worker waits quietly between quota polls without prompting", async () => { decisions: [waitDecision(), terminalDecision(), goalNotFoundDecision()], }) const result = await runWorker({ goalId: "goal-wait", directory: "/workspace", taskBody: "Task.", ...overrides }) - assert.equal(result.kind, "goal_gone") - assert.equal(transport.calls.prompts.length, 1) + assert.equal(result.kind, "paused_state") + assert.equal(transport.calls.prompts.length, 0) assert.ok(sleepMs.length >= 1) const state = await stateStore.read("goal-wait") - assert.equal(state.phase, "goal_gone") - assert.equal(state.standbyPolls, 1) + assert.equal(state.phase, "paused") + assert.equal(state.autoResume, false) }) @@ -413,7 +415,7 @@ test("worker pauses visibly when the unchanged-poll limit stops the loop", async }) const result = await runWorker({ goalId: "goal-stop", directory: "/workspace", taskBody: "Task.", ...overrides }) assert.equal(result.kind, "limit_stopped") - assert.equal(transport.calls.prompts.length, 1) + assert.equal(transport.calls.prompts.length, 0) assert.ok(transport.calls.synthetics.some((text) => text.includes("unchanged-poll limit"))) const state = await stateStore.read("goal-stop") assert.equal(state.phase, "limit_stopped") @@ -429,13 +431,14 @@ test("worker keeps looping after an external user message", async () => { transport, stateStore, decisions: [ + { should_run: true, scheduler_hint: { action: "run_now" } }, { should_run: true, scheduler_hint: { action: "run_now" } }, goalNotFoundDecision(), ], }) const run = runWorker({ goalId: "goal-user-msg", directory: "/workspace", taskBody: "Task.", ...overrides }) await new Promise((resolve) => setTimeout(resolve, 0)) - transport.addUserMessage("hold on") + transport.addUserMessage("What is the current status?") const result = await run assert.equal(result.kind, "goal_gone") assert.equal(transport.calls.prompts.length, 2) @@ -478,10 +481,11 @@ test("worker backs off quota probe failures and resets on success", async () => ] const overrides = makeWorkerOverrides({ transport, stateStore, sleepMs, decisions }) const result = await runWorker({ goalId: "goal-probe-fail", directory: "/workspace", taskBody: "Task.", ...overrides }) - assert.equal(result.kind, "goal_gone") + assert.equal(result.kind, "paused_state") const state = await stateStore.read("goal-probe-fail") assert.equal(state.probeRetryCount, 0) - assert.equal(state.standbyPolls, 1) + assert.equal(state.autoResume, false) + assert.deepEqual(transport.calls.prompts, ["Task."]) }) @@ -575,7 +579,7 @@ test("worker stops after the turn budget and posts a visible notice", async () = test("first prompt counts toward the turn budget and its turn finishes before stopping", async () => { const stateStore = memoryStateStore() const transport = fakeTransport() - const overrides = makeWorkerOverrides({ transport, stateStore, decisions: [] }) + const overrides = makeWorkerOverrides({ transport, stateStore, decisions: [{ should_run: true, scheduler_hint: { action: "run_now" } }] }) const result = await runWorker({ goalId: "goal-first-turn", directory: "/workspace", @@ -714,7 +718,7 @@ test("worker pauses when the session stops making progress", async () => { const overrides = makeWorkerOverrides({ transport, stateStore, - decisions: [], + decisions: [{ should_run: true, scheduler_hint: { action: "run_now" } }], }) const result = await runWorker({ goalId: "goal-stalled", @@ -759,7 +763,7 @@ test("worker holds quietly during a user pause and resumes when activity returns const overrides = makeWorkerOverrides({ transport, stateStore, - decisions: [terminalDecision(), goalNotFoundDecision()], + decisions: [{ should_run: true, scheduler_hint: { action: "run_now" } }, terminalDecision()], }) const running = runWorker({ goalId: "goal-hold", @@ -784,9 +788,9 @@ test("worker holds quietly during a user pause and resumes when activity returns ) sessionPaused = false const result = await running - assert.equal(result.kind, "goal_gone") + assert.equal(result.kind, "paused_state") const state = await stateStore.read("goal-hold") - assert.equal(state.phase, "goal_gone") + assert.equal(state.phase, "paused") }) @@ -823,7 +827,7 @@ test("worker resumes by prompting when quota grants run_now and no turn is pendi directory: "/workspace", ...overrides, }) - assert.equal(result.kind, "goal_gone") + assert.equal(result.kind, "paused_state") assert.equal( transport.calls.prompts.length, 1, @@ -832,3 +836,56 @@ test("worker resumes by prompting when quota grants run_now and no turn is pendi const state = await stateStore.read("goal-resume") assert.equal(state.turnCount, 4) }) + +test("initial task is gated before any model prompt and hard pause is durable", async () => { + const stateStore = memoryStateStore() + const transport = fakeTransport() + let probes = 0 + const args = { goalId: "initial-pause", directory: "/workspace", taskBody: "Task", stateStore, transport, + quotaProbe: async () => { probes++; return { should_run: false, scheduler_hint: { action: "stop_until_explicit_resume" } } } } + await runWorker(args) + assert.equal(transport.calls.prompts.length, 0) + assert.equal((await stateStore.read("initial-pause")).autoResume, false) + await runWorker(args) + assert.equal(probes, 1) +}) + +test("pause during quota probe survives both success and failed probe", async () => { + for (const fail of [false, true]) { + const stateStore = memoryStateStore() + const transport = fakeTransport() + await runWorker({ goalId: "probe-pause", directory: "/workspace", taskBody: "Task", stateStore, transport, + quotaProbe: async () => { + const state = await stateStore.read("probe-pause") + await stateStore.write("probe-pause", { ...state, autoResume: false }) + if (fail) throw new Error("probe unavailable") + return { should_run: true, scheduler_hint: { action: "run_now" } } + } }) + assert.equal(transport.calls.prompts.length, 0) + assert.equal((await stateStore.read("probe-pause")).autoResume, false) + } +}) + +test("force resume cannot bypass a still-paused Core goal", async () => { + const stateStore = memoryStateStore() + const transport = fakeTransport() + await stateStore.write("resume-denied", { directory: "/workspace", autoResume: false, turnCount: 0, pendingTurn: false }) + await runWorker({ goalId: "resume-denied", directory: "/workspace", taskBody: "Task", forceResume: true, stateStore, transport, + quotaProbe: async () => ({ should_run: false, scheduler_hint: { action: "stop_until_explicit_resume" } }) }) + assert.equal(transport.calls.prompts.length, 0) + assert.equal((await stateStore.read("resume-denied")).autoResume, false) +}) + +test("restart after denied initial dispatch preserves the original private task", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "worker-pause-")) + try { + const transport = fakeTransport() + const args = { goalId: "restart", directory: "/workspace", transport, maxTurns: 1 } + await runWorker({ ...args, taskBody: "Original task", stateStore: createStateStore({ root }), + quotaProbe: async () => ({ should_run: false, scheduler_hint: { action: "stop_until_explicit_resume" } }) }) + assert.equal(transport.calls.prompts.length, 0) + await runWorker({ ...args, forceResume: true, stateStore: createStateStore({ root }), + quotaProbe: async () => ({ should_run: true, scheduler_hint: { action: "run_now" } }) }) + assert.deepEqual(transport.calls.prompts, ["Original task"]) + } finally { await fs.rm(root, { recursive: true, force: true }) } +}) diff --git a/tests/opencode_goal_bridge_runtime.test.mjs b/tests/opencode_goal_bridge_runtime.test.mjs index f166f3c02a..fef3074ede 100644 --- a/tests/opencode_goal_bridge_runtime.test.mjs +++ b/tests/opencode_goal_bridge_runtime.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict" import test from "node:test" +import { promises as fs } from "node:fs" +import os from "node:os" +import path from "node:path" -import { createLoopxGoalPlugin } from "../loopx/opencode_goal_mode/goal-bridge-runtime.mjs" +import { createLoopxGoalPlugin, createFileBindingStore } from "../loopx/opencode_goal_mode/goal-bridge-runtime.mjs" function fakeTool(spec) { @@ -74,14 +77,13 @@ function terminalDecision() { } -function harness(initialDecision, { lockStore = memoryLockStore() } = {}) { - const store = memoryBindingStore() +function harness(initialDecision, { lockStore = memoryLockStore(), store = memoryBindingStore(), resumeResult = null, beforeHostStop = async () => {} } = {}) { const calls = { chat: 0, event: 0, complete: 0, dispose: 0, quota: 0, resume: 0, pause: 0 } const scheduled = [] let decision = initialDecision const GoalPlugin = async (_context, options) => ({ config: async () => {}, - "command.execute.before": async () => {}, + "command.execute.before": beforeHostStop, "chat.message": async () => { calls.chat += 1 }, @@ -97,6 +99,7 @@ function harness(initialDecision, { lockStore = memoryLockStore() } = {}) { args: {}, execute: async () => { calls.resume += 1 + if (resumeResult) return resumeResult return JSON.stringify({ version: 1, operation: "resume", ok: true }) }, }), @@ -104,12 +107,13 @@ function harness(initialDecision, { lockStore = memoryLockStore() } = {}) { args: {}, execute: async () => { calls.pause += 1 + await beforeHostStop() return JSON.stringify({ version: 1, operation: "pause", ok: true }) }, }), goal_block: fakeTool({ args: {}, - execute: async () => JSON.stringify({ version: 1, operation: "block", ok: true }), + execute: async () => { await beforeHostStop(); return JSON.stringify({ version: 1, operation: "block", ok: true }) }, }), goal_complete: fakeTool({ args: {}, @@ -131,6 +135,7 @@ function harness(initialDecision, { lockStore = memoryLockStore() } = {}) { update_goal: fakeTool({ args: {}, execute: async (args) => { + if (["paused", "blocked"].includes(args.status)) await beforeHostStop() if (args.status === "blocked") return "Goal marked blocked." if (args.status === "paused") return "Goal paused." if (args.status === "resumed") return "Goal resumed with fresh limits." @@ -562,29 +567,24 @@ test("continues after the user intervenes during a quota probe", async () => { }) -test("stands by after terminal closure and resumes when new work appears", async () => { +test("validated closure stops timers and subsequent idle events cannot restart it", async () => { const fixture = harness(terminalDecision()) const hooks = await fixture.plugin({ directory: "/workspace", client: {} }) await hooks.tool.loopx_goal_activate.execute( { goalId: "goal-terminal", objective: "LoopX task body" }, { sessionID: "session-terminal" }, ) - await hooks.event({ - event: { type: "session.idle", properties: { sessionID: "session-terminal" } }, - }) + const idle = { event: { type: "session.idle", properties: { sessionID: "session-terminal" } } } + await hooks.event(idle) assert.equal(fixture.calls.complete, 0) assert.equal(fixture.calls.event, 0) - const standby = await fixture.store.read("session-terminal") - assert.equal(standby.phase, "standby") - assert.equal(standby.autoResume, true) - assert.equal(fixture.scheduled.at(-1).delay, 300_000) - + assert.equal(fixture.calls.pause, 1) + assert.equal((await fixture.store.read("session-terminal")).autoResume, false) + assert.equal(fixture.scheduled.length, 0) fixture.setDecision({ should_run: true, scheduler_hint: { action: "run_now" } }) - await fixture.scheduled.at(-1).callback() - assert.equal(fixture.calls.event, 1) - const working = await fixture.store.read("session-terminal") - assert.equal(working.phase, "working") - assert.equal(working.standbyPolls, 0) + await hooks.event(idle) + assert.equal(fixture.calls.event, 0) + assert.equal(fixture.calls.quota, 1) }) @@ -734,3 +734,127 @@ test("probeLoopxQuota fails closed when a non-zero exit carries no payload", asy /Command failed/, ) }) + +test("ordinary user input cannot resume a host before Core admission", async () => { + const fixture = harness({ should_run: false, scheduler_hint: { action: "stop_until_explicit_resume" } }) + const hooks = await fixture.plugin({ directory: "/workspace", client: {} }) + await hooks.tool.loopx_goal_activate.execute({ goalId: "goal-paused", objective: "Task" }, { sessionID: "paused" }) + await hooks["chat.message"]({ sessionID: "paused" }, { parts: [{ type: "text", text: "How is it going?" }] }) + const before = fixture.calls.resume + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "paused" } } }) + assert.equal(fixture.calls.resume, before) + assert.equal((await fixture.store.read("paused")).autoResume, false) + fixture.setDecision({ should_run: true, scheduler_hint: { action: "run_now" } }) + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "paused" } } }) + assert.equal(fixture.calls.quota, 1) +}) + +test("explicit pause and resume discard the earlier in-flight admission", async () => { + let release + const decision = new Promise(resolve => { release = resolve }) + // The injected probe receives the same asynchronous result as the real CLI. + const fixture = harness(decision) + const hooks = await fixture.plugin({ directory: "/workspace", client: {} }) + await hooks.tool.loopx_goal_activate.execute({ goalId: "race", objective: "Task" }, { sessionID: "race" }) + const idle = { event: { type: "session.idle", properties: { sessionID: "race" } } } + const pending = hooks.event(idle) + while (!fixture.calls.quota) await new Promise(resolve => setImmediate(resolve)) + await hooks.tool.goal_pause.execute({}, { sessionID: "race" }) + await hooks.tool.goal_resume.execute({}, { sessionID: "race" }) + release({ should_run: true, scheduler_hint: { action: "run_now" } }) + await pending + assert.equal(fixture.calls.event, 0) + fixture.setDecision({ should_run: true, scheduler_hint: { action: "run_now" } }) + await hooks.event(idle) + assert.equal(fixture.calls.event, 1) +}) + +test("permission denial stays paused through ordinary chat and idle events", async () => { + const fixture = harness({ should_run: true, scheduler_hint: { action: "run_now" } }) + const hooks = await fixture.plugin({ directory: "/workspace", client: {} }) + await hooks.tool.loopx_goal_activate.execute({ goalId: "deny", objective: "Task" }, { sessionID: "deny" }) + await hooks.event({ event: { type: "permission.replied", properties: { sessionID: "deny", reply: "reject" } } }) + await hooks["chat.message"]({ sessionID: "deny" }, { parts: [{ type: "text", text: "Status?" }] }) + const resumed = fixture.calls.resume + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "deny" } } }) + assert.equal(fixture.calls.quota, 0) + assert.equal(fixture.calls.resume, resumed) + assert.equal((await fixture.store.read("deny")).autoResume, false) +}) + + +test("unconfirmed native resume does not forward the continuation event", async () => { + const fixture = harness({ should_run: true, scheduler_hint: { action: "run_now" } }, + { resumeResult: JSON.stringify({ ok: false, error: "host_unavailable" }) }) + const hooks = await fixture.plugin({ directory: "/workspace", client: {} }) + await hooks.tool.loopx_goal_activate.execute({ goalId: "resume-failed", objective: "Task" }, { sessionID: "failed" }) + await hooks["chat.message"]({ sessionID: "failed" }, { parts: [{ type: "text", text: "Status?" }] }) + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "failed" } } }) + assert.equal(fixture.calls.event, 0) + assert.equal((await fixture.store.read("failed")).autoResume, false) +}) + +for (const operation of ["goal_pause", "goal_block", "legacy_pause", "command_pause"]) { + test(`${operation} fences admission before awaiting the native stop`, async () => { + let releaseProbe, releaseStop, stopEntered + const probe = new Promise(resolve => { releaseProbe = resolve }) + const entered = new Promise(resolve => { stopEntered = resolve }) + const heldStop = new Promise(resolve => { releaseStop = resolve }) + const fixture = harness(probe, { beforeHostStop: async () => { stopEntered(); await heldStop } }) + const hooks = await fixture.plugin({ directory: "/workspace", client: {} }) + await hooks.tool.loopx_goal_activate.execute({ goalId: "slow-stop", objective: "Task" }, { sessionID: "slow" }) + const idle = hooks.event({ event: { type: "session.idle", properties: { sessionID: "slow" } } }) + while (!fixture.calls.quota) await new Promise(resolve => setImmediate(resolve)) + const stopping = operation === "command_pause" + ? hooks["command.execute.before"]({ command: "goal", sessionID: "slow", arguments: "pause" }, {}) + : hooks.tool[operation === "legacy_pause" ? "update_goal" : operation].execute( + operation === "legacy_pause" ? { status: "paused" } : {}, { sessionID: "slow" }) + await entered + releaseProbe({ should_run: true, scheduler_hint: { action: "run_now" } }) + await idle + try { + assert.equal(fixture.calls.event, 0) + assert.equal((await fixture.store.read("slow")).autoResume, false) + } finally { + releaseStop() + await stopping + await hooks.dispose() + } + }) +} + +test("native pause failure keeps local continuation disarmed without claiming host success", async () => { + const fixture = harness({ should_run: true, scheduler_hint: { action: "run_now" } }, + { beforeHostStop: async () => { throw new Error("native pause unavailable") } }) + const hooks = await fixture.plugin({ directory: "/workspace", client: {} }) + await hooks.tool.loopx_goal_activate.execute({ goalId: "failed-stop", objective: "Task" }, { sessionID: "failed-stop" }) + await assert.rejects(hooks.tool.goal_pause.execute({}, { sessionID: "failed-stop" }), /native pause unavailable/) + assert.equal((await fixture.store.read("failed-stop")).autoResume, false) + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "failed-stop" } } }) + assert.equal(fixture.calls.quota, 0) + assert.equal(fixture.calls.event, 0) + await hooks.dispose() +}) + +test("filesystem binding preserves pause across bridge recreation", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "bridge-pause-")) + try { + const first = harness({ should_run: false, scheduler_hint: { action: "stop_until_explicit_resume" } }, + { store: createFileBindingStore({ root }) }) + const hooks = await first.plugin({ directory: "/workspace", client: {} }) + await hooks.tool.loopx_goal_activate.execute({ goalId: "durable", objective: "Task" }, { sessionID: "durable" }) + const idle = { event: { type: "session.idle", properties: { sessionID: "durable" } } } + await hooks.event(idle) + await hooks.dispose() + const second = harness({ should_run: true, scheduler_hint: { action: "run_now" } }, + { store: createFileBindingStore({ root }) }) + const restored = await second.plugin({ directory: "/workspace", client: {} }) + await restored.event(idle) + assert.equal(second.calls.quota, 0) + await restored.tool.goal_resume.execute({}, { sessionID: "durable" }) + await restored.event(idle) + assert.equal(second.calls.quota, 1) + assert.equal(second.calls.event, 1) + await restored.dispose() + } finally { await fs.rm(root, { recursive: true, force: true }) } +}) diff --git a/tests/pi_goal_loop_runtime.test.mjs b/tests/pi_goal_loop_runtime.test.mjs index f03ddefaf2..eaec94bdfd 100644 --- a/tests/pi_goal_loop_runtime.test.mjs +++ b/tests/pi_goal_loop_runtime.test.mjs @@ -1579,3 +1579,85 @@ test("host-bound Pi authority rejects agent and capability rebinding before leas assert.equal(tamperedResult.error_code, "authority_mismatch") assert.equal(calls.length, 0) }) + +test("hard pause stays disarmed across idle events until explicit resume and fresh admission", async () => { + const fixture = harness({ should_run: false, scheduler_hint: { action: "stop_until_explicit_resume" } }) + fixture.loop.bind("paused", fixture.services) + await fixture.activate("paused") + await fixture.loop.settle("paused") + fixture.setDecision({ should_run: true, scheduler_hint: { action: "run_now" } }) + for (let i = 0; i < 3; i++) await fixture.loop.settle("paused") + assert.equal(fixture.calls.quota, 1) + assert.equal(fixture.calls.send, 0) + assert.equal(fixture.scheduled.length, 0) + await fixture.loop.resume("paused") + await fixture.loop.settle("paused") + assert.equal(fixture.calls.quota, 2) + assert.equal(fixture.calls.send, 1) +}) + +test("pause then resume fences the pre-pause quota result", async () => { + let release + let started + const ready = new Promise(resolve => { started = resolve }) + const fixture = harness(() => { started(); return new Promise(resolve => { release = resolve }) }) + fixture.loop.bind("race", fixture.services) + await fixture.activate("race") + const pending = fixture.loop.settle("race") + await ready + await fixture.loop.interrupt("race") + await fixture.loop.resume("race") + release({ should_run: true, scheduler_hint: { action: "run_now" } }) + await pending + assert.equal(fixture.calls.send, 0) + fixture.setDecision({ should_run: true, scheduler_hint: { action: "run_now" } }) + await fixture.loop.settle("race") + assert.equal(fixture.calls.send, 1) +}) + +test("scheduler run_now never overrides denied admission", async () => { + const fixture = harness({ should_run: false, scheduler_hint: { action: "run_now" } }) + fixture.loop.bind("denied", fixture.services) + await fixture.activate("denied") + await fixture.loop.settle("denied") + assert.equal(fixture.calls.send, 0) +}) + +test("cancelled timer cannot steal the replacement wake after explicit resume", async () => { + const fixture = harness(backoffDecision()) + fixture.loop.bind("timer", fixture.services) + await fixture.activate("timer") + await fixture.loop.settle("timer") + const oldTimer = fixture.scheduled.at(-1) + await fixture.loop.interrupt("timer") + await fixture.loop.resume("timer") + await fixture.loop.settle("timer") + const replacement = fixture.scheduled.at(-1) + const probes = fixture.calls.quota + await oldTimer.callback() + assert.equal(fixture.calls.quota, probes) + fixture.setDecision({ should_run: true, scheduler_hint: { action: "run_now" } }) + await replacement.callback() + assert.equal(fixture.calls.send, 1) +}) + +test("file-backed Core pause survives a new loop instance and resume rechecks admission", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-pause-")) + try { + const store = createBindingStore(root) + const first = harness({ should_run: false, scheduler_hint: { action: "stop_until_explicit_resume" } }) + first.loop.bind("durable", { ...first.services, store }) + await first.activate("durable") + await first.loop.settle("durable") + first.loop.dispose() + const second = harness({ should_run: true, scheduler_hint: { action: "run_now" } }) + second.loop.bind("durable", { ...second.services, store: createBindingStore(root) }) + await second.loop.settle("durable") + assert.equal(second.calls.quota, 0) + await second.loop.resume("durable") + await second.loop.settle("durable") + assert.equal(second.calls.quota, 1) + assert.equal(second.calls.send, 1) + second.loop.dispose() + } finally { await fs.rm(root, { recursive: true, force: true }) } +}) diff --git a/tests/test_chat_codex_goal.py b/tests/test_chat_codex_goal.py index fc5d1ca72c..30bcebee65 100644 --- a/tests/test_chat_codex_goal.py +++ b/tests/test_chat_codex_goal.py @@ -30,7 +30,9 @@ def __init__(self, tmp_path, monkeypatch, *, goal=None, events=()): monkeypatch.setattr(self.session, "_request", self.request) monkeypatch.setattr(self.session, "_next_event", self.next_event) monkeypatch.setattr( - self.session, "interrupt", lambda *a: self.calls.append(("interrupt", {})) + self.session, "interrupt", lambda *a: self.calls.append( + ("interrupt", {"turn_id": self.session.current_turn_id}) + ) ) monkeypatch.setattr( self.session, "close", lambda: setattr(self, "closed", True) @@ -241,6 +243,84 @@ def test_failed_pause_stops_owned_transport(tmp_path, monkeypatch): assert host.closed +@pytest.mark.parametrize("readback", ["active", "absent", "offline"]) +def test_stop_requires_pause_readback_or_closes_owned_transport( + tmp_path, monkeypatch, readback +): + host = Host(tmp_path, monkeypatch, goal=native_goal("active")) + pause_requested = False + + def unconfirmed_pause(method, params): + nonlocal pause_requested + if method == "thread/goal/set": + host.calls.append((method, params.copy())) + pause_requested = True + # Even a paused ACK is not proof that a later read is non-active. + return {"goal": native_goal("paused")} + if pause_requested and readback == "offline": + raise host.session._runtime_error("Readback unavailable") + if pause_requested and readback == "absent": + return {"goal": None} + return host.request(method, params) + + monkeypatch.setattr(host.session, "_request", unconfirmed_pause) + adapter = CodexAppServerAdapter(host.session, goal_driver=host.driver) + with pytest.raises(CodexChatAgentError): + adapter.interrupt_turn() + assert host.closed and host.driver.stopped.is_set() + assert host.goal["objective"] == "Original objective" + assert host.goal["tokensUsed"] == 500 + assert [p["status"] for m, p in host.calls if m == "thread/goal/set"] == ["paused"] + + +def test_stop_during_event_wait_does_not_deliver_late_turn_or_resume_context( + tmp_path, monkeypatch +): + def stop_before_late_start(host): + host.driver.pause() + return begin("late") + + host = Host(tmp_path, monkeypatch, goal=native_goal("paused"), + events=[stop_before_late_start]) + received = [] + monkeypatch.setattr(host.session, "steer", lambda *a, **kw: received.append("steer")) + host.driver.turn_start_handler = lambda _: received.append("handler") + response = host.driver.run( + parse_native_goal_command("/goal resume --tokens 2000"), + lambda k, p: received.append(k), execution_context="Fresh context", + ) + assert "turn.started" not in received and "steer" not in received and "handler" not in received + assert "Codex Goal: paused" in response["message"] + assert "Codex Goal: active" not in response["message"] + assert ("interrupt", {"turn_id": "late"}) in host.calls + assert not host.closed + + +def test_cold_recovery_rejects_ack_without_pause_effect(tmp_path, monkeypatch): + host = Host(tmp_path, monkeypatch, goal=native_goal("active")) + request = host.request + + def ack_only(method, params): + if method == "thread/goal/set": + return {"goal": native_goal("paused")} + return request(method, params) + + monkeypatch.setattr(host.session, "_request", ack_only) + store = ChatSessionStore(tmp_path / "runtime") + controller = ChatRuntimeController(store=store, codex_bin="codex") + session = store.create_session( + goal_id="fixture", agent_id="codex", adapter_kind="codex_app_server", + upstream_thread_id=host.session.thread_id, upstream_mode="chat_native_goal", + channel_id="goal.fixture", + ) + monkeypatch.setattr(controller, "_start_adapter", lambda **kw: CodexAppServerAdapter(host.session)) + with pytest.raises(CodexChatAgentError, match="could not be restored"): + controller.resume_session(session_id=session["session_id"], work_dir=tmp_path, objective="Research") + assert host.closed and session["session_id"] not in controller.adapters + assert store.load_session(session["session_id"])["status"] == "resume_failed" + controller.close() + + def test_native_response_cannot_supply_action_or_completion_authority( tmp_path, monkeypatch ): diff --git a/tests/test_external_scheduler_worker.py b/tests/test_external_scheduler_worker.py index 7253a7ac21..aa21569b1b 100644 --- a/tests/test_external_scheduler_worker.py +++ b/tests/test_external_scheduler_worker.py @@ -159,6 +159,40 @@ def _args( ) +@pytest.mark.parametrize("admission", ["false", "true", 1, 0, None, [], {"allowed": True}, "missing"]) +def test_malformed_admission_never_launches_wake(tmp_path, admission): + payload = _hint_payload(should_run=True) + payload["should_run"] = admission + if admission == "missing": + payload.pop("should_run") + cli = tmp_path / "quota" + _write_executable(cli, f"#!{sys.executable}\nprint({json.dumps(payload)!r})\n") + marker = tmp_path / "model-called" + args = _args(fake_cli=cli, state_file=tmp_path / "state.json", + wake_cmd=shlex.join([sys.executable, "-c", + f"from pathlib import Path; Path({str(marker)!r}).touch()"]), + wake_timeout_seconds=5) + assert worker.run_worker(args) == 2 + assert not marker.exists() + + +def test_failed_decision_cannot_authorize_wake(tmp_path, monkeypatch): + payload = _hint_payload(should_run=True) | {"ok": False} + monkeypatch.setattr(worker, "run_quota_should_run", lambda *a, **kw: payload) + monkeypatch.setattr(worker, "_run_wake", lambda *a, **kw: pytest.fail("failed decision woke")) + args = _args(fake_cli=tmp_path / "unused", state_file=tmp_path / "state.json", wake_cmd="unused") + assert worker.run_worker(args) == 2 + + +def test_explicit_stop_precedes_even_malformed_admission(tmp_path, monkeypatch): + payload = _hint_payload(should_run=True, local_scheduler_directive="stop") + payload["should_run"] = "false" + monkeypatch.setattr(worker, "run_quota_should_run", lambda *a, **kw: payload) + monkeypatch.setattr(worker, "_run_wake", lambda *a, **kw: pytest.fail("stopped worker woke")) + args = _args(fake_cli=tmp_path / "unused", state_file=tmp_path / "state.json", wake_cmd="unused") + assert worker.run_worker(args) == 0 + + @pytest.mark.parametrize("stop_code,expected", [(None, 75), (75, 0), (76, 75)]) def test_wake_terminal_code_requires_explicit_matching_opt_in(tmp_path, stop_code, expected): cli = tmp_path / "quota"