
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_atauto_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 事件是状态输入的传感器,不是驱动来源;将二者混用是循环自行停止类难以定位故障的来源。该结论适用于任何需要恰好一次、限速、有状态的后台驱动场景。