订阅协议
# 普通订阅 GET /api/devloop/projects/:pid/stream Accept: text/event-stream Authorization: Bearer <jwt> # 断线重连 GET /api/devloop/projects/:pid/stream Last-Event-ID: 1234 # 上次收到的最大 id # 服务端响应(标准 SSE 格式) HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive id: 1235 event: unit.state data: {"unit_id":"auth-be","from":"coding","to":"testing"} :keepalive # 每 25s 一次
核心事件(9 类)
① prd.updated · PM 写完 PRDL3
{ "rev": 3, "by": "pm-agent", "origin": "yibo", "changeSummary": "+ Section: OAuth login", "hasConflict": false }
发出时机:PM Agent 写完 prd.html,DocStore 校验通过后
消费者:Dashboard PRD diff 面板 · Architect 队列触发
② sdd.updated · Architect 写完 SDDL3 + L2
{ "rev": 5, "mode": "generate" | "patch", "derived_from": "prd@rev3", "units_diff": { "added": ["oauth-be", "oauth-fe"], "changed": [], "removed": [] } }
③ unit.state · Unit 状态机转换L1
{ "unit_id": "auth-be", "from": "coding", "to": "testing", "retries": 1, "reason": "coder_done" }
消费者:Dashboard DAG 节点重染色
④ agent.stream · Agent stdout/stderr 流L1 + L2
{ "agent": "tester", "unit_id": "auth-fe", "stream": "stdout" | "stderr", "chunk": "FAIL tests/login.spec.ts > invalid pwd\n" }
注:高频事件。前端 LiveAgentStream 用 RAF 批渲染,避免每条都触发 reactive 更新。
⑤ integration.result · L2 结果L2
{ "run_id": "run-int-20260513-1031", "ok": false, "failures_count": 1, "duration_ms": 8421 }
⑥ triage.result · BugTriager 归因完成L2
{ "category": "contract_mismatch", "confidence": 0.86, "affected_units": ["auth-be", "auth-fe"], "will_auto_apply": true }
⑦ human_intervention_needed · 需要人类介入L2 / L1 blocked
{ "reason": "low_confidence_triage" | "unit_blocked" | "l2_exhausted" | "prd_conflict", "suggestion": "SDD token 续期策略未规定,需要决策", "context": { /* triage.json / unit info / etc */ } }
消费者:Dashboard 弹紫色 toast。
⑧ project.shipped · 项目交付L2
{ "final_rev": { "prd": 3, "sdd": 5 }, "total_duration_ms": 1_842_000, "total_tokens": 487_321, "units": 12 }
UI 反应:整页 confetti 5s + L2 状态卡变深绿。
⑨ project.exhausted · L2 预算耗尽L2
{ "rounds": 3, "last_triage": { /* triage.json */ }, "next_action": "awaiting_human_decision" }
Agent 持久化事件(6 类)· P-8 触发
由 P-8 callAgentWithPersistence 流程发出,用于 Dashboard 渲染「Agent at step 3/5」+ 崩溃恢复对话框。
⑩ agent.started · Agent 调用开始
{ "agent": "architect", "call_id": "call-20260513-1031-7f3a", "unit_id": null, "input_summary": "mode=generate, prd@rev3" }
发出时机:statePath 写入 status=running 后立即
⑪ agent.todolist · Agent 写完 todolist
{ "agent": "architect", "call_id": "call-...", "items": [ { "id":"step-001", "description":"读 prd.html", "status":"todo" }, { "id":"step-002", "description":"识别功能列表", "status":"todo" }, ... ] }
消费者:Dashboard 展开 UnitDrawer / Agent panel 显示子任务列表
⑫ agent.step · 子任务进度更新
{ "agent": "architect", "call_id": "call-...", "item_id": "step-003", "from": "doing", "to": "done", "progress_pct": 60, "current_desc": "为 OAuth 拆 3 个 Unit" }
消费者:Dashboard 顶部 Agent 卡片显示「Architect: 3/5」+ desc
⑬ agent.done · 成功完成
{ "agent": "architect", "call_id": "call-...", "duration_ms": 42_318, "tokens_used": 8_421, "output_ref": "agent-state/snapshots/call-..-out.json" }
⑭ agent.error · 异常退出
{ "agent": "coder", "call_id": "call-...", "code": "SANDBOX_VIOLATION" | "TIMEOUT" | "AGENT_CRASH", "message": "试图写 ../profile-be/code/server.js", "recoverable": true }
⑮ resume_decision_needed · 重启后等决策
{ "agent": "architect", "call_id": "call-...", "last_step": 3, "total": 5, "paused_at": "2026-05-13T10:42:13Z", "resume_token": "rt-...", "options": ["resume_from_step", "restart_fresh", "abort"] }
发出时机:P-8 recoverOnBoot 扫到 status=running(即上次进程被杀)
UI 反应:Dashboard 顶部 banner 加紫色「⚠ 上次启动有 N 个 Agent 中断,需要决策」
事件全表
| id | event | 层 | 频率 | 关键 payload |
|---|---|---|---|---|
| 1 | prd.updated | L3 | 低 | rev, changeSummary, hasConflict |
| 2 | sdd.updated | L2/L3 | 低 | rev, mode, units_diff |
| 3 | unit.state | L1 | 中 | unit_id, from, to, retries |
| 4 | agent.stream | L1/L2 | 高(千 / 项目) | chunk |
| 5 | integration.result | L2 | 低(1-3 次 / 项目) | ok, failures_count |
| 6 | triage.result | L2 | 低 | category, confidence, affected_units |
| 7 | human_intervention_needed | — | 极低 | reason, suggestion |
| 8 | project.shipped | — | 1 次 | final_rev, total_tokens |
| 9 | project.exhausted | — | 极低 | rounds, last_triage |
| 10 | agent.started | L1/L2/L3 | 中 | agent, call_id, input_summary |
| 11 | agent.todolist | 同上 | 中 | items[] |
| 12 | agent.step | 同上 | 中 | item_id, from, to, progress_pct |
| 13 | agent.done | 同上 | 中 | duration_ms, tokens_used |
| 14 | agent.error | 同上 | 低 | code, message, recoverable |
| 15 | resume_decision_needed | boot | 极低 | options[] |
客户端订阅样板
// Vue 3 composable function useDevLoopStream(pid) { const es = new EventSource(`/api/devloop/projects/${pid}/stream`); es.addEventListener('unit.state', e => handleUnitState(JSON.parse(e.data))); es.addEventListener('agent.stream', e => handleStream(JSON.parse(e.data))); es.addEventListener('prd.updated', e => refreshPrd()); es.addEventListener('human_intervention_needed', e => showToast(JSON.parse(e.data))); es.addEventListener('project.shipped', () => celebrate()); // 浏览器原生支持 Last-Event-ID 自动重连,不需要手动写 onUnmounted(() => es.close()); }
事件序号与 Replay
每条事件在 stream.log 中按顺序持久化。Last-Event-ID 重连时,broker 从 log 文件读 id > 该值的所有事件批量补发,再切实时流。
# stream.log 格式(NDJSON) {"id":1234,"event":"unit.state","data":{...},"ts":1747120731} {"id":1235,"event":"agent.stream","data":{...},"ts":1747120732}
log 保留 7 天,超出自动 gzip 归档。