跳转到内容

通知与回退系统: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.sessionId

Ralph 跨会话恢复

伪代码(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(会话管理)