Commit 2a0ffb8a2f9884346495d7a490a999de49424ba3

Authored by yibo
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
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(&#39;/:id/nodes/:nodeId/force-fail&#39;, h((req, res) =&gt; {
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 +});
... ...