Commit f578c0e442762dfc1b78f3602d018b50b491639c
1 parent
8651b84a
refactor(task-decomposition): 46号文档 Phase 3 v2——CEO 直接派发,删掉认领机制
真实回归暴露"认领后没有触发源"的真问题,讨论后发现"认领"(pull,CAS 抢占)要解决的"多方竞争同一条任务"场景根本不存在——只有 CEO 一个人拍板 要不要派、派给谁。CEO 心跳统一发现+直接派发(push)成本更低,还能顺带 控制派发节奏,不需要认领。 删除:claimSubtask/unclaimSubtask(work-graph-db.js)、POST /claim、 POST /unclaim(company.js)、CLAIMABLE_WORK_ROLES 白名单、 claimable/claimed 两个 status 值、claimed_at 列。 改名:subtask_kind 的 'claimable_work' → 'decomposed_work' (materializeClaimableSubtasks → materializeDecomposedSubtasks), "认领"这个词不再准确,物化出的行现在就是普通 draft task。 新增: - triggerSubtaskPlanning 加递归防护(task.parent_task_id 非空直接跳过) ——设计 v2 时发现的真实漏洞:CEO 对子任务调 /enqueue 会默认 withSubtaskPlanning=true,不挡会对已经是"第几步"粒度的子任务再拆一层。 - createDecomposedWorkGraph:现造一张两节点轻量 work graph (impl=org-coder、validate=org-validator,agent_call 不是 subgraph), 跳过公司完整 org graph 的 brd/prd/sdd 规划仪式——subtask-planning 已经 把"怎么做"拆到这个粒度了,再走一遍纯属重复劳动。含一条没有真实验证过 的假设(org-coder/org-validator 裸调用会不会读不到预期上下文),如实 写在代码注释和文档里。 - enqueueTaskRun 按 subtask_kind 选图:decomposed_work 走轻量图, 普通 task 行为不变(回归测试覆盖)。 - ceo/heartbeat.md、tools.md 补了巡检 decomposed_work 草稿 + 判断节奏 + 调用现成 /enqueue 的指令。 测试:删除 task-pool-claim.test.js(随 v1 代码作废),新增 task-decomposition-dispatch.test.js(12 条),更新 task-decomposition-validator-gate.test.js 措辞。全量单测 1191 条, 1189 passed(唯二失败与本次改动无关),零残留 CLI 子进程。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H85SS5hVC4u1PbVhjmrhH8
Showing
9 changed files
with
420 additions
and
516 deletions
server/routes/company.js
| ... | ... | @@ -1026,9 +1026,8 @@ function taskView(t, progress = null, positions = null) { |
| 1026 | 1026 | // 43 号文档:子任务树的父子指针 + 这条 task 走哪种验收模式(看板据此决定要不要画子任务) |
| 1027 | 1027 | parentTaskId: t.parent_task_id || null, |
| 1028 | 1028 | acceptanceMode: t.acceptance_mode || 'holistic', |
| 1029 | - // 46 号文档 Phase 3:两种子任务靠这个区分——'acceptance_item' | 'claimable_work' | null | |
| 1029 | + // 46 号文档 Phase 3:两种子任务靠这个区分——'acceptance_item' | 'decomposed_work' | null | |
| 1030 | 1030 | subtaskKind: t.subtask_kind || null, |
| 1031 | - claimedAt: t.claimed_at || null, | |
| 1032 | 1031 | createdAt: t.created_at, |
| 1033 | 1032 | queuedAt: t.queued_at, |
| 1034 | 1033 | startedAt: t.started_at, |
| ... | ... | @@ -1112,9 +1111,20 @@ function isSlotRace(err) { |
| 1112 | 1111 | function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = false }) { |
| 1113 | 1112 | const wdb = require('../services/work-graph-db'); |
| 1114 | 1113 | |
| 1114 | + // 46 号文档 Phase 3(v2):decomposed_work 子任务不走公司完整 org graph(那是给顶层 task | |
| 1115 | + // 走 ceo→cto→org-planner→org-coder→org-validator 整套规划仪式的),现造一张最小的 | |
| 1116 | + // impl→validate work graph——`subtask-planning` 已经把"怎么做"拆到这一步的粒度了, | |
| 1117 | + // 再走一遍 org-planner 的 brd/prd/sdd 纯属重复劳动。见 `createDecomposedWorkGraph` 的注释, | |
| 1118 | + // 含一条没有真实验证过的假设(org-coder/org-validator 裸调用会不会读不到预期上下文)。 | |
| 1119 | + const isDecomposedSubtask = taskRow.subtask_kind === 'decomposed_work'; | |
| 1120 | + const graphKind = isDecomposedSubtask ? 'work' : 'org'; | |
| 1121 | + const graphId = isDecomposedSubtask | |
| 1122 | + ? wdb.createDecomposedWorkGraph({ companyId, task: taskRow }) | |
| 1123 | + : companyId; // kind='org' 时 graph_id 就是 company_id | |
| 1124 | + | |
| 1115 | 1125 | const created = wdb.createRun({ |
| 1116 | - graphKind: 'org', | |
| 1117 | - graphId: companyId, // kind='org' 时 graph_id 就是 company_id | |
| 1126 | + graphKind, | |
| 1127 | + graphId, | |
| 1118 | 1128 | companyId, |
| 1119 | 1129 | goalId: taskRow.goal_id, |
| 1120 | 1130 | taskId: taskRow.id, |
| ... | ... | @@ -2112,7 +2122,7 @@ router.get('/:companyId/tasks', (req, res) => { |
| 2112 | 2122 | goalId: req.query.goalId || req.query.goal_id || null, |
| 2113 | 2123 | // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务 |
| 2114 | 2124 | parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null, |
| 2115 | - // 46 号文档 Phase 3:`?subtaskKind=claimable_work&status=claimable` = 当前可认领池 | |
| 2125 | + // 46 号文档 Phase 3:`?subtaskKind=decomposed_work&status=draft` = CEO 心跳巡检对象 | |
| 2116 | 2126 | subtaskKind: req.query.subtaskKind || req.query.subtask_kind || null, |
| 2117 | 2127 | includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', |
| 2118 | 2128 | limit: Number.isInteger(limit) ? limit : null, |
| ... | ... | @@ -2391,81 +2401,6 @@ router.post('/:companyId/tasks/:taskId/subtask-result', (req, res) => { |
| 2391 | 2401 | }); |
| 2392 | 2402 | |
| 2393 | 2403 | /** |
| 2394 | - * 46 号文档 Phase 3:可认领子任务(`subtask_kind='claimable_work'`)先只对这个角色白名单开放。 | |
| 2395 | - * 写死不做能力匹配——系统里没有任何结构化的角色能力字段(调研已确认), | |
| 2396 | - * `org-coder` 是当前唯一的通用"写代码"角色,且物化出的 steps 基本都是实现类工作。 | |
| 2397 | - * 真需要扩展到多角色再加,不为假想的未来预先建模。 | |
| 2398 | - */ | |
| 2399 | -const CLAIMABLE_WORK_ROLES = ['org-coder']; | |
| 2400 | - | |
| 2401 | -/** | |
| 2402 | - * POST /api/company/:companyId/tasks/:taskId/claim — 46 号文档 Phase 3 | |
| 2403 | - * body: { agentId } | |
| 2404 | - * | |
| 2405 | - * 认领一条可认领子任务。全仓库第一个真正的"认领(pull)"语义端点——并发保护见 | |
| 2406 | - * `work-graph-db.claimSubtask`(CAS,`changes===0` 就是没抢到,返回 409 不是 500, | |
| 2407 | - * 这是预期会发生的正常竞争结果)。 | |
| 2408 | - */ | |
| 2409 | -router.post('/:companyId/tasks/:taskId/claim', (req, res) => { | |
| 2410 | - try { | |
| 2411 | - const wdb = require('../services/work-graph-db'); | |
| 2412 | - const task = requireTask(req, res); | |
| 2413 | - if (!task) return; | |
| 2414 | - if (task.subtask_kind !== 'claimable_work') { | |
| 2415 | - return res.status(400).json({ | |
| 2416 | - error: `${task.id} 不是可认领子任务(subtask_kind !== 'claimable_work')`, | |
| 2417 | - }); | |
| 2418 | - } | |
| 2419 | - const { agentId } = req.body || {}; | |
| 2420 | - if (!agentId) return res.status(400).json({ error: 'agentId 必填' }); | |
| 2421 | - | |
| 2422 | - const hired = listCompanyAgents(req.params.companyId) | |
| 2423 | - .find((r) => r.system_agent_id === agentId || r.role_name === agentId); | |
| 2424 | - if (!hired || !CLAIMABLE_WORK_ROLES.includes(hired.role_name)) { | |
| 2425 | - return res.status(403).json({ | |
| 2426 | - error: `${agentId} 不在可认领角色白名单里(${CLAIMABLE_WORK_ROLES.join(', ')})`, | |
| 2427 | - }); | |
| 2428 | - } | |
| 2429 | - | |
| 2430 | - const changed = wdb.claimSubtask(task.id, agentId); | |
| 2431 | - if (!changed) { | |
| 2432 | - return res.status(409).json({ | |
| 2433 | - error: `认领失败:${task.id} 已经不是可认领状态(被别人抢先认领,或状态已变化)`, | |
| 2434 | - }); | |
| 2435 | - } | |
| 2436 | - res.json({ ok: true, task: taskView(wdb.getTask(task.id)) }); | |
| 2437 | - } catch (err) { | |
| 2438 | - console.error('[TaskEntry] Error:', err.message); | |
| 2439 | - res.status(500).json({ error: err.message }); | |
| 2440 | - } | |
| 2441 | -}); | |
| 2442 | - | |
| 2443 | -/** | |
| 2444 | - * POST /api/company/:companyId/tasks/:taskId/unclaim — 46 号文档 Phase 3 | |
| 2445 | - * body: { agentId }:只有认领者本人能放弃(`work-graph-db.unclaimSubtask` 的 WHERE 挡着)。 | |
| 2446 | - */ | |
| 2447 | -router.post('/:companyId/tasks/:taskId/unclaim', (req, res) => { | |
| 2448 | - try { | |
| 2449 | - const wdb = require('../services/work-graph-db'); | |
| 2450 | - const task = requireTask(req, res); | |
| 2451 | - if (!task) return; | |
| 2452 | - const { agentId } = req.body || {}; | |
| 2453 | - if (!agentId) return res.status(400).json({ error: 'agentId 必填' }); | |
| 2454 | - | |
| 2455 | - const changed = wdb.unclaimSubtask(task.id, agentId); | |
| 2456 | - if (!changed) { | |
| 2457 | - return res.status(409).json({ | |
| 2458 | - error: `放弃失败:${task.id} 不是被 ${agentId} 认领的可认领子任务`, | |
| 2459 | - }); | |
| 2460 | - } | |
| 2461 | - res.json({ ok: true, task: taskView(wdb.getTask(task.id)) }); | |
| 2462 | - } catch (err) { | |
| 2463 | - console.error('[TaskEntry] Error:', err.message); | |
| 2464 | - res.status(500).json({ error: err.message }); | |
| 2465 | - } | |
| 2466 | -}); | |
| 2467 | - | |
| 2468 | -/** | |
| 2469 | 2404 | * POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3) |
| 2470 | 2405 | * |
| 2471 | 2406 | * 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列, | ... | ... |
server/services/graph-engine.js
| ... | ... | @@ -1880,9 +1880,10 @@ function settleNode(runId, nodeRunId, outcome = {}) { |
| 1880 | 1880 | if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) { |
| 1881 | 1881 | // 46 号文档 Phase 3:这条闸门只该看 43 号文档的验收条目子任务——`AND subtask_kind = |
| 1882 | 1882 | // 'acceptance_item'` 是必须的收窄,不是可选项。Phase 3 会往同一个 parent_task_id |
| 1883 | - // 底下物化第二种子任务(claimable_work,等着被认领执行,完成生命周期完全独立、 | |
| 1884 | - // 跟 coder-validator 的验收判定无关),不收窄这条查询会把它们也扫进来,天然不会 | |
| 1885 | - // 是 completed,直接把所有开了 subtask-planning 的 task 验收判定拖垮。 | |
| 1883 | + // 底下物化第二种子任务(decomposed_work,subtask-planning 拆出来等 CEO 派发执行的, | |
| 1884 | + // 完成生命周期完全独立、跟 coder-validator 的验收判定无关),不收窄这条查询会把 | |
| 1885 | + // 它们也扫进来,天然不会是 completed,直接把所有开了 subtask-planning 的 task | |
| 1886 | + // 验收判定拖垮。 | |
| 1886 | 1887 | const subtasks = getDb() |
| 1887 | 1888 | .prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) |
| 1888 | 1889 | .all(run0.task_id); |
| ... | ... | @@ -2434,7 +2435,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, |
| 2434 | 2435 | if (!task || task.acceptance_mode !== 'itemized') return; |
| 2435 | 2436 | |
| 2436 | 2437 | // 46 号文档 Phase 3:只数 acceptance_item——同一个 task 现在可能同时挂着 subtask-planning |
| 2437 | - // 物化的 claimable_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。 | |
| 2438 | + // 物化的 decomposed_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。 | |
| 2438 | 2439 | const already = getDb() |
| 2439 | 2440 | .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) |
| 2440 | 2441 | .get(task.id).n; | ... | ... |
server/services/subtask-planning.js
| ... | ... | @@ -349,6 +349,14 @@ function normalizeSteps(raw) { |
| 349 | 349 | async function triggerSubtaskPlanning({ run, task }) { |
| 350 | 350 | try { |
| 351 | 351 | if (!run || !task) return; |
| 352 | + // 46 号文档 Phase 3:子任务不该被再拆一层——`materializeDecomposedSubtasks` 物化出的行 | |
| 353 | + // 也会经过 enqueueTaskRun(CEO 派发时),如果不挡住,会对一条已经是"第几步怎么做"粒度的 | |
| 354 | + // 子任务再跑一次 subtask-planning,浪费一次 LLM 调用还可能无限递归拆下去。 | |
| 355 | + // 43 号文档的验收条目子任务同理(createTask 本身已经不许它们再声明 itemized)。 | |
| 356 | + if (task.parent_task_id) { | |
| 357 | + console.log(`[SubtaskPlanning] task ${task.id} 本身就是子任务(parent_task_id=${task.parent_task_id}),跳过`); | |
| 358 | + return; | |
| 359 | + } | |
| 352 | 360 | const { |
| 353 | 361 | steps, tokens, reasoningSource, experienceRefs, researchUsed, |
| 354 | 362 | } = await planSubtasks({ companyId: run.company_id, task }); |
| ... | ... | @@ -388,12 +396,12 @@ async function triggerSubtaskPlanning({ run, task }) { |
| 388 | 396 | nodeRunId: null, |
| 389 | 397 | }); |
| 390 | 398 | |
| 391 | - // 46 号文档 Phase 3:把这批 steps 物化成可认领子任务。跟上面写 artifact/登记索引一样, | |
| 392 | - // 走同一个 try/catch——物化失败不该让 subtask-plan.json 已经写好的部分失效。 | |
| 393 | - const materializedIds = wdb.materializeClaimableSubtasks({ task, steps }); | |
| 399 | + // 46 号文档 Phase 3:把这批 steps 物化成 decomposed_work 子任务。跟上面写 artifact/登记 | |
| 400 | + // 索引一样,走同一个 try/catch——物化失败不该让 subtask-plan.json 已经写好的部分失效。 | |
| 401 | + const materializedIds = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 394 | 402 | |
| 395 | 403 | console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),` |
| 396 | - + `物化 ${materializedIds.length} 条可认领子任务`); | |
| 404 | + + `物化 ${materializedIds.length} 条待 CEO 派发的子任务`); | |
| 397 | 405 | } catch (err) { |
| 398 | 406 | console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`); |
| 399 | 407 | } | ... | ... |
server/services/work-graph-db.js
| ... | ... | @@ -528,7 +528,6 @@ function getDb() { |
| 528 | 528 | description TEXT NOT NULL, -- 落进 task-goal.json 的那句;下达后不可改 |
| 529 | 529 | status TEXT NOT NULL DEFAULT 'draft', |
| 530 | 530 | -- draft | queued | running | completed | failed | blocked | cancelled |
| 531 | - -- | claimable | claimed(46 号文档 Phase 3,只对 subtask_kind='claimable_work' 有意义) | |
| 532 | 531 | -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现: |
| 533 | 532 | -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释 |
| 534 | 533 | execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), |
| ... | ... | @@ -562,13 +561,14 @@ function getDb() { |
| 562 | 561 | parent_task_id TEXT REFERENCES tasks(id), |
| 563 | 562 | -- 46 号文档 Phase 3:parent_task_id 非空的子任务分两种,靠这一列区分—— |
| 564 | 563 | -- 'acceptance_item' 43 号文档:验收条目物化(coder-validator 逐条上报,见 settleSubtaskResult) |
| 565 | - -- 'claimable_work' Phase 3:subtask-planning 步骤分解物化,等着被认领(见 claimSubtask) | |
| 564 | + -- 'decomposed_work' Phase 3:subtask-planning 步骤分解物化,等 CEO 心跳判断节奏后 enqueue | |
| 565 | + -- (v1 设计过"认领"pull 语义,真实回归后改成 CEO 直接派发的 push 语义—— | |
| 566 | + -- 认领需要的并发抢占场景不存在,CEO 一人拍板成本更低,历史决策见 46 号文档) | |
| 566 | 567 | -- NULL = 普通 task(不是子任务,或 parent_task_id 也是 NULL)。两套子任务共用同一张表 |
| 567 | 568 | -- (而不是各开一张),因为 tasks 表本来就是通用工作项容器,这一列只是给已经混杂的 |
| 568 | 569 | -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开 |
| 569 | 570 | -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。 |
| 570 | - subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','claimable_work') OR subtask_kind IS NULL), | |
| 571 | - claimed_at TEXT, -- 认领发生的时间;只对 claimable_work 有意义 | |
| 571 | + subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','decomposed_work') OR subtask_kind IS NULL), | |
| 572 | 572 | created_at TEXT NOT NULL, |
| 573 | 573 | updated_at TEXT NOT NULL, |
| 574 | 574 | queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) |
| ... | ... | @@ -657,15 +657,12 @@ function getDb() { |
| 657 | 657 | // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。 |
| 658 | 658 | db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(company_id, assignee_agent_id, created_at)`); |
| 659 | 659 | |
| 660 | - // ── 批 6:46 号文档 Phase 3,tasks 追加 subtask_kind / claimed_at ──────────── | |
| 660 | + // ── 批 6:46 号文档 Phase 3,tasks 追加 subtask_kind ───────────────────── | |
| 661 | 661 | // 同批 5 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上 subtask_kind 的取值域 |
| 662 | 662 | // 只由 createTask 的应用层校验守,与新建库的 CHECK 不同源。 |
| 663 | 663 | if (!hasColumn(db, 'tasks', 'subtask_kind')) { |
| 664 | 664 | db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`); |
| 665 | 665 | } |
| 666 | - if (!hasColumn(db, 'tasks', 'claimed_at')) { | |
| 667 | - db.exec(`ALTER TABLE tasks ADD COLUMN claimed_at TEXT`); | |
| 668 | - } | |
| 669 | 666 | |
| 670 | 667 | // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 |
| 671 | 668 | if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { |
| ... | ... | @@ -1906,7 +1903,6 @@ function createTask({ |
| 1906 | 1903 | parentTaskId = null, |
| 1907 | 1904 | acceptanceMode = 'holistic', |
| 1908 | 1905 | subtaskKind = null, |
| 1909 | - status = 'draft', | |
| 1910 | 1906 | } = {}) { |
| 1911 | 1907 | if (!goalId) throw new Error('createTask: goalId 必填'); |
| 1912 | 1908 | if (!title) throw new Error('createTask: title 必填'); |
| ... | ... | @@ -1917,21 +1913,15 @@ function createTask({ |
| 1917 | 1913 | if (parentTaskId && acceptanceMode === 'itemized') { |
| 1918 | 1914 | throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分'); |
| 1919 | 1915 | } |
| 1920 | - // 46 号文档 Phase 3:subtask_kind 只对子任务有意义,取值域应用层守(同上 acceptanceMode 那条注释) | |
| 1921 | - if (subtaskKind !== null && !['acceptance_item', 'claimable_work'].includes(subtaskKind)) { | |
| 1916 | + // 46 号文档 Phase 3:subtask_kind 只对子任务有意义,取值域应用层守(同上 acceptanceMode 那条注释)。 | |
| 1917 | + // 没有专属初始 status——decomposed_work 子任务就是普通 draft task,物化即走 | |
| 1918 | + // draft→(CEO 判断节奏后)enqueue 这条现成的路,不需要额外的状态。 | |
| 1919 | + if (subtaskKind !== null && !['acceptance_item', 'decomposed_work'].includes(subtaskKind)) { | |
| 1922 | 1920 | throw new Error(`createTask: subtaskKind 非法: ${subtaskKind}`); |
| 1923 | 1921 | } |
| 1924 | 1922 | if (subtaskKind && !parentTaskId) { |
| 1925 | 1923 | throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义'); |
| 1926 | 1924 | } |
| 1927 | - // 初始状态只放开 'draft'(现状)和 'claimable'(Phase 3:claimable_work 物化即开放认领, | |
| 1928 | - // 不需要走「先 draft 再手动下达」那一套——这批子任务从来就不打算过 task-scheduler)。 | |
| 1929 | - if (!['draft', 'claimable'].includes(status)) { | |
| 1930 | - throw new Error(`createTask: status 只能是 'draft' 或 'claimable'(初始状态),收到: ${status}`); | |
| 1931 | - } | |
| 1932 | - if (status === 'claimable' && subtaskKind !== 'claimable_work') { | |
| 1933 | - throw new Error(`createTask: status='claimable' 只对 subtaskKind='claimable_work' 有意义`); | |
| 1934 | - } | |
| 1935 | 1925 | |
| 1936 | 1926 | const db = getDb(); |
| 1937 | 1927 | const goal = getGoal(goalId); |
| ... | ... | @@ -1964,10 +1954,10 @@ function createTask({ |
| 1964 | 1954 | INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, |
| 1965 | 1955 | execution_mode, depends_on, priority, budget_json, report_to, created_by, |
| 1966 | 1956 | parent_task_id, acceptance_mode, subtask_kind, created_at, updated_at) |
| 1967 | - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 1957 | + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 1968 | 1958 | `); |
| 1969 | 1959 | const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( |
| 1970 | - tid, goalId, String(cid), nextSeq, String(title), String(description), status, | |
| 1960 | + tid, goalId, String(cid), nextSeq, String(title), String(description), | |
| 1971 | 1961 | executionMode, JSON.stringify(deps), priority, budget, |
| 1972 | 1962 | // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 |
| 1973 | 1963 | // goal 上有就继承下来,别让每个调用方各记一遍。 |
| ... | ... | @@ -2018,8 +2008,8 @@ function listCompanyTasks(companyId, { |
| 2018 | 2008 | where.push('parent_task_id = @parentTaskId'); |
| 2019 | 2009 | params.parentTaskId = parentTaskId; |
| 2020 | 2010 | } |
| 2021 | - // 46 号文档 Phase 3:`?subtaskKind=claimable_work&status=claimable` 就是"这家公司当前 | |
| 2022 | - // 可认领池"的查询——不需要专门的池子端点,复用这条通用列表接口加一个过滤参数。 | |
| 2011 | + // 46 号文档 Phase 3:`?subtaskKind=decomposed_work&status=draft` 就是 CEO 心跳巡检 | |
| 2012 | + // "有哪些子任务等我判断节奏"的查询——不需要专门的端点,复用这条通用列表接口加一个过滤参数。 | |
| 2023 | 2013 | if (subtaskKind) { |
| 2024 | 2014 | where.push('subtask_kind = @subtaskKind'); |
| 2025 | 2015 | params.subtaskKind = subtaskKind; |
| ... | ... | @@ -2058,10 +2048,7 @@ const TASK_PATCH_COLUMNS = { |
| 2058 | 2048 | * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次, |
| 2059 | 2049 | * `requeueDerivedBlockedTask` 把它连同 blocked_reason 一起推回 queued。 |
| 2060 | 2050 | */ |
| 2061 | -// 'claimable' 是 46 号文档 Phase 3 加的:claimable_work 子任务物化后要能回填 depends_on | |
| 2062 | -// (steps[].depends_on_hint 转真实 task id,见 materializeClaimableSubtasks),这批子任务 | |
| 2063 | -// 从来不过 task-scheduler,不存在"已经排队、依赖被别处消费过"的顾虑,放开无副作用。 | |
| 2064 | -const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked', 'claimable']; | |
| 2051 | +const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked']; | |
| 2065 | 2052 | |
| 2066 | 2053 | /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ |
| 2067 | 2054 | const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; |
| ... | ... | @@ -2188,54 +2175,12 @@ function settleSubtaskResult(taskId, status, summary = null) { |
| 2188 | 2175 | } |
| 2189 | 2176 | |
| 2190 | 2177 | /** |
| 2191 | - * 46 号文档 Phase 3:认领一条可认领子任务,claimable → claimed。 | |
| 2192 | - * | |
| 2193 | - * 全仓库第一个真正的"认领(pull)"语义写入——`assignee_agent_id` 此前所有写入点 | |
| 2194 | - * (`enqueueTaskRun`/`settleTaskFromRun` 的哨兵值兜底、`agent-chat.saveTaskMeta` 的 | |
| 2195 | - * 派发)都是"派发(push)":发起方单方面指定 `to`,从没有过"多方竞争同一条任务, | |
| 2196 | - * 谁先抢到算谁的"这种场景,所以也没有先例可抄,CAS 写法照抄 `claimQueuedTask`(同一份 | |
| 2197 | - * "changes 数决定谁赢"判据)。 | |
| 2198 | - * | |
| 2199 | - * 认领成功的同时把 `executor` 改成 `'agent'`——claimable_work 子任务生来就是要被某个 | |
| 2200 | - * agent 自主完成的,只是物化时还没有认领对象;一旦认领,它就是一条普通的 | |
| 2201 | - * `executor='agent'` 派单任务,后续走 `META_TO_TASK_STATUS` 那套既有生命周期 | |
| 2202 | - * (`agent-chat.js` 的 `/task/report`),不需要 Phase 3 再发明一套执行触发逻辑。 | |
| 2203 | - * | |
| 2204 | - * @returns {number} 1 = 认领成功;0 = 没抢到(已被认领 / 状态不对 / 不是可认领子任务) | |
| 2205 | - */ | |
| 2206 | -function claimSubtask(taskId, agentId) { | |
| 2207 | - if (!agentId) throw new Error('claimSubtask: agentId 必填'); | |
| 2208 | - const now = new Date().toISOString(); | |
| 2209 | - return getDb() | |
| 2210 | - .prepare(` | |
| 2211 | - UPDATE tasks | |
| 2212 | - SET status = 'claimed', executor = 'agent', assignee_agent_id = @agentId, | |
| 2213 | - claimed_at = @now, updated_at = @now | |
| 2214 | - WHERE id = @id AND subtask_kind = 'claimable_work' AND status = 'claimable' AND archived_at IS NULL | |
| 2215 | - `) | |
| 2216 | - .run({ id: taskId, agentId, now }).changes; | |
| 2217 | -} | |
| 2218 | - | |
| 2219 | -/** | |
| 2220 | - * 放弃认领:claimed → claimable,对称的 CAS。**只有认领者本人能放弃**—— | |
| 2221 | - * `assignee_agent_id = @agentId` 这道 WHERE 挡住"别人代为放弃",避免抢别人手上的活。 | |
| 2222 | - * @returns {number} 1 = 放弃成功;0 = 不是这个 agent 认领的 / 状态不对 | |
| 2223 | - */ | |
| 2224 | -function unclaimSubtask(taskId, agentId) { | |
| 2225 | - if (!agentId) throw new Error('unclaimSubtask: agentId 必填'); | |
| 2226 | - const now = new Date().toISOString(); | |
| 2227 | - return getDb() | |
| 2228 | - .prepare(` | |
| 2229 | - UPDATE tasks | |
| 2230 | - SET status = 'claimable', executor = 'graph', assignee_agent_id = NULL, | |
| 2231 | - claimed_at = NULL, updated_at = @now | |
| 2232 | - WHERE id = @id AND subtask_kind = 'claimable_work' AND status = 'claimed' AND assignee_agent_id = @agentId | |
| 2233 | - `) | |
| 2234 | - .run({ id: taskId, agentId, now }).changes; | |
| 2235 | -} | |
| 2236 | - | |
| 2237 | -/** | |
| 2238 | - * 46 号文档 Phase 3:把 `subtask-planning` 产出的 `steps[]` 物化成可认领子任务。 | |
| 2178 | + * 46 号文档 Phase 3(v2,2026-09-02 改版):把 `subtask-planning` 产出的 `steps[]` 物化成 | |
| 2179 | + * decomposed_work 子任务。**v1 曾经设计成"认领"(pull,`claimable`/`claimed` 两个专属状态 + | |
| 2180 | + * `claimSubtask`/`unclaimSubtask` 两个 CAS 函数),真实回归后拍板改掉**:认领要解决的是 | |
| 2181 | + * "多方竞争同一条任务",但实际执行者只有 CEO 一个人拍板要不要派、派给谁,压根不存在竞争, | |
| 2182 | + * CAS 是给不存在的问题找的解——现在直接砍掉,物化出的子任务就是普通 `draft` task, | |
| 2183 | + * CEO 心跳判断节奏后调现成的 `/tasks/:taskId/enqueue`(旧 API,不用再发明新端点)。 | |
| 2239 | 2184 | * |
| 2240 | 2185 | * 两阶段建法:先把全部行建好拿到真实 task id,再回填 `depends_on`——`steps[].depends_on_hint` |
| 2241 | 2186 | * 是这批 steps 内部的 1-based `seq` 引用,`subtask-planning.js` 的 `normalizeSteps` 只保证 |
| ... | ... | @@ -2243,7 +2188,10 @@ function unclaimSubtask(taskId, agentId) { |
| 2243 | 2188 | * 边建边填会在"前一步依赖后一步"这种前向引用上直接炸(`validateDependsOn` 要求依赖必须已存在)。 |
| 2244 | 2189 | * 先建后填就不用管顺序。 |
| 2245 | 2190 | * |
| 2246 | - * 幂等:已经物化过 `claimable_work` 子任务的 task 直接跳过,不重复物化(同款判据见 | |
| 2191 | + * `executionMode: 'auto'`——CEO 一旦决定 enqueue 就该一路跑完,不需要停在入口等人工 `/next` | |
| 2192 | + * (那是 `manual` 模式的语义,给"人下达"这个场景设计的,不适合"CEO 已经拍过板"这条路)。 | |
| 2193 | + * | |
| 2194 | + * 幂等:已经物化过 `decomposed_work` 子任务的 task 直接跳过,不重复物化(同款判据见 | |
| 2247 | 2195 | * `graph-engine.maybeMaterializeItemizedSubtasks` 的 `already` 检查——两处独立各写一份, |
| 2248 | 2196 | * 因为分属两个模块,不值得为一次判空抽个共享工具函数)。 |
| 2249 | 2197 | * |
| ... | ... | @@ -2253,11 +2201,11 @@ function unclaimSubtask(taskId, agentId) { |
| 2253 | 2201 | * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,depends_on_hint?:number[]}>}} ctx |
| 2254 | 2202 | * @returns {string[]} 物化出的 task id,按 steps 顺序;已物化过 / 无 steps 时返回 [] |
| 2255 | 2203 | */ |
| 2256 | -function materializeClaimableSubtasks({ task, steps }) { | |
| 2204 | +function materializeDecomposedSubtasks({ task, steps }) { | |
| 2257 | 2205 | if (!task || !Array.isArray(steps) || !steps.length) return []; |
| 2258 | 2206 | |
| 2259 | 2207 | const already = getDb() |
| 2260 | - .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'claimable_work'`) | |
| 2208 | + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) | |
| 2261 | 2209 | .get(task.id).n; |
| 2262 | 2210 | if (already > 0) return []; |
| 2263 | 2211 | |
| ... | ... | @@ -2268,10 +2216,10 @@ function materializeClaimableSubtasks({ task, steps }) { |
| 2268 | 2216 | companyId: task.company_id, |
| 2269 | 2217 | title: String(step.title || '').slice(0, 200), |
| 2270 | 2218 | description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''), |
| 2219 | + executionMode: 'auto', | |
| 2271 | 2220 | createdBy: 'subtask-planning', |
| 2272 | 2221 | parentTaskId: task.id, |
| 2273 | - subtaskKind: 'claimable_work', | |
| 2274 | - status: 'claimable', | |
| 2222 | + subtaskKind: 'decomposed_work', | |
| 2275 | 2223 | }); |
| 2276 | 2224 | seqToId.set(step.seq, row.id); |
| 2277 | 2225 | } |
| ... | ... | @@ -2286,6 +2234,47 @@ function materializeClaimableSubtasks({ task, steps }) { |
| 2286 | 2234 | } |
| 2287 | 2235 | |
| 2288 | 2236 | /** |
| 2237 | + * 46 号文档 Phase 3(v2):给一条 `decomposed_work` 子任务现造一张最小的 work graph—— | |
| 2238 | + * `实现(org-coder)→ 验证(org-validator)`两个节点,`agent_call`(不是 `subgraph`), | |
| 2239 | + * 跳过公司完整 org graph 那套 ceo→cto→org-planner→org-coder→org-validator 的规划仪式。 | |
| 2240 | + * | |
| 2241 | + * 为什么跳得掉:`subtask-planning` 已经把"怎么做"拆到了这一步的粒度,`org-planner` 平时干的 | |
| 2242 | + * brd/prd/sdd 那一套是在回答"这件事怎么做"——对一条已经具体到"实现 add 子命令"的子任务, | |
| 2243 | + * 再走一遍纯属重复劳动,Phase 0 真实测过一条完整 task 走完整 org graph 近 19 分钟, | |
| 2244 | + * 子任务量级不该背这个成本。 | |
| 2245 | + * | |
| 2246 | + * 没有声明任何 `needs`/`produces` 边契约(`declareEdgeContract` 一次都没调)——`agent_call` | |
| 2247 | + * 节点没有声明 needs 就不会触发 `requiredInputsMissing`,没有声明 produces 就不会触发 | |
| 2248 | + * `unsatisfiedRequiredProduces`,这两道闸门天然对这张图是空的,不用额外放行。 | |
| 2249 | + * | |
| 2250 | + * ⚠️ **这是一次没有真实验证过的假设,写在这里免得下一个人以为它是钉死的设计**: | |
| 2251 | + * `org-coder`/`org-validator` 平时只在"公司完整 org graph 的 subgraph 节点"这个上下文里 | |
| 2252 | + * 被调用过,它们的 `agent.md`/`tools.md` 有没有隐含"前面一定有 brd/prd/sdd 产物"这种假设, | |
| 2253 | + * 直接当 `agent_call` 裸调用会不会读不到预期上下文——没有实测过,需要真实回归验证。 | |
| 2254 | + * 用户的决定是"先跑轻量版看效果,不行再上完整 org graph"(46 号文档),这就是那次实验。 | |
| 2255 | + * | |
| 2256 | + * @param {{companyId: string, task: object}} ctx task 是那条 decomposed_work 子任务本身 | |
| 2257 | + * @returns {string} 新建的 work graph id | |
| 2258 | + */ | |
| 2259 | +function createDecomposedWorkGraph({ companyId, task }) { | |
| 2260 | + const g = createWorkGraph({ | |
| 2261 | + companyId, | |
| 2262 | + plannedBy: 'subtask-dispatch', | |
| 2263 | + goalId: task.goal_id, | |
| 2264 | + taskId: task.id, | |
| 2265 | + nodes: [ | |
| 2266 | + { nodeName: 'impl', systemAgentId: 'org-coder', nodeKind: 'agent_call' }, | |
| 2267 | + { nodeName: 'validate', systemAgentId: 'org-validator', nodeKind: 'agent_call' }, | |
| 2268 | + ], | |
| 2269 | + edges: [{ from: 'impl', to: 'validate', condition: 'success' }], | |
| 2270 | + }); | |
| 2271 | + if (!g.ok) { | |
| 2272 | + throw new Error(`createDecomposedWorkGraph: 建图失败: ${JSON.stringify(g.errors)}`); | |
| 2273 | + } | |
| 2274 | + return g.id; | |
| 2275 | +} | |
| 2276 | + | |
| 2277 | +/** | |
| 2289 | 2278 | * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 |
| 2290 | 2279 | * |
| 2291 | 2280 | * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, |
| ... | ... | @@ -2919,10 +2908,9 @@ module.exports = { |
| 2919 | 2908 | settleTaskFromRun, |
| 2920 | 2909 | settleSubtaskResult, |
| 2921 | 2910 | GRAPH_TAKEOVER_ASSIGNEE, |
| 2922 | - // 46 号文档 Phase 3:任务池认领 | |
| 2923 | - claimSubtask, | |
| 2924 | - unclaimSubtask, | |
| 2925 | - materializeClaimableSubtasks, | |
| 2911 | + // 46 号文档 Phase 3(v2):CEO 派发 decomposed_work 子任务 | |
| 2912 | + materializeDecomposedSubtasks, | |
| 2913 | + createDecomposedWorkGraph, | |
| 2926 | 2914 | // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 |
| 2927 | 2915 | claimQueuedTask, |
| 2928 | 2916 | blockQueuedTask, | ... | ... |
server/tests/unit/task-decomposition-dispatch.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * 46 号文档 Phase 3(v2,2026-09-02 改版):CEO 派发 decomposed_work 子任务。 | |
| 3 | + * | |
| 4 | + * v1 做的是"认领"(pull,CAS 抢占),真实回归后拍板改成"CEO 直接派发"(push,不存在 | |
| 5 | + * 多方竞争,CAS 是在给不存在的问题找解)。这个文件替掉已删除的 task-pool-claim.test.js, | |
| 6 | + * 覆盖新设计的三层: | |
| 7 | + * 1. `materializeDecomposedSubtasks`(物化,depends_on 回填,幂等) | |
| 8 | + * 2. `createDecomposedWorkGraph`(现造的轻量 impl→validate 图,形状对不对) | |
| 9 | + * 3. 路由层 `enqueueTaskRun`——decomposed_work 走轻量 work graph,普通 task 走公司 org | |
| 10 | + * graph 不受影响(回归),以及 `triggerSubtaskPlanning` 的递归防护(子任务不会被再拆一层) | |
| 11 | + */ | |
| 12 | +import { | |
| 13 | + describe, it, expect, beforeAll, afterAll, | |
| 14 | +} from 'vitest'; | |
| 15 | +import fs from 'fs'; | |
| 16 | +import os from 'os'; | |
| 17 | +import path from 'path'; | |
| 18 | + | |
| 19 | +const TEST_DB_PATH = '/tmp/test-task-decomp-dispatch.sqlite'; | |
| 20 | +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | |
| 21 | +const MODULES = ['../../services/graph-db', '../../services/work-graph-db']; | |
| 22 | + | |
| 23 | +function ensureAgent(wdb, id) { | |
| 24 | + const db = wdb.getDb(); | |
| 25 | + const now = new Date().toISOString(); | |
| 26 | + db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 27 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 28 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 29 | + .run(id, id, `system/agents/${id}`, now, now); | |
| 30 | +} | |
| 31 | + | |
| 32 | +describe('46 号文档 Phase 3(v2):DB 层', () => { | |
| 33 | + let wdb; | |
| 34 | + | |
| 35 | + beforeAll(() => { | |
| 36 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 37 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 38 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 39 | + wdb = require('../../services/work-graph-db'); | |
| 40 | + ensureAgent(wdb, 'org-coder'); | |
| 41 | + ensureAgent(wdb, 'org-validator'); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + afterAll(() => { | |
| 45 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 46 | + delete process.env.GRAPH_DB_PATH; | |
| 47 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 48 | + }); | |
| 49 | + | |
| 50 | + // ── createTask 校验(简化后:没有专属初始 status 了)───────────────── | |
| 51 | + | |
| 52 | + it('subtaskKind 非法值直接拒', () => { | |
| 53 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 54 | + expect(() => wdb.createTask({ | |
| 55 | + goalId: goal.id, title: 't', description: 'd', subtaskKind: 'not-a-real-kind', | |
| 56 | + })).toThrow(/subtaskKind 非法/); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it('subtaskKind 没有 parentTaskId 时直接拒', () => { | |
| 60 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 61 | + expect(() => wdb.createTask({ | |
| 62 | + goalId: goal.id, title: 't', description: 'd', subtaskKind: 'decomposed_work', | |
| 63 | + })).toThrow(/只对子任务.*有意义/); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('正常创建 decomposed_work 子任务:就是一条普通 draft task,只多了 subtask_kind 标签', () => { | |
| 67 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 68 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 69 | + const child = wdb.createTask({ | |
| 70 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 71 | + }); | |
| 72 | + expect(child.subtask_kind).toBe('decomposed_work'); | |
| 73 | + expect(child.status).toBe('draft'); // 没有专属状态了 | |
| 74 | + expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑 | |
| 75 | + }); | |
| 76 | + | |
| 77 | + // ── materializeDecomposedSubtasks ───────────────────────────────── | |
| 78 | + | |
| 79 | + describe('materializeDecomposedSubtasks', () => { | |
| 80 | + function setupTask(companyId) { | |
| 81 | + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 82 | + return wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 83 | + } | |
| 84 | + | |
| 85 | + it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => { | |
| 86 | + const task = setupTask('co-p3-2'); | |
| 87 | + const steps = [ | |
| 88 | + { seq: 1, title: '第一步', depends_on_hint: [3] }, // 前向引用:依赖还没建出来的第三步 | |
| 89 | + { seq: 2, title: '第二步', depends_on_hint: [1] }, | |
| 90 | + { seq: 3, title: '第三步', depends_on_hint: [] }, | |
| 91 | + ]; | |
| 92 | + const ids = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 93 | + expect(ids).toHaveLength(3); | |
| 94 | + | |
| 95 | + const rows = ids.map((id) => wdb.getTask(id)); | |
| 96 | + expect(rows.every((r) => r.subtask_kind === 'decomposed_work')).toBe(true); | |
| 97 | + expect(rows.every((r) => r.status === 'draft')).toBe(true); | |
| 98 | + expect(rows.every((r) => r.execution_mode === 'auto')).toBe(true); | |
| 99 | + expect(rows[0].title).toBe('第一步'); | |
| 100 | + expect(JSON.parse(rows[0].depends_on)).toEqual([ids[2]]); // 前向引用正确解析成第三步的真实 id | |
| 101 | + expect(JSON.parse(rows[1].depends_on)).toEqual([ids[0]]); | |
| 102 | + expect(JSON.parse(rows[2].depends_on)).toEqual([]); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => { | |
| 106 | + const task = setupTask('co-p3-3'); | |
| 107 | + const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99] }]; | |
| 108 | + const ids = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 109 | + expect(ids).toHaveLength(1); | |
| 110 | + expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]); | |
| 111 | + }); | |
| 112 | + | |
| 113 | + it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => { | |
| 114 | + const task = setupTask('co-p3-4'); | |
| 115 | + const steps = [{ seq: 1, title: '一步', depends_on_hint: [] }]; | |
| 116 | + const first = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 117 | + expect(first).toHaveLength(1); | |
| 118 | + const second = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 119 | + expect(second).toEqual([]); | |
| 120 | + | |
| 121 | + const rows = wdb.getDb() | |
| 122 | + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) | |
| 123 | + .get(task.id); | |
| 124 | + expect(rows.n).toBe(1); | |
| 125 | + }); | |
| 126 | + | |
| 127 | + it('空 steps / 无 task 时返回 [],不报错', () => { | |
| 128 | + const task = setupTask('co-p3-5'); | |
| 129 | + expect(wdb.materializeDecomposedSubtasks({ task, steps: [] })).toEqual([]); | |
| 130 | + expect(wdb.materializeDecomposedSubtasks({ task: null, steps: [{ seq: 1, title: 'x' }] })).toEqual([]); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it('与 43 号文档的 acceptance_item 共存互不干扰:各自幂等判据只看自己的 subtask_kind', () => { | |
| 134 | + const task = setupTask('co-p3-6'); | |
| 135 | + wdb.createTask({ | |
| 136 | + goalId: task.goal_id, title: 'a1', description: 'd', parentTaskId: task.id, | |
| 137 | + subtaskKind: 'acceptance_item', | |
| 138 | + }); | |
| 139 | + const ids = wdb.materializeDecomposedSubtasks({ | |
| 140 | + task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }], | |
| 141 | + }); | |
| 142 | + expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 | |
| 143 | + }); | |
| 144 | + }); | |
| 145 | + | |
| 146 | + // ── createDecomposedWorkGraph ────────────────────────────────────── | |
| 147 | + | |
| 148 | + describe('createDecomposedWorkGraph', () => { | |
| 149 | + it('建出一张两节点的轻量图:impl(org-coder) → validate(org-validator),都是 agent_call', () => { | |
| 150 | + const goal = wdb.createGoal({ companyId: 'co-p3-7', title: 'g', description: 'd' }); | |
| 151 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 152 | + const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-7', task }); | |
| 153 | + expect(graphId).toBeTruthy(); | |
| 154 | + | |
| 155 | + const nodes = wdb.getDb() | |
| 156 | + .prepare(`SELECT node_name, system_agent_id, node_kind FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`) | |
| 157 | + .all(graphId); | |
| 158 | + expect(nodes).toEqual([ | |
| 159 | + { node_name: 'impl', system_agent_id: 'org-coder', node_kind: 'agent_call' }, | |
| 160 | + { node_name: 'validate', system_agent_id: 'org-validator', node_kind: 'agent_call' }, | |
| 161 | + ]); | |
| 162 | + | |
| 163 | + const wg = wdb.getDb().prepare(`SELECT task_id, goal_id FROM work_graphs WHERE id = ?`).get(graphId); | |
| 164 | + expect(wg.task_id).toBe(task.id); | |
| 165 | + expect(wg.goal_id).toBe(goal.id); | |
| 166 | + }); | |
| 167 | + | |
| 168 | + it('这张图能被 createRun 直接拿去起一条 work run(机制打通,不真的执行)', () => { | |
| 169 | + const goal = wdb.createGoal({ companyId: 'co-p3-8', title: 'g', description: 'd' }); | |
| 170 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 171 | + const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-8', task }); | |
| 172 | + const run = wdb.createRun({ | |
| 173 | + graphKind: 'work', graphId, companyId: 'co-p3-8', goalId: goal.id, taskId: task.id, | |
| 174 | + }); | |
| 175 | + expect(run.ok).not.toBe(false); | |
| 176 | + expect(wdb.getRun(run.id).graph_kind).toBe('work'); | |
| 177 | + expect(wdb.getRun(run.id).graph_id).toBe(graphId); | |
| 178 | + }); | |
| 179 | + }); | |
| 180 | +}); | |
| 181 | + | |
| 182 | +describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', () => { | |
| 183 | + const CID = 'route-p3v2-co'; | |
| 184 | + let wdb; | |
| 185 | + let org; | |
| 186 | + let server; | |
| 187 | + let base; | |
| 188 | + let origCompanyGet; | |
| 189 | + | |
| 190 | + async function call(method, url, body) { | |
| 191 | + const r = await fetch(base + url, { | |
| 192 | + method, | |
| 193 | + headers: { 'Content-Type': 'application/json' }, | |
| 194 | + body: body === undefined ? undefined : JSON.stringify(body), | |
| 195 | + }); | |
| 196 | + return [r.status, await r.json()]; | |
| 197 | + } | |
| 198 | + | |
| 199 | + beforeAll(async () => { | |
| 200 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 201 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 202 | + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3v2-dispatch-routes-')); | |
| 203 | + const allModules = [ | |
| 204 | + ...MODULES, '../../services/graph-service', '../../services/org-graph-service', | |
| 205 | + '../../services/subtask-planning', '../../routes/company', | |
| 206 | + ]; | |
| 207 | + for (const m of allModules) { | |
| 208 | + try { delete require.cache[require.resolve(m)]; } catch {} | |
| 209 | + } | |
| 210 | + wdb = require('../../services/work-graph-db'); | |
| 211 | + org = require('../../services/org-graph-service'); | |
| 212 | + ensureAgent(wdb, 'org-planner'); | |
| 213 | + ensureAgent(wdb, 'org-coder'); | |
| 214 | + ensureAgent(wdb, 'org-validator'); | |
| 215 | + | |
| 216 | + const companyService = require('../../services/company-service'); | |
| 217 | + origCompanyGet = companyService.CompanyService.prototype.get; | |
| 218 | + companyService.CompanyService.prototype.get = (companyId) => | |
| 219 | + (companyId === CID ? { id: CID, name: CID, status: 'running' } : null); | |
| 220 | + | |
| 221 | + // 起 /enqueue 前置门禁要求公司有 org graph——最小起一张(org-planner→org-coder→org-validator) | |
| 222 | + expect(org.writeGraph(CID, { | |
| 223 | + nodes: ['org-planner', 'org-coder', 'org-validator'], | |
| 224 | + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }, | |
| 225 | + { from: 'org-coder', to: 'org-validator', condition: 'success' }], | |
| 226 | + }).ok).toBe(true); | |
| 227 | + | |
| 228 | + // subtask-planning 的真实调用不走这个测试(VITEST 环境下 planSubtasks 自动短路,见该模块), | |
| 229 | + // 不用额外注入桩。 | |
| 230 | + | |
| 231 | + const express = require('express'); | |
| 232 | + const app = express(); | |
| 233 | + app.use(express.json()); | |
| 234 | + app.use('/api/company', require('../../routes/company')); | |
| 235 | + await new Promise((resolve) => { server = app.listen(0, resolve); }); | |
| 236 | + base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`; | |
| 237 | + }); | |
| 238 | + | |
| 239 | + afterAll(() => { | |
| 240 | + if (server) server.close(); | |
| 241 | + const companyService = require('../../services/company-service'); | |
| 242 | + if (origCompanyGet) companyService.CompanyService.prototype.get = origCompanyGet; | |
| 243 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 244 | + try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch {} | |
| 245 | + delete process.env.GRAPH_DB_PATH; | |
| 246 | + delete process.env.GRAPH_RUNS_DIR; | |
| 247 | + const allModules = [ | |
| 248 | + ...MODULES, '../../services/graph-service', '../../services/org-graph-service', | |
| 249 | + '../../services/subtask-planning', '../../routes/company', | |
| 250 | + ]; | |
| 251 | + for (const m of allModules) { | |
| 252 | + try { delete require.cache[require.resolve(m)]; } catch {} | |
| 253 | + } | |
| 254 | + }); | |
| 255 | + | |
| 256 | + it('普通顶层 task:enqueue 后走公司 org graph(graph_kind=org, graph_id=companyId)——回归,行为不变', async () => { | |
| 257 | + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 258 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 259 | + | |
| 260 | + const [status, body] = await call('POST', `/tasks/${task.id}/enqueue`); | |
| 261 | + expect(status).toBe(201); | |
| 262 | + const run = wdb.getRun(body.runId); | |
| 263 | + expect(run.graph_kind).toBe('org'); | |
| 264 | + expect(run.graph_id).toBe(CID); | |
| 265 | + }); | |
| 266 | + | |
| 267 | + it('decomposed_work 子任务:enqueue 后走轻量 work graph(graph_kind=work,graph_id 不是 companyId)', async () => { | |
| 268 | + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 269 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 270 | + const child = wdb.createTask({ | |
| 271 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 272 | + }); | |
| 273 | + | |
| 274 | + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); | |
| 275 | + expect(status).toBe(201); | |
| 276 | + const run = wdb.getRun(body.runId); | |
| 277 | + expect(run.graph_kind).toBe('work'); | |
| 278 | + expect(run.graph_id).not.toBe(CID); | |
| 279 | + | |
| 280 | + const nodes = wdb.getDb() | |
| 281 | + .prepare(`SELECT node_name, system_agent_id FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`) | |
| 282 | + .all(run.graph_id); | |
| 283 | + expect(nodes.map((n) => n.node_name)).toEqual(['impl', 'validate']); | |
| 284 | + }); | |
| 285 | +}); | ... | ... |
server/tests/unit/task-decomposition-validator-gate.test.js
| ... | ... | @@ -142,7 +142,7 @@ describe('43 号文档 Phase 3', () => { |
| 142 | 142 | expect(out.node.status).toBe('success'); |
| 143 | 143 | }); |
| 144 | 144 | |
| 145 | - it('46 号文档 Phase 3:同一个 task 下还挂着 claimable_work 子任务(未 completed)时,' | |
| 145 | + it('46 号文档 Phase 3:同一个 task 下还挂着 decomposed_work 子任务(未 completed)时,' | |
| 146 | 146 | + '不影响 acceptance_item 的验收判定——两套子任务完成判定互不干扰', async () => { |
| 147 | 147 | const companyId = 'co-gate-6'; |
| 148 | 148 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); |
| ... | ... | @@ -151,11 +151,11 @@ describe('43 号文档 Phase 3', () => { |
| 151 | 151 | goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', |
| 152 | 152 | }); |
| 153 | 153 | wdb.settleSubtaskResult(c1.id, 'completed'); // 验收条目全部完成 |
| 154 | - // claimable_work 子任务还停在 claimable,永远不会 completed(Phase 3 不碰执行)—— | |
| 154 | + // decomposed_work 子任务还停在 draft,还没被 CEO enqueue、更不会 completed—— | |
| 155 | 155 | // 如果闸门没收窄,这条会被当成"未完成的子任务"误伤,把上面本该通过的验收打成失败 |
| 156 | 156 | wdb.createTask({ |
| 157 | - goalId: goal.id, title: 'claim-me', description: 'd', parentTaskId: parent.id, | |
| 158 | - subtaskKind: 'claimable_work', status: 'claimable', | |
| 157 | + goalId: goal.id, title: 'todo-step', description: 'd', parentTaskId: parent.id, | |
| 158 | + subtaskKind: 'decomposed_work', | |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | 161 | const out = await runValidator(companyId, parent.id); | ... | ... |
server/tests/unit/task-pool-claim.test.js deleted
| 1 | -/** | |
| 2 | - * 46 号文档 Phase 3:任务池认领机制。 | |
| 3 | - * | |
| 4 | - * 三层各测各的: | |
| 5 | - * 1. `createTask` 的 subtaskKind/status 校验(纯 DB 层参数校验) | |
| 6 | - * 2. `materializeClaimableSubtasks`(steps[] → claimable_work 行,含 depends_on 回填) | |
| 7 | - * 3. `claimSubtask`/`unclaimSubtask` 的 CAS(全仓库第一个"认领"语义的并发保护) | |
| 8 | - * 4. 路由层 claim/unclaim 端点(角色白名单 + CAS 的 HTTP 翻译) | |
| 9 | - * | |
| 10 | - * DB 层沿用 `task-decomposition-validator-gate.test.js` 的隔离手法;路由层沿用 | |
| 11 | - * `company-goal-task-routes.test.js` 的「真起 express、没有 supertest 用 fetch 打」手法。 | |
| 12 | - */ | |
| 13 | -import { | |
| 14 | - describe, it, expect, beforeAll, afterAll, | |
| 15 | -} from 'vitest'; | |
| 16 | -import fs from 'fs'; | |
| 17 | -import os from 'os'; | |
| 18 | -import path from 'path'; | |
| 19 | - | |
| 20 | -const TEST_DB_PATH = '/tmp/test-task-pool-claim.sqlite'; | |
| 21 | -const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | |
| 22 | -const MODULES = ['../../services/graph-db', '../../services/work-graph-db']; | |
| 23 | - | |
| 24 | -describe('46 号文档 Phase 3:DB 层', () => { | |
| 25 | - let wdb; | |
| 26 | - | |
| 27 | - beforeAll(() => { | |
| 28 | - for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 29 | - process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 30 | - for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 31 | - wdb = require('../../services/work-graph-db'); | |
| 32 | - }); | |
| 33 | - | |
| 34 | - afterAll(() => { | |
| 35 | - for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 36 | - delete process.env.GRAPH_DB_PATH; | |
| 37 | - for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 38 | - }); | |
| 39 | - | |
| 40 | - // ── createTask 校验 ────────────────────────────────────────────── | |
| 41 | - | |
| 42 | - it('subtaskKind 非法值直接拒', () => { | |
| 43 | - const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 44 | - expect(() => wdb.createTask({ | |
| 45 | - goalId: goal.id, title: 't', description: 'd', subtaskKind: 'not-a-real-kind', | |
| 46 | - })).toThrow(/subtaskKind 非法/); | |
| 47 | - }); | |
| 48 | - | |
| 49 | - it('subtaskKind 没有 parentTaskId 时直接拒(只对子任务有意义)', () => { | |
| 50 | - const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 51 | - expect(() => wdb.createTask({ | |
| 52 | - goalId: goal.id, title: 't', description: 'd', subtaskKind: 'claimable_work', | |
| 53 | - })).toThrow(/只对子任务.*有意义/); | |
| 54 | - }); | |
| 55 | - | |
| 56 | - it('status="claimable" 但 subtaskKind 不是 claimable_work 时直接拒', () => { | |
| 57 | - const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 58 | - const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 59 | - expect(() => wdb.createTask({ | |
| 60 | - goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, | |
| 61 | - subtaskKind: 'acceptance_item', status: 'claimable', | |
| 62 | - })).toThrow(/status='claimable' 只对/); | |
| 63 | - }); | |
| 64 | - | |
| 65 | - it('status 非 draft/claimable 直接拒', () => { | |
| 66 | - const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 67 | - expect(() => wdb.createTask({ | |
| 68 | - goalId: goal.id, title: 't', description: 'd', status: 'running', | |
| 69 | - })).toThrow(/status 只能是/); | |
| 70 | - }); | |
| 71 | - | |
| 72 | - it('正常创建 claimable_work 子任务:subtask_kind/status 落库正确', () => { | |
| 73 | - const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 74 | - const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 75 | - const child = wdb.createTask({ | |
| 76 | - goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, | |
| 77 | - subtaskKind: 'claimable_work', status: 'claimable', | |
| 78 | - }); | |
| 79 | - expect(child.subtask_kind).toBe('claimable_work'); | |
| 80 | - expect(child.status).toBe('claimable'); | |
| 81 | - expect(child.executor).toBe('graph'); // 默认值,认领时才会翻成 'agent' | |
| 82 | - expect(child.assignee_agent_id).toBeNull(); | |
| 83 | - }); | |
| 84 | - | |
| 85 | - // ── materializeClaimableSubtasks ───────────────────────────────── | |
| 86 | - | |
| 87 | - describe('materializeClaimableSubtasks', () => { | |
| 88 | - function setupTask(companyId) { | |
| 89 | - const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 90 | - return wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 91 | - } | |
| 92 | - | |
| 93 | - it('按 steps 顺序物化,depends_on 正确回填成真实 task id(含前向引用)', () => { | |
| 94 | - const task = setupTask('co-p3-2'); | |
| 95 | - const steps = [ | |
| 96 | - { seq: 1, title: '第一步', depends_on_hint: [3] }, // 前向引用:依赖还没建出来的第三步 | |
| 97 | - { seq: 2, title: '第二步', depends_on_hint: [1] }, | |
| 98 | - { seq: 3, title: '第三步', depends_on_hint: [] }, | |
| 99 | - ]; | |
| 100 | - const ids = wdb.materializeClaimableSubtasks({ task, steps }); | |
| 101 | - expect(ids).toHaveLength(3); | |
| 102 | - | |
| 103 | - const rows = ids.map((id) => wdb.getTask(id)); | |
| 104 | - expect(rows.every((r) => r.subtask_kind === 'claimable_work')).toBe(true); | |
| 105 | - expect(rows.every((r) => r.status === 'claimable')).toBe(true); | |
| 106 | - expect(rows[0].title).toBe('第一步'); | |
| 107 | - expect(JSON.parse(rows[0].depends_on)).toEqual([ids[2]]); // 前向引用也正确解析成第三步的真实 id | |
| 108 | - expect(JSON.parse(rows[1].depends_on)).toEqual([ids[0]]); | |
| 109 | - expect(JSON.parse(rows[2].depends_on)).toEqual([]); | |
| 110 | - }); | |
| 111 | - | |
| 112 | - it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => { | |
| 113 | - const task = setupTask('co-p3-3'); | |
| 114 | - const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99] }]; | |
| 115 | - const ids = wdb.materializeClaimableSubtasks({ task, steps }); | |
| 116 | - expect(ids).toHaveLength(1); | |
| 117 | - expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]); | |
| 118 | - }); | |
| 119 | - | |
| 120 | - it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => { | |
| 121 | - const task = setupTask('co-p3-4'); | |
| 122 | - const steps = [{ seq: 1, title: '一步', depends_on_hint: [] }]; | |
| 123 | - const first = wdb.materializeClaimableSubtasks({ task, steps }); | |
| 124 | - expect(first).toHaveLength(1); | |
| 125 | - const second = wdb.materializeClaimableSubtasks({ task, steps }); | |
| 126 | - expect(second).toEqual([]); | |
| 127 | - | |
| 128 | - const rows = wdb.getDb() | |
| 129 | - .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'claimable_work'`) | |
| 130 | - .get(task.id); | |
| 131 | - expect(rows.n).toBe(1); | |
| 132 | - }); | |
| 133 | - | |
| 134 | - it('空 steps / 无 task 时返回 [],不报错', () => { | |
| 135 | - const task = setupTask('co-p3-5'); | |
| 136 | - expect(wdb.materializeClaimableSubtasks({ task, steps: [] })).toEqual([]); | |
| 137 | - expect(wdb.materializeClaimableSubtasks({ task: null, steps: [{ seq: 1, title: 'x' }] })).toEqual([]); | |
| 138 | - }); | |
| 139 | - | |
| 140 | - it('与 43 号文档的 acceptance_item 共存互不干扰:各自幂等判据只看自己的 subtask_kind', () => { | |
| 141 | - const task = setupTask('co-p3-6'); | |
| 142 | - wdb.createTask({ | |
| 143 | - goalId: task.goal_id, title: 'a1', description: 'd', parentTaskId: task.id, | |
| 144 | - subtaskKind: 'acceptance_item', | |
| 145 | - }); | |
| 146 | - const ids = wdb.materializeClaimableSubtasks({ | |
| 147 | - task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }], | |
| 148 | - }); | |
| 149 | - expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 | |
| 150 | - }); | |
| 151 | - }); | |
| 152 | - | |
| 153 | - // ── claimSubtask / unclaimSubtask CAS ──────────────────────────── | |
| 154 | - | |
| 155 | - describe('claimSubtask / unclaimSubtask', () => { | |
| 156 | - function makeClaimable(companyId) { | |
| 157 | - const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 158 | - const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 159 | - return wdb.createTask({ | |
| 160 | - goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, | |
| 161 | - subtaskKind: 'claimable_work', status: 'claimable', | |
| 162 | - }); | |
| 163 | - } | |
| 164 | - | |
| 165 | - it('认领成功:status/executor/assignee/claimed_at 全部正确落库', () => { | |
| 166 | - const t = makeClaimable('co-p3-7'); | |
| 167 | - expect(wdb.claimSubtask(t.id, 'org-coder')).toBe(1); | |
| 168 | - const after = wdb.getTask(t.id); | |
| 169 | - expect(after.status).toBe('claimed'); | |
| 170 | - expect(after.executor).toBe('agent'); | |
| 171 | - expect(after.assignee_agent_id).toBe('org-coder'); | |
| 172 | - expect(after.claimed_at).toBeTruthy(); | |
| 173 | - }); | |
| 174 | - | |
| 175 | - it('并发认领同一条:只有一方成功(changes CAS)', () => { | |
| 176 | - const t = makeClaimable('co-p3-8'); | |
| 177 | - const r1 = wdb.claimSubtask(t.id, 'org-coder'); | |
| 178 | - const r2 = wdb.claimSubtask(t.id, 'org-coder'); // 模拟第二次竞争(同一行为不同调用) | |
| 179 | - expect([r1, r2].sort()).toEqual([0, 1]); // 一个 1 一个 0,不是两个都 1 | |
| 180 | - }); | |
| 181 | - | |
| 182 | - it('非 claimable 状态(比如已经是 claimed)认领失败,changes=0', () => { | |
| 183 | - const t = makeClaimable('co-p3-9'); | |
| 184 | - wdb.claimSubtask(t.id, 'org-coder'); | |
| 185 | - expect(wdb.claimSubtask(t.id, 'someone-else')).toBe(0); | |
| 186 | - expect(wdb.getTask(t.id).assignee_agent_id).toBe('org-coder'); // 没被顶掉 | |
| 187 | - }); | |
| 188 | - | |
| 189 | - it('不是 claimable_work 的行认领失败', () => { | |
| 190 | - const goal = wdb.createGoal({ companyId: 'co-p3-10', title: 'g', description: 'd' }); | |
| 191 | - const t = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); // 普通 task | |
| 192 | - expect(wdb.claimSubtask(t.id, 'org-coder')).toBe(0); | |
| 193 | - }); | |
| 194 | - | |
| 195 | - it('放弃认领:claimed → claimable,executor 退回 graph,assignee 清空', () => { | |
| 196 | - const t = makeClaimable('co-p3-11'); | |
| 197 | - wdb.claimSubtask(t.id, 'org-coder'); | |
| 198 | - expect(wdb.unclaimSubtask(t.id, 'org-coder')).toBe(1); | |
| 199 | - const after = wdb.getTask(t.id); | |
| 200 | - expect(after.status).toBe('claimable'); | |
| 201 | - expect(after.executor).toBe('graph'); | |
| 202 | - expect(after.assignee_agent_id).toBeNull(); | |
| 203 | - expect(after.claimed_at).toBeNull(); | |
| 204 | - }); | |
| 205 | - | |
| 206 | - it('放弃认领:不是认领者本人放弃失败', () => { | |
| 207 | - const t = makeClaimable('co-p3-12'); | |
| 208 | - wdb.claimSubtask(t.id, 'org-coder'); | |
| 209 | - expect(wdb.unclaimSubtask(t.id, 'someone-else')).toBe(0); | |
| 210 | - expect(wdb.getTask(t.id).status).toBe('claimed'); // 没被动 | |
| 211 | - }); | |
| 212 | - }); | |
| 213 | -}); | |
| 214 | - | |
| 215 | -describe('46 号文档 Phase 3:路由层 claim/unclaim', () => { | |
| 216 | - const CID = 'route-p3-co'; | |
| 217 | - let wdb; | |
| 218 | - let server; | |
| 219 | - let base; | |
| 220 | - let origCompanyGet; | |
| 221 | - | |
| 222 | - function hireInto(companyId, agentId, roleName) { | |
| 223 | - const db = wdb.getDb(); | |
| 224 | - const now = new Date().toISOString(); | |
| 225 | - db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 226 | - (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 227 | - VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 228 | - .run(agentId, agentId, `system/agents/${agentId}`, now, now); | |
| 229 | - db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) | |
| 230 | - VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, roleName, now); | |
| 231 | - } | |
| 232 | - | |
| 233 | - async function call(method, url, body) { | |
| 234 | - const r = await fetch(base + url, { | |
| 235 | - method, | |
| 236 | - headers: { 'Content-Type': 'application/json' }, | |
| 237 | - body: body === undefined ? undefined : JSON.stringify(body), | |
| 238 | - }); | |
| 239 | - return [r.status, await r.json()]; | |
| 240 | - } | |
| 241 | - | |
| 242 | - beforeAll(async () => { | |
| 243 | - for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 244 | - process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 245 | - process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3-claim-routes-')); | |
| 246 | - for (const m of [...MODULES, '../../routes/company']) { | |
| 247 | - try { delete require.cache[require.resolve(m)]; } catch {} | |
| 248 | - } | |
| 249 | - wdb = require('../../services/work-graph-db'); | |
| 250 | - | |
| 251 | - const companyService = require('../../services/company-service'); | |
| 252 | - origCompanyGet = companyService.CompanyService.prototype.get; | |
| 253 | - companyService.CompanyService.prototype.get = (companyId) => | |
| 254 | - (companyId === CID ? { id: CID, name: CID, status: 'running' } : null); | |
| 255 | - | |
| 256 | - hireInto(CID, 'org-coder', 'org-coder'); | |
| 257 | - hireInto(CID, 'ceo', 'ceo'); // 用来验证"雇了但不在白名单"这条 403 分支 | |
| 258 | - | |
| 259 | - const express = require('express'); | |
| 260 | - const app = express(); | |
| 261 | - app.use(express.json()); | |
| 262 | - app.use('/api/company', require('../../routes/company')); | |
| 263 | - await new Promise((resolve) => { server = app.listen(0, resolve); }); | |
| 264 | - base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`; | |
| 265 | - }); | |
| 266 | - | |
| 267 | - afterAll(() => { | |
| 268 | - if (server) server.close(); | |
| 269 | - const companyService = require('../../services/company-service'); | |
| 270 | - if (origCompanyGet) companyService.CompanyService.prototype.get = origCompanyGet; | |
| 271 | - for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 272 | - try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch {} | |
| 273 | - delete process.env.GRAPH_DB_PATH; | |
| 274 | - delete process.env.GRAPH_RUNS_DIR; | |
| 275 | - for (const m of [...MODULES, '../../routes/company']) { | |
| 276 | - try { delete require.cache[require.resolve(m)]; } catch {} | |
| 277 | - } | |
| 278 | - }); | |
| 279 | - | |
| 280 | - function makeClaimable() { | |
| 281 | - const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 282 | - const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 283 | - return wdb.createTask({ | |
| 284 | - goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, | |
| 285 | - subtaskKind: 'claimable_work', status: 'claimable', | |
| 286 | - }); | |
| 287 | - } | |
| 288 | - | |
| 289 | - it('白名单角色认领成功', async () => { | |
| 290 | - const t = makeClaimable(); | |
| 291 | - const [status, body] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); | |
| 292 | - expect(status).toBe(200); | |
| 293 | - expect(body.task.status).toBe('claimed'); | |
| 294 | - expect(body.task.assigneeAgentId).toBe('org-coder'); | |
| 295 | - expect(body.task.subtaskKind).toBe('claimable_work'); | |
| 296 | - }); | |
| 297 | - | |
| 298 | - it('非白名单角色认领:403', async () => { | |
| 299 | - const t = makeClaimable(); | |
| 300 | - const [status, body] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'ceo' }); | |
| 301 | - expect(status).toBe(403); | |
| 302 | - expect(body.error).toMatch(/白名单/); | |
| 303 | - }); | |
| 304 | - | |
| 305 | - it('缺 agentId:400', async () => { | |
| 306 | - const t = makeClaimable(); | |
| 307 | - const [status] = await call('POST', `/tasks/${t.id}/claim`, {}); | |
| 308 | - expect(status).toBe(400); | |
| 309 | - }); | |
| 310 | - | |
| 311 | - it('不是可认领子任务(普通 task):400', async () => { | |
| 312 | - const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 313 | - const t = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 314 | - const [status] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); | |
| 315 | - expect(status).toBe(400); | |
| 316 | - }); | |
| 317 | - | |
| 318 | - it('已被认领的再认领:409', async () => { | |
| 319 | - const t = makeClaimable(); | |
| 320 | - await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); | |
| 321 | - const [status] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); | |
| 322 | - expect(status).toBe(409); | |
| 323 | - }); | |
| 324 | - | |
| 325 | - it('认领后放弃:状态正确回退', async () => { | |
| 326 | - const t = makeClaimable(); | |
| 327 | - await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); | |
| 328 | - const [status, body] = await call('POST', `/tasks/${t.id}/unclaim`, { agentId: 'org-coder' }); | |
| 329 | - expect(status).toBe(200); | |
| 330 | - expect(body.task.status).toBe('claimable'); | |
| 331 | - expect(body.task.assigneeAgentId).toBeNull(); | |
| 332 | - }); | |
| 333 | - | |
| 334 | - it('不是认领者本人放弃:409', async () => { | |
| 335 | - const t = makeClaimable(); | |
| 336 | - await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); | |
| 337 | - const [status] = await call('POST', `/tasks/${t.id}/unclaim`, { agentId: 'ceo' }); | |
| 338 | - expect(status).toBe(409); | |
| 339 | - }); | |
| 340 | -}); |
system/agents/ceo/heartbeat.md
| ... | ... | @@ -7,8 +7,23 @@ |
| 7 | 7 | |
| 8 | 8 | - [ ] 查 `GET /tasks?status=blocked` 和 `?status=failed`,看有没有需要处理的 |
| 9 | 9 | - [ ] 查 `GET /goals?status=active`,扫一眼有没有明显该调整优先级的 |
| 10 | +- [ ] 查 `GET /tasks?subtaskKind=decomposed_work&status=draft`,看有没有等你派发的子任务 | |
| 11 | + (46 号文档 Phase 3:`subtask-planning` 拆出来的步骤,落库就是普通 draft task, | |
| 12 | + 不会自己跑起来,要你判断"现在放不放") | |
| 10 | 13 | - [ ] 都正常就直接回复"心跳正常",不要为了"用一下工具"而用工具 |
| 11 | 14 | |
| 15 | +## 子任务怎么派(decomposed_work) | |
| 16 | + | |
| 17 | +- 看到的每一条都已经是具体到"这一步做什么"的粒度(`subtask-planning` 已经拆过了), | |
| 18 | + 不需要你再判断"这条该不该做"——**只需要判断节奏**:现在放,还是等前置步骤先跑完再放。 | |
| 19 | +- 想知道前置步骤是谁:任务详情里的 `dependsOn` 数组。不用你自己排队——`depends_on` | |
| 20 | + 没完成的,`enqueue` 了也会被调度器自动拦成 `blocked`,一次性把一条 task 下所有 | |
| 21 | + `draft` 的子任务全部 `enqueue` 是安全的,先后顺序、并发几个都是调度器的活,不是你的。 | |
| 22 | +- 派一条:`POST /tasks/<taskId>/enqueue`(跟你派自己提的草稿任务是同一个端点,同一条权限)。 | |
| 23 | +- 这批子任务会走一张**轻量图**(只有实现+验证两步,不是公司完整的组织架构那套), | |
| 24 | + 成本比一条顶层 task 低很多,但目前还没有正式跑过真实验证——如果发现同一个 task 下的 | |
| 25 | + 子任务频繁失败或者结果质量不行,在回复里如实说,不要自己想办法绕过去。 | |
| 26 | + | |
| 12 | 27 | ## 发现问题时 |
| 13 | 28 | |
| 14 | 29 | - 具体判断依据见 `tools.md` 的"什么时候该提新任务、什么时候该改优先级"一节 | ... | ... |
system/agents/ceo/tools.md
| ... | ... | @@ -45,6 +45,14 @@ curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals/ |
| 45 | 45 | -H "x-internal-token: $LEMON_INTERNAL_TOKEN" |
| 46 | 46 | ``` |
| 47 | 47 | |
| 48 | +### 找等你派发的子任务(46 号文档 Phase 3) | |
| 49 | +```bash | |
| 50 | +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks?subtaskKind=decomposed_work&status=draft" \ | |
| 51 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | |
| 52 | +``` | |
| 53 | +每条都带 `parentTaskId`(它是哪条 task 拆出来的)和 `dependsOn`(同批里它依赖哪几条, | |
| 54 | +数组里是别的子任务的 taskId,不是要你手动排序——只是给你看清楚"这条要等谁先完成")。 | |
| 55 | + | |
| 48 | 56 | --- |
| 49 | 57 | |
| 50 | 58 | ## 写:只在真正判断需要时才用 |
| ... | ... | @@ -76,6 +84,10 @@ curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId |
| 76 | 84 | 必要的——`enqueue` 之后就会真的排队执行、真的烧预算。判断依据不够就先别 enqueue, |
| 77 | 85 | 留着草稿等人类看到也行。 |
| 78 | 86 | |
| 87 | +**`decomposed_work` 子任务走同一个端点**——`subtask-planning` 已经把内容拆好了,你不用再判断 | |
| 88 | +"清不清楚",只判断节奏(现在放还是等)。同一条 task 下的子任务可以一次性全部 enqueue, | |
| 89 | +`depends_on` 没完成的会被调度器自动拦住,不用你自己算顺序。 | |
| 90 | + | |
| 79 | 91 | --- |
| 80 | 92 | |
| 81 | 93 | ## 卡住的 run 节点:只能用这一个端点处理,不许自己动手 | ... | ... |