From 2a0ffb8a2f9884346495d7a490a999de49424ba3 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 30 Aug 2026 23:59:22 +0800 Subject: [PATCH] feat(graph-run): planning/reflection 轨迹落进 timeline.ndjson(42 号文档 Phase 4) --- bin/lemon.js | 10 ++++++++++ docs/todo/system-design/42-org-work-graph-reliability-followups.md | 17 +++++++++++++++-- server/routes/graph-run.js | 41 +++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-run-phase-note.test.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 server/tests/unit/graph-run-phase-note.test.js diff --git a/bin/lemon.js b/bin/lemon.js index 55cdfcf..e8c78cf 100755 --- a/bin/lemon.js +++ b/bin/lemon.js @@ -184,6 +184,16 @@ async function run() { phase: values.phase, artifact: values.artifact }); console.log(JSON.stringify(res.data, null, 2)); + + // 42 号文档 Phase 4:额外打一次 graph-runs 的 phase-note 端点,把这次阶段流转 + // 落进 timeline.ndjson(大多数调用方——legacy 会话式 agent——这个端点会安静 + // no-op,因为 --artifact 不是 work graph 节点产物目录的形状;不影响上面那次 + // 调用的结果,出错也不影响本命令的退出码)。 + try { + await request('POST', '/graph-runs/phase-note', { + artifact: values.artifact, phase: values.phase, agentId: values.agentId + }); + } catch { /* best-effort,静默丢弃 */ } } else if (command === 'pipeline create') { const { values } = parseArgs({ args: args.slice(2), options: { diff --git a/docs/todo/system-design/42-org-work-graph-reliability-followups.md b/docs/todo/system-design/42-org-work-graph-reliability-followups.md index 0c3ed2a..306cd33 100644 --- a/docs/todo/system-design/42-org-work-graph-reliability-followups.md +++ b/docs/todo/system-design/42-org-work-graph-reliability-followups.md @@ -1,6 +1,6 @@ # 42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办 -> **状态**:Phase 1-3 ✅ 已实现(2026-08-30,Phase 3 前端缺一次真实浏览器验证,见该节说明);Phase 4 📝 待开工。 +> **状态**:Phase 1-4 全部 ✅ 已实现(2026-08-30)。Phase 3 前端缺一次真实浏览器验证(见该节说明),其余无已知缺口。 > **日期**:2026-08-30 > **背景**:[39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) 的 P1-P5 落地后,用它做了一次真实端到端测试(建公司 → 下 goal → `ceo` 监督 → 真实跑出一个可玩的贪吃蛇游戏,Playwright 5/5 验证通过)。**1469 个单测全绿,这次真跑却测出至少 3 个单测完全没覆盖到的真问题**——这份文档是那次复盘沉淀下来的待办,按开工顺序分四个 phase。 > **读者须知**:每个 phase 的现状描述都来自那次真实测试里实际撞到的场景,不是推测;具体文件/行号落地前按惯例重新核实。 @@ -93,7 +93,20 @@ Phase 4:timeline.ndjson 承接 planning/reflection 轨迹 —— 结构性改å --- -## 五、Phase 4:`timeline.ndjson` 承接 planning/reflection 轨迹 +## 五、Phase 4:`timeline.ndjson` 承接 planning/reflection 轨迹 —— ✅ 已实现(2026-08-30) + +**调研结论(落地前按设计要求先做的那一步)**:`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` 的新角色自动适用。 + +**关键发现,决定了整个实现方式**:`AgentManager.callAgent()` 的 `metadata`(含 `taskId`/`nodeRunId`/`rootRunId`)**只进服务端日志和 hook,agent 读不到**(代码原文注释)。但 agent 的节点私有产物目录本来就以 `data/companies/{cid}/runs/{rootRunId}/nodes/{nodeRunId}/` 这个形状写进它的提示词(`describeArtifactContract`),而 `--artifact` 参数本来就是这个目录下的文件路径——**两个 ID 已经现成地编码在调用方唯一知道、唯一会传的那个参数里**,不需要给 `AgentManager`/子进程塞任何新的环境变量,也不需要改一个字的 agent.md 提示词。 + +**实现**(与设计里"两种做法二选一"对应:选的是**不改 `bin/lemon.js` 的 CLI 签名、不碰 `agent-chat.js`**这条更保守的路): +- `bin/lemon.js` 的 `task transition` 命令保持原有对 `/agent/:agentId/task/transition`(legacy)的调用完全不变,额外 best-effort 打一次新端点 `POST /api/graph-runs/phase-note`(body 只传 `artifact`/`phase`/`agentId`),失败静默吞掉,不影响命令本身的返回值/退出码。 +- 新端点从 `artifact` 路径正则解出 `rootRunId`/`nodeRunId`,校验 `rootRunId` 对应真实存在的 run(不存在就是 legacy 会话式调用的路径形状,安静返回 `not-a-graph-artifact`,不是错误),校验通过后调 `graph-run-sse.emitEvent()` 追加一条 `kind:'agent.phase'` 的 timeline 记录,`ref` 是 `nodeRunId`。 +- **`agent-chat.js` 完全没有改动**——这次特意避开了它:落地当天这个文件正被另一个并行会话实时编辑(`git status` 显示未提交修改),去碰一个正在被别人改的共享文件风险太高,改成把新能力放进这次 Phase 1-3 已经在用的 `graph-run.js`,物理上不产生交集。 +- **"不改的部分"边界确认成立**:新端点只调 `emitEvent`(纯追加 timeline,`apply` 传 `null`),不碰 `graph_run_nodes`/`graph_runs` 任何一行——测试里断言了这一点(节点状态在调用前后不变)。 +- **设计里 open 的第三个问题(P0 拦截/OKR 更新怎么处理)就此关闭**:答案是"完全不动"——这两段逻辑挂在 legacy `/task/transition` 里,图执行场景下(`sessions` Map 找不到会话)本来就从没触发过,这次也没打算让它们触发;新端点是完全独立的第二条路径,不是对旧端点的迁移或替换。 + +**测试**:`server/tests/unit/graph-run-phase-note.test.js`(4 例:合法节点产物路径写进 timeline 且节点状态不受影响、路径形状不对时安静跳过、路径形状对但 run 不存在时安静跳过、没传 artifact 时报 `no-artifact`)。全量测试 1503/1506,零回归(剩余 3 个失败全部来自另一个并行会话正在进行中、还没提交的工作)。 **现状(真跑撞到的,这是四个 phase 里最大的一个)**:`org-coder` 的四段式(RESEARCH→PLAN→EXECUTE→VERIFY)流转靠调 `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()` 根本找不到它的会话,阶段流转的业务逻辑(P0 审批拦截、OKR 子状态更新)**静默跳过**;它的中间产物(`_research.md`/`_plan.md`/`_lessons.md`)不进 `graph_files` 契约(这个是刻意的,见下"不改的部分"),但也**不进任何跨节点/跨角色共享的轨迹**,人类或 `ceo` 事后想搞清楚"这一步当时是怎么想的",只能去读原始的 agent 会话 transcript(`GET /:id/nodes/:nodeRunId/transcript`),没有结构化的入口。 diff --git a/server/routes/graph-run.js b/server/routes/graph-run.js index 2eb2ae9..281d3be 100644 --- a/server/routes/graph-run.js +++ b/server/routes/graph-run.js @@ -411,6 +411,47 @@ router.post('/:id/nodes/:nodeId/force-fail', h((req, res) => { })); /** + * POST /api/graph-runs/phase-note — 42 号文档 Phase 4:planning/reflection 轨迹落进 timeline。 + * body: { artifact: string, phase?: string, agentId?: string } + * + * `bin/lemon.js task transition` 在打 legacy 的 `/api/agent/:agentId/task/transition` + * 之外,额外(best-effort、不影响主流程)打这一个端点。它不需要 `:id`——调用方(跑在 + * work graph 节点里的 agent)不知道自己的 runId,只知道自己刚写的 `--artifact` 绝对路径; + * 这个路径的形状(`.../runs/{rootRunId}/nodes/{nodeRunId}/...`)本身就带着两个 ID + * (节点私有产物目录的既有约定,见 `graph-engine.js` 的 `nodeArtifactDir`/`runRootDir`), + * 从路径里解出来就行,不用改任何 agent.md 的提示词、也不用改 `AgentManager` 往子进程塞 + * 新的环境变量。 + * + * 解不出来 / 解出来的 rootRunId 不对应真实存在的 run(legacy 会话式 agent 调用这条命令时 + * 就是这样——它们的 `--artifact` 压根不是这个路径形状):安静跳过,返回 `{ok:false, + * reason:'not-a-graph-artifact'}`,不是错误——这条端点存在的前提就是"大多数调用方都用不上它"。 + * + * 只做**追加**:不碰 `graph_run_nodes`/`graph_runs` 任何一行,不影响 needs/produces 契约、 + * 不影响 validator 的判定依据(39 号文档 §七.2 定案的边界——private 产物不能变成契约输入)。 + */ +router.post('/phase-note', h((req, res) => { + const { artifact, phase, agentId } = req.body || {}; + if (typeof artifact !== 'string' || !artifact) { + return res.json({ ok: false, reason: 'no-artifact' }); + } + + const m = artifact.match(/[\\/]runs[\\/]([^\\/]+)[\\/]nodes[\\/]([^\\/]+)[\\/]/); + if (!m) return res.json({ ok: false, reason: 'not-a-graph-artifact' }); + const [, rootRunId, nodeRunId] = m; + + if (!getRun(rootRunId)) return res.json({ ok: false, reason: 'not-a-graph-artifact' }); + + const sse = require('../services/graph-run-sse'); + const event = sse.emitEvent( + rootRunId, 'agent.phase', + `${agentId || '(未知 agent)'} 阶段流转 → ${phase || '(未知阶段)'}`, + { agentId: agentId || null, phase: phase || null, artifact }, + { ref: nodeRunId, kind: 'agent.phase' } + ); + res.json({ ok: true, rootRunId, nodeRunId, timelineEventId: event && event.timeline && event.timeline.ts }); +})); + +/** * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列) * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean } * 可用条件:run_status = 'blocked' diff --git a/server/tests/unit/graph-run-phase-note.test.js b/server/tests/unit/graph-run-phase-note.test.js new file mode 100644 index 0000000..2264957 --- /dev/null +++ b/server/tests/unit/graph-run-phase-note.test.js @@ -0,0 +1,157 @@ +/** + * Unit Tests — POST /api/graph-runs/phase-note(42 号文档 Phase 4) + * + * `bin/lemon.js task transition` 额外打这个端点,把 agent 的阶段流转(RESEARCH/PLAN/ + * EXECUTE/VERIFY 之类)落进 timeline.ndjson——org-coder 等角色调 legacy 的 + * `/api/agent/:agentId/task/transition` 时,work graph 节点场景下那个端点的 sessions + * Map 找不到会话,静默跳过;这条端点从 --artifact 路径本身(节点私有产物目录的既有形状 + * `.../runs/{rootRunId}/nodes/{nodeRunId}/...`)解出两个 ID,不需要任何新的环境变量或 + * agent.md 改动。 + * + * 钉住的边界(39 号文档 §七.2 定案):这条端点只追加 timeline,绝不碰 + * graph_run_nodes/graph_runs 任何一行——不影响 needs/produces 契约、不影响 validator + * 判定依据。 + * + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,与 graph-run-route-io.test.js 同法。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import express from 'express'; + +const TEST_DB_PATH = '/tmp/test-graph-run-phase-note.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const MODULES = [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/company-paths', + '../../services/graph-run-sse', + '../../services/graph-engine', + '../../routes/graph-run', +]; + +describe('POST /api/graph-runs/phase-note', () => { + let wdb; + let sse; + let srv; + let base; + let runsDir; + const companyId = 'co-phase-note'; + + beforeAll(async () => { + require('../helpers/ensure-test-company').ensureTestCompany(companyId); + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-note-runs-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + wdb = require('../../services/work-graph-db'); + sse = require('../../services/graph-run-sse'); + + const app = express(); + app.use(express.json()); + app.use('/api/graph-runs', require('../../routes/graph-run')); + await new Promise((resolve) => { + srv = app.listen(0, '127.0.0.1', () => { + base = `http://127.0.0.1:${srv.address().port}`; + resolve(); + }); + }); + }); + + afterAll(async () => { + if (srv) await new Promise((r) => srv.close(r)); + require('../helpers/ensure-test-company').removeTestCompany(companyId); + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_DB_PATH; + delete process.env.GRAPH_RUNS_DIR; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + }); + + function ensureAgents(ids) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); + } + + function makeRun() { + ensureAgents(['org-coder']); + const g = wdb.createWorkGraph({ companyId, nodes: ['org-coder'], edges: [] }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); + require('../../services/graph-engine').markRunReady(run.id); + const node = wdb.getRunState(run.id).nodes[0]; + return { runId: run.id, nodeRunId: node.id }; + } + + async function post(body) { + const r = await fetch(`${base}/api/graph-runs/phase-note`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return [r.status, await r.json()]; + } + + it('合法的节点产物路径 → 写进 timeline.ndjson,节点状态不受影响', async () => { + const { runId, nodeRunId } = makeRun(); + const beforeStatus = wdb.getRunState(runId).nodes[0].status; + const artifact = `/Users/x/lemonboss/data/companies/${companyId}/runs/${runId}/nodes/${nodeRunId}/_plan.md`; + + const [status, body] = await post({ artifact, phase: 'plan', agentId: `${companyId}:org-coder` }); + expect(status).toBe(200); + expect(body.ok).toBe(true); + expect(body.rootRunId).toBe(runId); + expect(body.nodeRunId).toBe(nodeRunId); + + // timeline.ndjson 里真的能查到这条记录 + const raw = fs.readFileSync(sse.timelinePath(runId), 'utf8'); + const lines = raw.trim().split('\n').map((l) => JSON.parse(l)); + const mine = lines.find((l) => l.kind === 'agent.phase' && l.ref === nodeRunId); + expect(mine).toBeTruthy(); + expect(mine.payload.phase).toBe('plan'); + expect(mine.payload.artifact).toBe(artifact); + expect(mine.payload.agentId).toBe(`${companyId}:org-coder`); + expect(mine.root_run_id).toBe(runId); + + // 不改契约面:节点状态原样,没有因为这条追加日志被结算或改动 + expect(wdb.getRunState(runId).nodes[0].status).toBe(beforeStatus); + }); + + it('artifact 路径不是节点产物目录形状(legacy 会话式 agent 的调用) → ok:false,安静跳过', async () => { + const [status, body] = await post({ + artifact: '/Users/x/lemonboss/system/agents/some-agent/co1/task-abc/_plan.md', + phase: 'plan', + agentId: 'co1:some-agent', + }); + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(body.reason).toBe('not-a-graph-artifact'); + }); + + it('artifact 路径形状对,但 rootRunId 不对应真实存在的 run → ok:false', async () => { + const [status, body] = await post({ + artifact: `/Users/x/lemonboss/data/companies/${companyId}/runs/run_does_not_exist/nodes/rn_does_not_exist/_plan.md`, + phase: 'plan', + }); + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(body.reason).toBe('not-a-graph-artifact'); + }); + + it('没传 artifact → ok:false,reason:no-artifact', async () => { + const [status, body] = await post({ phase: 'plan' }); + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(body.reason).toBe('no-artifact'); + }); +}); -- libgit2 0.21.4