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,6 +184,16 @@ async function run() { | ||
| 184 | phase: values.phase, artifact: values.artifact | 184 | phase: values.phase, artifact: values.artifact |
| 185 | }); | 185 | }); |
| 186 | console.log(JSON.stringify(res.data, null, 2)); | 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 | else if (command === 'pipeline create') { | 198 | else if (command === 'pipeline create') { |
| 189 | const { values } = parseArgs({ args: args.slice(2), options: { | 199 | const { values } = parseArgs({ args: args.slice(2), options: { |
docs/todo/system-design/42-org-work-graph-reliability-followups.md
| 1 | # 42. Org/Work Graph 扳撠 39 ﹝ | 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 | > ****嚗2026-08-30 | 4 | > ****嚗2026-08-30 |
| 5 | > ****嚗39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) P1-P5 摰甈∠垢蝡舀遣 銝 goal `ceo` 銝銝芸揪虜laywright 5/5 撉**1469 銝芸蝏選活瘚撠 3 銝芸瘝∟憸**遢﹝甈∪銝撌仿◇摨葵 phase | 5 | > ****嚗39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) P1-P5 摰甈∠垢蝡舀遣 銝 goal `ceo` 銝銝芸揪虜laywright 5/5 撉**1469 銝芸蝏選活瘚撠 3 銝芸瘝∟憸**遢﹝甈∪銝撌仿◇摨葵 phase |
| 6 | > **霂餉◆**嚗葵 phase 膩甈∠嚗瘚雿辣/銵靘摰 | 6 | > **霂餉◆**嚗葵 phase 膩甈∠嚗瘚雿辣/銵靘摰 |
| @@ -93,7 +93,20 @@ Phase 4嚗imeline.ndjson planning/reflection 頧刻蕨 蝏扳 | @@ -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 | **嚗葵 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`嚗瓷 | 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,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 | * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列) | 455 | * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列) |
| 415 | * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean } | 456 | * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean } |
| 416 | * 可用条件:run_status = 'blocked' | 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 | +}); |