Commit 2a0ffb8a2f9884346495d7a490a999de49424ba3
1 parent
0cb55d96
feat(graph-run): planning/reflection 轨迹落进 timeline.ndjson(42 号文档 Phase 4)
org-coder 等 9 个角色(含 _base 骨架模板)的四段式阶段流转靠调
bin/lemon.js task transition,打到 agent-chat.js 的 /task/transition——
这个端点靠自己的 sessions Map 找会话,work graph 节点场景下找不到,
阶段流转静默跳过,中间产物不进任何跨节点共享的轨迹。
调研确认范围后选了最保守的实现:不改 bin/lemon.js 的 CLI 签名、不碰
agent-chat.js(当天正被另一个并行会话实时编辑,风险太高)。关键发现
是 --artifact 参数本来就是 .../runs/{rootRunId}/nodes/{nodeRunId}/...
形状的路径,两个 ID 已经现成编码在里面,不需要给子进程塞新环境变量,
不需要改一个字的 agent.md 提示词。
bin/lemon.js 在原有调用之外 best-effort 追加一次 POST
/api/graph-runs/phase-note,新端点从 artifact 路径解出两个 ID,
校验 run 真实存在后调 emitEvent() 追加 timeline 记录,只追加不碰
graph_run_nodes/graph_runs 任何一行——不影响 needs/produces 契约。
P0 拦截/OKR 更新两段旧逻辑维持原样,新端点是独立的第二条路径,不是
迁移。
新增测试 4 例,全量测试 1503/1506,零回归(剩余失败全部来自另一个
并行会话进行中、还未提交的工作)。42 号文档 Phase 1-4 全部完工。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwG4b7Nq1NLR9eeE3M9Us9
Showing
4 changed files
with
223 additions
and
2 deletions
bin/lemon.js
| ... | ... | @@ -184,6 +184,16 @@ async function run() { |
| 184 | 184 | phase: values.phase, artifact: values.artifact |
| 185 | 185 | }); |
| 186 | 186 | console.log(JSON.stringify(res.data, null, 2)); |
| 187 | + | |
| 188 | + // 42 号文档 Phase 4:额外打一次 graph-runs 的 phase-note 端点,把这次阶段流转 | |
| 189 | + // 落进 timeline.ndjson(大多数调用方——legacy 会话式 agent——这个端点会安静 | |
| 190 | + // no-op,因为 --artifact 不是 work graph 节点产物目录的形状;不影响上面那次 | |
| 191 | + // 调用的结果,出错也不影响本命令的退出码)。 | |
| 192 | + try { | |
| 193 | + await request('POST', '/graph-runs/phase-note', { | |
| 194 | + artifact: values.artifact, phase: values.phase, agentId: values.agentId | |
| 195 | + }); | |
| 196 | + } catch { /* best-effort,静默丢弃 */ } | |
| 187 | 197 | } |
| 188 | 198 | else if (command === 'pipeline create') { |
| 189 | 199 | const { values } = parseArgs({ args: args.slice(2), options: { | ... | ... |
docs/todo/system-design/42-org-work-graph-reliability-followups.md
| 1 | 1 | # 42. Org/Work Graph 扳撠 39 ﹝ |
| 2 | 2 | |
| 3 | -> ****嚗hase 1-3 撌脣嚗2026-08-30嚗hase 3 垢蝻箔甈∠撉砲秩hase 4 敺撌乓 | |
| 3 | +> ****嚗hase 1-4 撌脣嚗2026-08-30嚗hase 3 垢蝻箔甈∠撉砲秩雿歇蝻箏 | |
| 4 | 4 | > ****嚗2026-08-30 |
| 5 | 5 | > ****嚗39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) P1-P5 摰甈∠垢蝡舀遣 銝 goal `ceo` 銝銝芸揪虜laywright 5/5 撉**1469 銝芸蝏選活瘚撠 3 銝芸瘝∟憸**遢﹝甈∪銝撌仿◇摨葵 phase |
| 6 | 6 | > **霂餉◆**嚗葵 phase 膩甈∠嚗瘚雿辣/銵靘摰 |
| ... | ... | @@ -93,7 +93,20 @@ Phase 4嚗imeline.ndjson planning/reflection 頧刻蕨 蝏扳 |
| 93 | 93 | |
| 94 | 94 | --- |
| 95 | 95 | |
| 96 | -## 鈭hase 4嚗timeline.ndjson` planning/reflection 頧刻蕨 | |
| 96 | +## 鈭hase 4嚗timeline.ndjson` planning/reflection 頧刻蕨 撌脣嚗2026-08-30嚗 | |
| 97 | + | |
| 98 | +**靚捏嚗挽霈∟銝甇伐**嚗grep` 隞 `system/agents/*/agent.md`嚗 `task transition` 9 銝芰嚗_base`code-planner`coder`org-coder`org-planner`org-writer`pricing-api-integrator`unit-tester`workgraph-planning`嚗葵 `zz-*` 瘚允銝**`_base` **撱 agent 憭爸璅⊥嚗餈恣葵閫靘銝"憭畾菜蓮"餈蝐餉蝥血遙雿誧 `_base` 閫 | |
| 99 | + | |
| 100 | +**嚗摰銝芸撘**嚗AgentManager.callAgent()` `metadata`嚗 `taskId`/`nodeRunId`/`rootRunId`嚗**餈蝡舀敹 hook嚗gent 霂颱**嚗誨釣 agent 蝘漣敶撠曹誑 `data/companies/{cid}/runs/{rootRunId}/nodes/{nodeRunId}/` 餈葵敶Y內霂describeArtifactContract`嚗 `--artifact` 撠望餈葵敶辣頝臬**銝支葵 ID 撌脩蝻靚銝銝隡銝芸**嚗閬 `AgentManager`/摮遙雿憓閬銝銝芸 agent.md 內霂 | |
| 101 | + | |
| 102 | +**摰**嚗挽霈⊿"銝斤"撖孵**銝 `bin/lemon.js` CLI 蝑曉1 `agent-chat.js`**餈靽楝嚗 | |
| 103 | +- `bin/lemon.js` `task transition` 隞支笆 `/agent/:agentId/task/transition`嚗egacy嚗摰銝 best-effort 甈⊥蝡舐 `POST /api/graph-runs/phase-note`嚗ody 隡 `artifact`/`phase`/`agentId`嚗仃韐仿蔣隞斗頨怎/ | |
| 104 | +- 蝡舐隞 `artifact` 頝臬迤圾 `rootRunId`/`nodeRunId`嚗撉 `rootRunId` 撖孵 run嚗撠望 legacy 隡楝敺耦嚗 `not-a-graph-artifact`嚗秤嚗撉 `graph-run-sse.emitEvent()` 餈賢 `kind:'agent.phase'` timeline 霈啣ref` `nodeRunId` | |
| 105 | +- **`agent-chat.js` 摰瘝⊥**活撘鈭敶予餈葵辣甇◤銝銝芸僎銵蝻git status` 蝷箸漱靽格嚗蝣唬銝芣迤鋡怠鈭箸鈭急辣憌憭芷餈活 Phase 1-3 撌脩 `graph-run.js`嚗漣漱 | |
| 106 | +- **"銝"颲寧&霈斗**嚗蝡舐靚 `emitEvent`嚗滲餈賢 timeline嚗apply` 隡 `null`嚗1 `graph_run_nodes`/`graph_runs` 隞颱銵閮鈭嚗靚 | |
| 107 | +- **霈曇恣 open 洵銝葵憸0 /OKR 停甇文**嚗"摰銝"舅畾菟餉 legacy `/task/transition` 銵銝sessions` Map 銝隡撠曹瓷閫血活銋瓷悟摰賑閫血蝡舐摰蝡洵鈭頝臬撖寞蝡舐宏 | |
| 108 | + | |
| 109 | +**瘚**嚗server/tests/unit/graph-run-phase-note.test.js`嚗4 靘鈭抒頝臬 timeline 銝蔣楝敺耦銝笆摰歲餈楝敺耦撖嫣 run 銝摰歲餈瓷隡 artifact `no-artifact`嚗 1503/1506嚗雿 3 銝芸仃韐亙銝銝芸僎銵迤餈葉瓷漱極雿 | |
| 97 | 110 | |
| 98 | 111 | **嚗葵 phase 憭抒銝迎**嚗org-coder` 挾撘ESEARCHLANXECUTEERIFY嚗蓮 `bin/lemon.js task transition`嚗 `agent-chat.js` `/task/transition` 蝡舐葵蝡舐撌梁 `sessions` Map "敶暑頝"嚗 `org-coder` 雿蛹 work graph 頝粥 `graph-engine.js` `AgentManager` `session-pool.js`嚗 CLAUDE.md 撠梯扇敶"銝支葵鈭撟脩"銝芸銝芸拿org-coder` work graph 瘚蓮誘嚗sessions.entries()` 銝摰畾菜蓮餉0 摰⊥KR 摮嚗**歲餈**嚗葉鈭抒嚗_research.md`/`_plan.md`/`_lessons.md`嚗 `graph_files` 憟漲嚗葵"銝"嚗**銝遙雿楊/頝刻鈭怎膘餈**嚗犖蝐餅 `ceo` 鈭"餈甇亙"嚗霂餃 agent 隡 transcript嚗GET /:id/nodes/:nodeRunId/transcript`嚗瓷 |
| 99 | 112 | ... | ... |
server/routes/graph-run.js
| ... | ... | @@ -411,6 +411,47 @@ router.post('/:id/nodes/:nodeId/force-fail', h((req, res) => { |
| 411 | 411 | })); |
| 412 | 412 | |
| 413 | 413 | /** |
| 414 | + * POST /api/graph-runs/phase-note — 42 号文档 Phase 4:planning/reflection 轨迹落进 timeline。 | |
| 415 | + * body: { artifact: string, phase?: string, agentId?: string } | |
| 416 | + * | |
| 417 | + * `bin/lemon.js task transition` 在打 legacy 的 `/api/agent/:agentId/task/transition` | |
| 418 | + * 之外,额外(best-effort、不影响主流程)打这一个端点。它不需要 `:id`——调用方(跑在 | |
| 419 | + * work graph 节点里的 agent)不知道自己的 runId,只知道自己刚写的 `--artifact` 绝对路径; | |
| 420 | + * 这个路径的形状(`.../runs/{rootRunId}/nodes/{nodeRunId}/...`)本身就带着两个 ID | |
| 421 | + * (节点私有产物目录的既有约定,见 `graph-engine.js` 的 `nodeArtifactDir`/`runRootDir`), | |
| 422 | + * 从路径里解出来就行,不用改任何 agent.md 的提示词、也不用改 `AgentManager` 往子进程塞 | |
| 423 | + * 新的环境变量。 | |
| 424 | + * | |
| 425 | + * 解不出来 / 解出来的 rootRunId 不对应真实存在的 run(legacy 会话式 agent 调用这条命令时 | |
| 426 | + * 就是这样——它们的 `--artifact` 压根不是这个路径形状):安静跳过,返回 `{ok:false, | |
| 427 | + * reason:'not-a-graph-artifact'}`,不是错误——这条端点存在的前提就是"大多数调用方都用不上它"。 | |
| 428 | + * | |
| 429 | + * 只做**追加**:不碰 `graph_run_nodes`/`graph_runs` 任何一行,不影响 needs/produces 契约、 | |
| 430 | + * 不影响 validator 的判定依据(39 号文档 §七.2 定案的边界——private 产物不能变成契约输入)。 | |
| 431 | + */ | |
| 432 | +router.post('/phase-note', h((req, res) => { | |
| 433 | + const { artifact, phase, agentId } = req.body || {}; | |
| 434 | + if (typeof artifact !== 'string' || !artifact) { | |
| 435 | + return res.json({ ok: false, reason: 'no-artifact' }); | |
| 436 | + } | |
| 437 | + | |
| 438 | + const m = artifact.match(/[\\/]runs[\\/]([^\\/]+)[\\/]nodes[\\/]([^\\/]+)[\\/]/); | |
| 439 | + if (!m) return res.json({ ok: false, reason: 'not-a-graph-artifact' }); | |
| 440 | + const [, rootRunId, nodeRunId] = m; | |
| 441 | + | |
| 442 | + if (!getRun(rootRunId)) return res.json({ ok: false, reason: 'not-a-graph-artifact' }); | |
| 443 | + | |
| 444 | + const sse = require('../services/graph-run-sse'); | |
| 445 | + const event = sse.emitEvent( | |
| 446 | + rootRunId, 'agent.phase', | |
| 447 | + `${agentId || '(未知 agent)'} 阶段流转 → ${phase || '(未知阶段)'}`, | |
| 448 | + { agentId: agentId || null, phase: phase || null, artifact }, | |
| 449 | + { ref: nodeRunId, kind: 'agent.phase' } | |
| 450 | + ); | |
| 451 | + res.json({ ok: true, rootRunId, nodeRunId, timelineEventId: event && event.timeline && event.timeline.ts }); | |
| 452 | +})); | |
| 453 | + | |
| 454 | +/** | |
| 414 | 455 | * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列) |
| 415 | 456 | * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean } |
| 416 | 457 | * 可用条件:run_status = 'blocked' | ... | ... |
server/tests/unit/graph-run-phase-note.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — POST /api/graph-runs/phase-note(42 号文档 Phase 4) | |
| 3 | + * | |
| 4 | + * `bin/lemon.js task transition` 额外打这个端点,把 agent 的阶段流转(RESEARCH/PLAN/ | |
| 5 | + * EXECUTE/VERIFY 之类)落进 timeline.ndjson——org-coder 等角色调 legacy 的 | |
| 6 | + * `/api/agent/:agentId/task/transition` 时,work graph 节点场景下那个端点的 sessions | |
| 7 | + * Map 找不到会话,静默跳过;这条端点从 --artifact 路径本身(节点私有产物目录的既有形状 | |
| 8 | + * `.../runs/{rootRunId}/nodes/{nodeRunId}/...`)解出两个 ID,不需要任何新的环境变量或 | |
| 9 | + * agent.md 改动。 | |
| 10 | + * | |
| 11 | + * 钉住的边界(39 号文档 §七.2 定案):这条端点只追加 timeline,绝不碰 | |
| 12 | + * graph_run_nodes/graph_runs 任何一行——不影响 needs/produces 契约、不影响 validator | |
| 13 | + * 判定依据。 | |
| 14 | + * | |
| 15 | + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,与 graph-run-route-io.test.js 同法。 | |
| 16 | + */ | |
| 17 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | |
| 18 | +import fs from 'fs'; | |
| 19 | +import os from 'os'; | |
| 20 | +import path from 'path'; | |
| 21 | +import express from 'express'; | |
| 22 | + | |
| 23 | +const TEST_DB_PATH = '/tmp/test-graph-run-phase-note.sqlite'; | |
| 24 | +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | |
| 25 | +const MODULES = [ | |
| 26 | + '../../services/graph-db', | |
| 27 | + '../../services/graph-service', | |
| 28 | + '../../services/org-graph-service', | |
| 29 | + '../../services/work-graph-db', | |
| 30 | + '../../services/company-paths', | |
| 31 | + '../../services/graph-run-sse', | |
| 32 | + '../../services/graph-engine', | |
| 33 | + '../../routes/graph-run', | |
| 34 | +]; | |
| 35 | + | |
| 36 | +describe('POST /api/graph-runs/phase-note', () => { | |
| 37 | + let wdb; | |
| 38 | + let sse; | |
| 39 | + let srv; | |
| 40 | + let base; | |
| 41 | + let runsDir; | |
| 42 | + const companyId = 'co-phase-note'; | |
| 43 | + | |
| 44 | + beforeAll(async () => { | |
| 45 | + require('../helpers/ensure-test-company').ensureTestCompany(companyId); | |
| 46 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 47 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 48 | + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-note-runs-')); | |
| 49 | + process.env.GRAPH_RUNS_DIR = runsDir; | |
| 50 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 51 | + wdb = require('../../services/work-graph-db'); | |
| 52 | + sse = require('../../services/graph-run-sse'); | |
| 53 | + | |
| 54 | + const app = express(); | |
| 55 | + app.use(express.json()); | |
| 56 | + app.use('/api/graph-runs', require('../../routes/graph-run')); | |
| 57 | + await new Promise((resolve) => { | |
| 58 | + srv = app.listen(0, '127.0.0.1', () => { | |
| 59 | + base = `http://127.0.0.1:${srv.address().port}`; | |
| 60 | + resolve(); | |
| 61 | + }); | |
| 62 | + }); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + afterAll(async () => { | |
| 66 | + if (srv) await new Promise((r) => srv.close(r)); | |
| 67 | + require('../helpers/ensure-test-company').removeTestCompany(companyId); | |
| 68 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 69 | + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} | |
| 70 | + delete process.env.GRAPH_DB_PATH; | |
| 71 | + delete process.env.GRAPH_RUNS_DIR; | |
| 72 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 73 | + }); | |
| 74 | + | |
| 75 | + function ensureAgents(ids) { | |
| 76 | + const db = wdb.getDb(); | |
| 77 | + const now = new Date().toISOString(); | |
| 78 | + const ins = db.prepare(` | |
| 79 | + INSERT OR IGNORE INTO system_agents | |
| 80 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 81 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) | |
| 82 | + `); | |
| 83 | + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); | |
| 84 | + } | |
| 85 | + | |
| 86 | + function makeRun() { | |
| 87 | + ensureAgents(['org-coder']); | |
| 88 | + const g = wdb.createWorkGraph({ companyId, nodes: ['org-coder'], edges: [] }); | |
| 89 | + expect(g.errors || []).toEqual([]); | |
| 90 | + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); | |
| 91 | + require('../../services/graph-engine').markRunReady(run.id); | |
| 92 | + const node = wdb.getRunState(run.id).nodes[0]; | |
| 93 | + return { runId: run.id, nodeRunId: node.id }; | |
| 94 | + } | |
| 95 | + | |
| 96 | + async function post(body) { | |
| 97 | + const r = await fetch(`${base}/api/graph-runs/phase-note`, { | |
| 98 | + method: 'POST', | |
| 99 | + headers: { 'Content-Type': 'application/json' }, | |
| 100 | + body: JSON.stringify(body), | |
| 101 | + }); | |
| 102 | + return [r.status, await r.json()]; | |
| 103 | + } | |
| 104 | + | |
| 105 | + it('合法的节点产物路径 → 写进 timeline.ndjson,节点状态不受影响', async () => { | |
| 106 | + const { runId, nodeRunId } = makeRun(); | |
| 107 | + const beforeStatus = wdb.getRunState(runId).nodes[0].status; | |
| 108 | + const artifact = `/Users/x/lemonboss/data/companies/${companyId}/runs/${runId}/nodes/${nodeRunId}/_plan.md`; | |
| 109 | + | |
| 110 | + const [status, body] = await post({ artifact, phase: 'plan', agentId: `${companyId}:org-coder` }); | |
| 111 | + expect(status).toBe(200); | |
| 112 | + expect(body.ok).toBe(true); | |
| 113 | + expect(body.rootRunId).toBe(runId); | |
| 114 | + expect(body.nodeRunId).toBe(nodeRunId); | |
| 115 | + | |
| 116 | + // timeline.ndjson 里真的能查到这条记录 | |
| 117 | + const raw = fs.readFileSync(sse.timelinePath(runId), 'utf8'); | |
| 118 | + const lines = raw.trim().split('\n').map((l) => JSON.parse(l)); | |
| 119 | + const mine = lines.find((l) => l.kind === 'agent.phase' && l.ref === nodeRunId); | |
| 120 | + expect(mine).toBeTruthy(); | |
| 121 | + expect(mine.payload.phase).toBe('plan'); | |
| 122 | + expect(mine.payload.artifact).toBe(artifact); | |
| 123 | + expect(mine.payload.agentId).toBe(`${companyId}:org-coder`); | |
| 124 | + expect(mine.root_run_id).toBe(runId); | |
| 125 | + | |
| 126 | + // 不改契约面:节点状态原样,没有因为这条追加日志被结算或改动 | |
| 127 | + expect(wdb.getRunState(runId).nodes[0].status).toBe(beforeStatus); | |
| 128 | + }); | |
| 129 | + | |
| 130 | + it('artifact 路径不是节点产物目录形状(legacy 会话式 agent 的调用) → ok:false,安静跳过', async () => { | |
| 131 | + const [status, body] = await post({ | |
| 132 | + artifact: '/Users/x/lemonboss/system/agents/some-agent/co1/task-abc/_plan.md', | |
| 133 | + phase: 'plan', | |
| 134 | + agentId: 'co1:some-agent', | |
| 135 | + }); | |
| 136 | + expect(status).toBe(200); | |
| 137 | + expect(body.ok).toBe(false); | |
| 138 | + expect(body.reason).toBe('not-a-graph-artifact'); | |
| 139 | + }); | |
| 140 | + | |
| 141 | + it('artifact 路径形状对,但 rootRunId 不对应真实存在的 run → ok:false', async () => { | |
| 142 | + const [status, body] = await post({ | |
| 143 | + artifact: `/Users/x/lemonboss/data/companies/${companyId}/runs/run_does_not_exist/nodes/rn_does_not_exist/_plan.md`, | |
| 144 | + phase: 'plan', | |
| 145 | + }); | |
| 146 | + expect(status).toBe(200); | |
| 147 | + expect(body.ok).toBe(false); | |
| 148 | + expect(body.reason).toBe('not-a-graph-artifact'); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it('没传 artifact → ok:false,reason:no-artifact', async () => { | |
| 152 | + const [status, body] = await post({ phase: 'plan' }); | |
| 153 | + expect(status).toBe(200); | |
| 154 | + expect(body.ok).toBe(false); | |
| 155 | + expect(body.reason).toBe('no-artifact'); | |
| 156 | + }); | |
| 157 | +}); | ... | ... |