SSE 事件总线
15 类事件的完整规格。每条带 event 名、payload schema、发出时机、消费者。前端 Dashboard 是主消费者;CLI 工具也可订阅做实时监听。9 个核心 + 6 个 Agent 持久化事件。
订阅协议
# 普通订阅
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 归档。