Docs > docs/orchestration-tracker-design.md
任务: t_02222bb4 — hermes-dashboard 实时多角色编排 SSE 显示 目标: 不修改 opencode/oh-my-openagent 本身, 通过观察其运行时副作用文件推断 Sisyphus 多阶段编排状态 (EXPLORE → PLAN → ROUTE → EXECUTE_OR_SUPERVISE → VERIFY → RETRY → DONE), 并通过 SSE 实时推送到 dashboard。
调研方法: explore agent 实际 ls -laR + stat + 解析了 /home/yi/.hermes/kanban/workspaces/*/.omo/ 下所有 JSON; 另一 explore agent 通读了 oh-my-openagent@latest/dist/index.js (177,998 行打包 bundle) 中全部 writeFileSync/appendFileSync 调用点; librarian agent 核实了 hook 机制与 opencode session 存储。
不存在显式的 phase 状态机。 "EXPLORE"/"PLAN"/"ROUTE"/"VERIFY" 等字符串在 dist bundle 中只出现在 prompt 模板里 (如 "PHASE 1: ANALYZE THE DESIGN SYSTEM"), 没有任何运行时代码把它们作为枚举值写入文件或事件。Phase 是 Sisyphus 的"叙事心智模型", 必须由外部观察者推断。
| 路径 | 写入时机 | 内容 | 信号类型 |
|---|---|---|---|
<project>/.omo/boulder.json |
每次状态迁移 (sub-agent 启动/任务开始/任务完成) 原子重写 | {schema_version:2, active_work_id, works:{<id>:{status, active_plan, plan_name, session_ids[], task_sessions:{<taskKey>:{session_id, agent, status, started_at, ended_at}}}}} |
主状态 — work/task 级 |
<project>/.omo/plans/<plan-name>.md |
Prometheus/Sisyphus 勾选 checkbox 时 | ## TODOs 下 - [x] 1. … / - [ ] 2. …; ## Final Verification Wave 下 - [ ] F1. … |
phase 进度 — checkbox 驱动 |
<project>/.omo/run-continuation/<sesID>.json |
session 活跃期间反复重写 (实测 30s~10min 间隔) | {sessionID, updatedAt, sources:{"background-task":{state:"active"\|"idle", reason:"N background task(s) active"}}} |
活跃信号 — 263B=active / 214B=idle tombstone |
~/.local/share/opencode/storage/oh-my-openagent/tui-state/<hash>.json |
TUI session 状态变化 | {projectDir, activeAgents:[{name,status}], jobBoard:[{title,status,toolCalls,lastTool}]} |
agent 占用 — projectDir 字段可解 t_↔ses_ 映射 |
~/.local/share/opencode*/opencode.db (SQLite, 每个 hermes profile 各一个) |
每次 message/part/event | 表: session(id,parent_id,agent,title,directory,time_created,time_updated), message(session_id,data), part(session_id,data), event(aggregate_id,seq,type,data), todo(session_id,content,status,priority) |
最细粒度 — tool call 级 |
<project>/.omo/notepads/<plan>/{learnings,decisions,issues,problems}.md |
sub-agent 完成任务后追加 | markdown 追加 | 辅助 — mtime 增量 |
~/.omo/runtime/<teamRunId>/state.json 等 |
team-mode 专用 (本任务不用) | team 状态机 | 不适用 |
.omo/, 且每个只有 run-continuation/ses_*.json 一个文件, 全部是 Birth==Modify==Change (精确到毫秒) 的 idle tombstone。.omo/plans/、.omo/state/、.omo/todos/、.omo/sessions/ 在任何地方都不存在。{"state":"idle"} (session 结束一次性写入); 263B={"state":"active","reason":"N background task(s) active"} (活跃期间反复重写, hermes-dashboard/.omo/ 下实测 10min 与 30s 重写间隔)。~/.hermes/profiles/omo-pm/home/.local/share/opencode/opencode.db), 主用户 DB (~/.local/share/opencode/opencode.db) 只含主会话。必须聚合所有 profile DB, 单看默认 DB 会漏掉 worker 的 sub-agent 会话。parent_id 指向 root session (实测 ses_ff045758... parent=ses_ff046dbf...), 可构建 orchestration tree。chat.message/tool.execute.before/event 等事件), 不写文件、不发 IPC。外部进程只能读 SQLite event 表与 part 表。信号源优先级 (新信号覆盖旧信号):
workspaces/<task_id>/.omo/run-continuation/ses_*.json 文件名即 root session id (+其 parent_id 子会话)。started_at±120s) 内的 root session, 若其前 20 个 part 的 JSON data 中包含 t_<id> 字符串 (hermes worker 会把任务 spec 作为首条 prompt 传给 opencode) → 认定归属。实测有效: 本会话首 part 含完整任务 spec。directory 匹配 workspace 的 root session。_classify_part)读取最活跃 session 的最近 40 个 part (按 time_created DESC), 首个携带 phase 信号的 part 决定当前 phase:
| part 特征 | 推断 phase |
|---|---|
type=patch 或 tool∈{write,edit} |
EXECUTE_OR_SUPERVISE |
tool=task 且 subagent_type∈{explore,librarian} |
EXPLORE |
tool=task 且 subagent_type∈{plan,metis,momus} 或 tool=todowrite |
PLAN |
tool=task 其他 (category 派发) 或 tool=delegate_task |
EXECUTE_OR_SUPERVISE |
tool∈{read,grep,glob,webfetch,websearch,context7_*} |
EXPLORE |
tool=lsp_diagnostics 或 tool=bash 且命令含 pytest/npm test/deploy.sh//healthz/curl |
VERIFY |
tool=bash 且 state.status=error |
RETRY |
kanban status∈{done,blocked,failed} |
DONE |
| 无信号但 root session 活跃且 agent 含 "Sisyphus" | PLAN (fallback) |
| 无任何 session | EXPLORE (cold start, "no session found yet") |
.omo/plans/<plan>.md 中 ## TODOs / ## Final Verification Wave 的 checkbox 状态, 首个未勾选序号决定 EXECUTE vs VERIFY 边界 (dist/index.js:96339 parseCurrentTopLevelTask 的 canonical 算法)。当前环境中 kanban workspace 不产生 boulder.json (worker 不走 /start-work), 故未采用。event 表有单调 seq, 可按 aggregate_id (session) + seq 做增量轮询, 比全量扫 part 表更高效。当前 1s 轮询 part 表已足够 (单会话 40 行 LIMIT)。time_updated 超过 300s 无更新 → payload 标记 stale:true (UI 显示灰化提示)。opencode profile DBs (只读, mode=ro) kanban.db (只读)
├─ ~/.local/share/opencode/opencode.db └─ tasks(started_at, status)
└─ ~/.hermes/profiles/*/home/.local/share/opencode/opencode.db
│
▼ 每秒轮询 (SSE generator 驱动)
services/orchestration_tracker.py
├─ _sessions_for_task() 三级回退定位 session
├─ _classify_part() part→phase 映射
├─ _infer_current_phase() 最活跃 session 的最新信号
├─ record_event() phase/agent 变化时写入 orchestration.db
├─ get_current_phase() 公开 API: {task_id, phase, started_at, elapsed, agent, files[], status, detail, stale, ts}
└─ get_full_timeline() 公开 API: 持久化历史 (oldest first)
│
▼
orchestration.db — orchestration_events 表
(id, task_id, phase, agent, files(json), detail, started_at, elapsed, created_at)
INSERT 策略: 仅当 (phase, agent) 与上一行不同时插入 (去抖)
│
▼
routes/api.py
├─ GET /api/tasks/<id>/orchestration/stream SSE, 1s 心跳, data:{json}\n\n, 终态自动断流, 30min 上限
└─ GET /api/tasks/<id>/orchestration/timeline 一次性 {current, timeline[]}
│
▼
前端 (templates + static/js/main.js)
├─ task_detail.html "🎛️ 编排实时状态" 卡片 EventSource 实时渲染 phase/agent/elapsed/files
├─ task_detail.html "🕓 完整编排时间线" 区块 展开时 fetch timeline, 流结束时自动刷新
├─ dashboard.html kanban 计数条 #running-phase + 每个 running 卡片 .running-phase 指示器 (10s 轮询)
└─ base.html .phase-badge.phase-* CSS (EXPLORE/PLAN/VERIFY/RETRY/DONE 配色 + pulse 动画)
get_current_phase() — 无需独立 watcher 进程, 无 inotify 跨 profile 目录监听的复杂度。SQLite 只读查询 (40 行 LIMIT) 在 1s 周期内开销可忽略。_opencode_db_paths() 枚举 ~/.hermes/profiles/*/home/.local/share/opencode/opencode.db, 对每个 DB 独立定位+推断, last_activity 最新者胜。这是实测发现: worker (omo-pm profile) 的会话不在默认 DB 中。mode=ro + URI 打开: 绝不触碰活跃进程的 WAL。{error:"task not found"}; DB 不可读 → EXPLORE+"opencode.db unavailable"; 无 session → EXPLORE+"no session found yet"。SSE 流对 service 异常免疫 (捕获后照常 yield)。onmessage 按 payload 字符串去重 (1s 心跳大部分是重复 payload), 仅变化时重渲染; 终态 (status∈{done,blocked,failed}) 收到最后一帧后 es.close() 并刷新 timeline。Loading…