通知与回退系统:Turn 追踪、自动推进与跨会话恢复
通知与回退系统:Turn 追踪、自动推进与跨会话恢复
学习目标
- 理解 notify-hook 在每次 Agent turn 后的处理流程
- 掌握自动推进机制如何检测停滞模式并注入继续提示
- 了解通知回退观察者的轮询策略和状态变更检测
- 理解 PID 活跃性检查和会话陈旧检测
前置知识
本章聚焦 Oh-My-Codex 的具体工程实践,无需前置阅读。
项目实践
Notify Hook 核心流程
伪代码(src/scripts/notify-hook.ts):function runNotifyHook(event): // 1. Turn 追踪 recordTurnMetrics(event) updateTokenUsage(event) updateQuotaTracking(event)
// 2. 自动推进 if shouldAutoNudge(event): injectContinuationPrompt()
// 3. 团队分派 if hasActiveTeam(): drainPendingDispatches() nudgeLeaderIfWorkerAckReceived()
// 4. Ralph 会话恢复 if hasActiveRalph(): reconcileRalphPersistence()
// 5. Worker 心跳 if isWorker(event): updateWorkerHeartbeat(event) notifyLeaderIfWorkerIdle(event)
// 6. 操作事件 deriveSessionLifecycleSignals(event) enrichRepositoryMetadata(event)Turn 追踪
伪代码(recordTurnMetrics):function recordTurnMetrics(event): metrics = read(".omx/metrics.json") hudState = read(".omx/state/hud-state.json")
metrics.turns++ metrics.totalTokens += event.tokenUsage?.total || 0 metrics.inputTokens += event.tokenUsage?.input || 0 metrics.outputTokens += event.tokenUsage?.output || 0
if event.timestamp: metrics.lastTurnAt = event.timestamp
// HUD 状态更新 hudState.turnCount = metrics.turns hudState.lastTurnTimestamp = event.timestamp hudState.tokenUsage = metrics.totalTokens
writeAtomic(".omx/metrics.json", metrics) writeAtomic(".omx/state/hud-state.json", hudState)自动推进机制
伪代码(shouldAutoNudge + injectContinuationPrompt):function shouldAutoNudge(event): // 深度面试期间抑制自动推进 if isActiveSkill("deep-interview"): return false
// 检测停滞模式 stallPatterns = [ isPermissionPromptStuck(event), // 权限提示卡住 isUserBlockedState(event), // 用户阻塞状态 isNoProgressTurn(event) // 无进展的 turn ]
return stallPatterns.some(p => p)
function injectContinuationPrompt(): if not hasTmuxContext(): return
// 向 tmux 窗口格注入继续提示 sendKeys(paneId, "Please continue with your work.", { waitForPrompt: true, timeout: 5000 })深度面试期间的抑制:深度面试需要用户回答问题,自动推进会跳过用户的回答,所以被抑制。
团队分派
伪代码(团队分派在 notify-hook 中):function processTeamDispatches(): requests = read("dispatch/requests.json")
for req in requests.pending: workerId = req.workerId workerStatus = readWorkerStatus(workerId)
if workerStatus == "idle" or workerStatus == "done": // Worker 可以接收新任务 deliverInbox(workerId, req.instructions) req.status = "notified" sendShortTrigger(workerId)
recordEvent("dispatch_delivered", { workerId, taskId: req.taskId }) else: recordEvent("dispatch_skipped", { workerId, reason: workerStatus })
// 检查 leader 信箱中的 worker ACK leaderMailbox = read("mailbox/leader-fixed.json") for msg in leaderMailbox: if msg.type == "worker_ack": markDispatchDelivered(msg.taskId)通知回退观察者
伪代码(src/scripts/notify-fallback-watcher.ts):function runFallbackWatcher(): prevState = null
while true: // 读取状态文件 newState = readAllStateFiles()
if deepEqual(newState, prevState): sleep(1s) continue
// 状态变更处理 handleStateChange(newState, prevState)
// 检查面板就绪性(团队 send-keys) checkPaneReadiness()
// PID 活跃性检查 checkPidLiveness()
// 检查子代理活动窗口 checkSubagentActivity()
prevState = newState sleep(1s)PID 活跃性检查
伪代码(checkPidLiveness):function checkPidLiveness(): sessions = readAllSessions()
for session in sessions: pid = session.pid
if platform == "linux": // Linux: 通过 /proc/<pid>/stat 检查 start-ticks try: stat = read(`/proc/${pid}/stat`) startTicks = parseStartTicks(stat)
if startTicks != session.startTicks: // PID 存在但 start-ticks 不同 → 进程已替换 markSessionStale(session) continue catch ENOENT: // 进程不存在 markSessionStale(session) continue else: // 非 Linux:尝试发送信号 0 if not kill(pid, 0): markSessionStale(session)为什么检查 start-ticks:PID 可能被操作系统重新分配给新进程。仅检查 PID 存在与否不够,需要验证 start-ticks(进程启动时的时钟 tick 数)是否匹配。
通知调度器
伪代码(src/scripts/notify-dispatcher.ts):function runNotifyDispatcher(event): // 同 turn 合并(最小 10 秒间隔) if lastNotifyTime and now() - lastNotifyTime < 10_000ms: if event.turnId == lastTurnId or event.sessionId == lastSessionId: return // 去重
// 陈旧事件过滤(最大 5 分钟年龄) eventAge = now() - event.timestamp if eventAge > 5 * 60_000: return
// 先运行用户的通知命令 if userNotifyCommand: try: run(userNotifyCommand, JSON.stringify(event)) catch e: warn(`User notify command failed: ${e.message}`)
// 再运行 OMX 的通知 Hook run("omx notify-hook", JSON.stringify(event))
lastNotifyTime = now() lastTurnId = event.turnId lastSessionId = event.sessionIdRalph 跨会话恢复
伪代码(reconcileRalphPersistence):function reconcileRalphPersistence(): state = readRalphState()
if state.active and not isSessionAlive(state.sessionId): // Ralph 活跃但会话不存在 → 跨会话恢复 newSessionId = getOrCreateSessionId() state.sessionId = newSessionId state.current_phase = "executing" state.iteration++
writeRalphState(state)
// 注入恢复提示 injectRalphResumePrompt(newSessionId)问题与规避
| 问题 | 后果 | OMX 的规避策略 |
|---|---|---|
| 自动推进误注入 | 打断用户交互 | 深度面试期间抑制 + 停滞模式检测 |
| 通知事件重复 | 指标计数翻倍 | 同 turn 合并(10 秒最小间隔) |
| 回退观察者延迟 | 状态变更处理不及时 | 1 秒轮询间隔 |
| PID 复用导致误判 | 活跃会话被标记为陈旧 | start-ticks 验证 |
| 陈旧事件堆积 | 过时事件被处理 | 5 分钟最大年龄过滤 |
设计取舍
为什么同时维护原生 Hook 和回退观察者?
OMX 优先使用 Codex 原生 Hook,同时维护一套轮询式回退观察者。
优势:
- 版本兼容:旧版 Codex 不支持原生 Hook 时仍能工作
- 弹性:原生 Hook 失败时回退观察者作为安全网
- 功能完整:回退观察者可以检测原生 Hook 无法覆盖的场景(如面板就绪性)
代价:
- 维护成本:两套处理逻辑需要同步维护
- 行为差异:Hook 和轮询的触发时机和上下文不同
- 调试困难:需要判断问题是 Hook 路径还是回退路径导致的
参考来源
- Oh-My-Codex v0.18.9 源码 —
src/scripts/notify-hook.ts(通知 Hook) - Oh-My-Codex v0.18.9 源码 —
src/scripts/notify-fallback-watcher.ts(回退观察者) - Oh-My-Codex v0.18.9 源码 —
src/scripts/notify-dispatcher.ts(通知调度器) - Oh-My-Codex v0.18.9 源码 —
src/hooks/session.ts(会话管理)