opencode 自动循环方案:拒绝事件驱动,TICK 单一驱动器
本文基于 opencode-goal 插件(开源,SDK 1.18)真实实现编写。主题:自动续推循环为何不由 idle/error 事件直接触发,而由独立 TICK 驱动。
1. 问题域
自动续推循环的初版直觉实现:监听session.status(idle)与session.error事件,事件到达即发送下一条续推或处理错误。该方案在真实环境暴露四类缺陷,最终被整体替换为单一 TICK 驱动器。
2. 事件驱动的四类缺陷
2.1 事件不可靠
事件流经 WebSocket 推送,不承诺送达。idle 事件丢失后,循环静默停滞——无日志、无异常,仅表现为"不再驱动"。该故障形态排查代价最大。
2.2 乱序与重复
session.error与message.updated两条通道可能先后携带同一中止(实测间隔在秒级)。状态迁移不得假设"仅到达一次"或"按序到达"。事件驱动实现必须自行承担去重与版本比对。
2.3 重入并发
idle 到达时若上一次续推尚未完成,直接发送即产生并发双发。必须引入互斥;互斥引入后,事件驱动事实上已演进为"以事件为触发器的状态机",但状态机主体仍分散。
2.4 副作用确认缺失
"续推是否成功发送"在事件语义下无天然锚点。SDK 超时、半发送、队列积压使错误处理分散且不一致。
3. 替代设计:单一 TICK 驱动器
自 2026-08-29 起,循环仅保留一个驱动器——后台 TICK(默认间隔 1000ms,envGOAL_TICK_INTERVAL_MS):
- 请求权威会话状态快照(
client.session.status); - 遍历全部带目标会话,依次经过门链(
drive_session):- 无目标或目标 paused/complete/unmet → 跳过
user_abort_at非空 → 跳过(用户中止,仅显式恢复)- 会话未激活(重启门:本实例中用户未发言)且为 active → 跳过
- 快照 busy/retry → 记录停滞起始时刻,超
STUCK_RESCUE_STALL_MS(默认 90s)且无事件且无进行中工具则自动 abort 一次,其余跳过 - 子会话 busy → 跳过
- 速率窗口未到 → 跳过
- 上下文超限 → 触发自动压缩并返回
- 其余 →
run_auto_continue(原子保留 → 发送)
事件在此设计中只记录状态,绝不触发发送。事件处理仅执行:滑动窗口盖章、中止指纹记入、忙闲跟踪。
4. 正确性论证(对应 2 节缺陷)
| 缺陷 | 处理 |
|---|---|
| 事件丢失 | 循环不依赖事件;决策基于磁盘状态与最新快照,idle 丢失后 TICK 仍依据窗口自然驱动 |
| 乱序/重复 | 状态以持久化为准;相同事件的重复到达对状态写入幂等 |
| 并发双发 | active_continuations集合持有整个发送周期;每次 TICK 快照一次并复用,杜绝并发双发 |
| 恰好一次 | 单调锚last_continuation_at+auto_turns在同一变更内自增(reserve_continuation原子) |
4.1 no-progress 自愈的门序位置
paused 且stop_reason === "no progress"的目标由 TICK 轮询(get_comatose_goal_ids):首次检出记录时刻,冷却期(GOAL_NO_PROGRESS_RESUME_COOLDOWN_MS,默认 60000)内保持暂停;期满自动恢复 active 并继续驱动。检测器暂停非用户行为,循环不得因此永久停止。
5. 代价与边界
- TICK 间隔引入感知延迟:空闲后至多等待一个间隔再发送;
- 事件记录必须正确实现(漏盖章将使窗口失效);
- 快照失败时保守跳过本轮,不做猜测式发送;
- 多插件实例各自运行 TICK:同会话的跨实例双发由用户操作门与 CAS 写收敛,不在本文范围。
6. 验证
goal-tick-loop-test.mjs:TICK 驱动断言;goal-gates-test.mjs/goal-guards-test.mjs:门链各分支;goal-fault-injection-test.mjs:SDK 挂起/抛错/重放/乱序注入后目标保持 active、循环继续;goal-state-exhaustive-test.mjs:状态迁移穷举。
7. 结论
自动循环的可靠形式是周期性地基于权威状态做决策,而非响应事件。opencode 事件是状态输入的传感器,不是驱动来源;将二者混用是"循环自行停止"类难以定位故障的来源。该结论适用于任何需要"恰好一次、限速、有状态"的后台驱动场景。