Commit f578c0e442762dfc1b78f3602d018b50b491639c

Authored by yibo
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
server/routes/company.js
@@ -1026,9 +1026,8 @@ function taskView(t, progress = null, positions = null) { @@ -1026,9 +1026,8 @@ function taskView(t, progress = null, positions = null) {
1026 // 43 号文档:子任务树的父子指针 + 这条 task 走哪种验收模式(看板据此决定要不要画子任务) 1026 // 43 号文档:子任务树的父子指针 + 这条 task 走哪种验收模式(看板据此决定要不要画子任务)
1027 parentTaskId: t.parent_task_id || null, 1027 parentTaskId: t.parent_task_id || null,
1028 acceptanceMode: t.acceptance_mode || 'holistic', 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 subtaskKind: t.subtask_kind || null, 1030 subtaskKind: t.subtask_kind || null,
1031 - claimedAt: t.claimed_at || null,  
1032 createdAt: t.created_at, 1031 createdAt: t.created_at,
1033 queuedAt: t.queued_at, 1032 queuedAt: t.queued_at,
1034 startedAt: t.started_at, 1033 startedAt: t.started_at,
@@ -1112,9 +1111,20 @@ function isSlotRace(err) { @@ -1112,9 +1111,20 @@ function isSlotRace(err) {
1112 function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = false }) { 1111 function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = false }) {
1113 const wdb = require('../services/work-graph-db'); 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 const created = wdb.createRun({ 1125 const created = wdb.createRun({
1116 - graphKind: 'org',  
1117 - graphId: companyId, // kind='org' 时 graph_id 就是 company_id 1126 + graphKind,
  1127 + graphId,
1118 companyId, 1128 companyId,
1119 goalId: taskRow.goal_id, 1129 goalId: taskRow.goal_id,
1120 taskId: taskRow.id, 1130 taskId: taskRow.id,
@@ -2112,7 +2122,7 @@ router.get(&#39;/:companyId/tasks&#39;, (req, res) =&gt; { @@ -2112,7 +2122,7 @@ router.get(&#39;/:companyId/tasks&#39;, (req, res) =&gt; {
2112 goalId: req.query.goalId || req.query.goal_id || null, 2122 goalId: req.query.goalId || req.query.goal_id || null,
2113 // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务 2123 // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务
2114 parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null, 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 subtaskKind: req.query.subtaskKind || req.query.subtask_kind || null, 2126 subtaskKind: req.query.subtaskKind || req.query.subtask_kind || null,
2117 includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', 2127 includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true',
2118 limit: Number.isInteger(limit) ? limit : null, 2128 limit: Number.isInteger(limit) ? limit : null,
@@ -2391,81 +2401,6 @@ router.post(&#39;/:companyId/tasks/:taskId/subtask-result&#39;, (req, res) =&gt; { @@ -2391,81 +2401,6 @@ router.post(&#39;/:companyId/tasks/:taskId/subtask-result&#39;, (req, res) =&gt; {
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 * POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3) 2404 * POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3)
2470 * 2405 *
2471 * 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列, 2406 * 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列,
server/services/graph-engine.js
@@ -1880,9 +1880,10 @@ function settleNode(runId, nodeRunId, outcome = {}) { @@ -1880,9 +1880,10 @@ function settleNode(runId, nodeRunId, outcome = {}) {
1880 if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) { 1880 if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) {
1881 // 46 号文档 Phase 3:这条闸门只该看 43 号文档的验收条目子任务——`AND subtask_kind = 1881 // 46 号文档 Phase 3:这条闸门只该看 43 号文档的验收条目子任务——`AND subtask_kind =
1882 // 'acceptance_item'` 是必须的收窄,不是可选项。Phase 3 会往同一个 parent_task_id 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 const subtasks = getDb() 1887 const subtasks = getDb()
1887 .prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) 1888 .prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`)
1888 .all(run0.task_id); 1889 .all(run0.task_id);
@@ -2434,7 +2435,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, @@ -2434,7 +2435,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def,
2434 if (!task || task.acceptance_mode !== 'itemized') return; 2435 if (!task || task.acceptance_mode !== 'itemized') return;
2435 2436
2436 // 46 号文档 Phase 3:只数 acceptance_item——同一个 task 现在可能同时挂着 subtask-planning 2437 // 46 号文档 Phase 3:只数 acceptance_item——同一个 task 现在可能同时挂着 subtask-planning
2437 - // 物化的 claimable_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。 2438 + // 物化的 decomposed_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。
2438 const already = getDb() 2439 const already = getDb()
2439 .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) 2440 .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`)
2440 .get(task.id).n; 2441 .get(task.id).n;
server/services/subtask-planning.js
@@ -349,6 +349,14 @@ function normalizeSteps(raw) { @@ -349,6 +349,14 @@ function normalizeSteps(raw) {
349 async function triggerSubtaskPlanning({ run, task }) { 349 async function triggerSubtaskPlanning({ run, task }) {
350 try { 350 try {
351 if (!run || !task) return; 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 const { 360 const {
353 steps, tokens, reasoningSource, experienceRefs, researchUsed, 361 steps, tokens, reasoningSource, experienceRefs, researchUsed,
354 } = await planSubtasks({ companyId: run.company_id, task }); 362 } = await planSubtasks({ companyId: run.company_id, task });
@@ -388,12 +396,12 @@ async function triggerSubtaskPlanning({ run, task }) { @@ -388,12 +396,12 @@ async function triggerSubtaskPlanning({ run, task }) {
388 nodeRunId: null, 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 console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),` 403 console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),`
396 - + `物化 ${materializedIds.length} 条可认领子任务`); 404 + + `物化 ${materializedIds.length} 条待 CEO 派发的子任务`);
397 } catch (err) { 405 } catch (err) {
398 console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`); 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,7 +528,6 @@ function getDb() {
528 description TEXT NOT NULL, -- 落进 task-goal.json 的那句;下达后不可改 528 description TEXT NOT NULL, -- 落进 task-goal.json 的那句;下达后不可改
529 status TEXT NOT NULL DEFAULT 'draft', 529 status TEXT NOT NULL DEFAULT 'draft',
530 -- draft | queued | running | completed | failed | blocked | cancelled 530 -- draft | queued | running | completed | failed | blocked | cancelled
531 - -- | claimable | claimed(46 号文档 Phase 3,只对 subtask_kind='claimable_work' 有意义)  
532 -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现: 531 -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现:
533 -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释 532 -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释
534 execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), 533 execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')),
@@ -562,13 +561,14 @@ function getDb() { @@ -562,13 +561,14 @@ function getDb() {
562 parent_task_id TEXT REFERENCES tasks(id), 561 parent_task_id TEXT REFERENCES tasks(id),
563 -- 46 号文档 Phase 3:parent_task_id 非空的子任务分两种,靠这一列区分—— 562 -- 46 号文档 Phase 3:parent_task_id 非空的子任务分两种,靠这一列区分——
564 -- 'acceptance_item' 43 号文档:验收条目物化(coder-validator 逐条上报,见 settleSubtaskResult) 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 -- NULL = 普通 task(不是子任务,或 parent_task_id 也是 NULL)。两套子任务共用同一张表 567 -- NULL = 普通 task(不是子任务,或 parent_task_id 也是 NULL)。两套子任务共用同一张表
567 -- (而不是各开一张),因为 tasks 表本来就是通用工作项容器,这一列只是给已经混杂的 568 -- (而不是各开一张),因为 tasks 表本来就是通用工作项容器,这一列只是给已经混杂的
568 -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开 569 -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开
569 -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。 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 created_at TEXT NOT NULL, 572 created_at TEXT NOT NULL,
573 updated_at TEXT NOT NULL, 573 updated_at TEXT NOT NULL,
574 queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) 574 queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4)
@@ -657,15 +657,12 @@ function getDb() { @@ -657,15 +657,12 @@ function getDb() {
657 // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。 657 // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。
658 db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(company_id, assignee_agent_id, created_at)`); 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 // 同批 5 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上 subtask_kind 的取值域 661 // 同批 5 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上 subtask_kind 的取值域
662 // 只由 createTask 的应用层校验守,与新建库的 CHECK 不同源。 662 // 只由 createTask 的应用层校验守,与新建库的 CHECK 不同源。
663 if (!hasColumn(db, 'tasks', 'subtask_kind')) { 663 if (!hasColumn(db, 'tasks', 'subtask_kind')) {
664 db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`); 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 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 667 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。
671 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { 668 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) {
@@ -1906,7 +1903,6 @@ function createTask({ @@ -1906,7 +1903,6 @@ function createTask({
1906 parentTaskId = null, 1903 parentTaskId = null,
1907 acceptanceMode = 'holistic', 1904 acceptanceMode = 'holistic',
1908 subtaskKind = null, 1905 subtaskKind = null,
1909 - status = 'draft',  
1910 } = {}) { 1906 } = {}) {
1911 if (!goalId) throw new Error('createTask: goalId 必填'); 1907 if (!goalId) throw new Error('createTask: goalId 必填');
1912 if (!title) throw new Error('createTask: title 必填'); 1908 if (!title) throw new Error('createTask: title 必填');
@@ -1917,21 +1913,15 @@ function createTask({ @@ -1917,21 +1913,15 @@ function createTask({
1917 if (parentTaskId && acceptanceMode === 'itemized') { 1913 if (parentTaskId && acceptanceMode === 'itemized') {
1918 throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分'); 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 throw new Error(`createTask: subtaskKind 非法: ${subtaskKind}`); 1920 throw new Error(`createTask: subtaskKind 非法: ${subtaskKind}`);
1923 } 1921 }
1924 if (subtaskKind && !parentTaskId) { 1922 if (subtaskKind && !parentTaskId) {
1925 throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义'); 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 const db = getDb(); 1926 const db = getDb();
1937 const goal = getGoal(goalId); 1927 const goal = getGoal(goalId);
@@ -1964,10 +1954,10 @@ function createTask({ @@ -1964,10 +1954,10 @@ function createTask({
1964 INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, 1954 INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status,
1965 execution_mode, depends_on, priority, budget_json, report_to, created_by, 1955 execution_mode, depends_on, priority, budget_json, report_to, created_by,
1966 parent_task_id, acceptance_mode, subtask_kind, created_at, updated_at) 1956 parent_task_id, acceptance_mode, subtask_kind, created_at, updated_at)
1967 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 1957 + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1968 `); 1958 `);
1969 const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( 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 executionMode, JSON.stringify(deps), priority, budget, 1961 executionMode, JSON.stringify(deps), priority, budget,
1972 // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 1962 // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。
1973 // goal 上有就继承下来,别让每个调用方各记一遍。 1963 // goal 上有就继承下来,别让每个调用方各记一遍。
@@ -2018,8 +2008,8 @@ function listCompanyTasks(companyId, { @@ -2018,8 +2008,8 @@ function listCompanyTasks(companyId, {
2018 where.push('parent_task_id = @parentTaskId'); 2008 where.push('parent_task_id = @parentTaskId');
2019 params.parentTaskId = parentTaskId; 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 if (subtaskKind) { 2013 if (subtaskKind) {
2024 where.push('subtask_kind = @subtaskKind'); 2014 where.push('subtask_kind = @subtaskKind');
2025 params.subtaskKind = subtaskKind; 2015 params.subtaskKind = subtaskKind;
@@ -2058,10 +2048,7 @@ const TASK_PATCH_COLUMNS = { @@ -2058,10 +2048,7 @@ const TASK_PATCH_COLUMNS = {
2058 * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次, 2048 * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次,
2059 * `requeueDerivedBlockedTask` 把它连同 blocked_reason 一起推回 queued。 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 /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ 2053 /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */
2067 const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; 2054 const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson'];
@@ -2188,54 +2175,12 @@ function settleSubtaskResult(taskId, status, summary = null) { @@ -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 * 两阶段建法:先把全部行建好拿到真实 task id,再回填 `depends_on`——`steps[].depends_on_hint` 2185 * 两阶段建法:先把全部行建好拿到真实 task id,再回填 `depends_on`——`steps[].depends_on_hint`
2241 * 是这批 steps 内部的 1-based `seq` 引用,`subtask-planning.js` 的 `normalizeSteps` 只保证 2186 * 是这批 steps 内部的 1-based `seq` 引用,`subtask-planning.js` 的 `normalizeSteps` 只保证
@@ -2243,7 +2188,10 @@ function unclaimSubtask(taskId, agentId) { @@ -2243,7 +2188,10 @@ function unclaimSubtask(taskId, agentId) {
2243 * 边建边填会在"前一步依赖后一步"这种前向引用上直接炸(`validateDependsOn` 要求依赖必须已存在)。 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 * `graph-engine.maybeMaterializeItemizedSubtasks` 的 `already` 检查——两处独立各写一份, 2195 * `graph-engine.maybeMaterializeItemizedSubtasks` 的 `already` 检查——两处独立各写一份,
2248 * 因为分属两个模块,不值得为一次判空抽个共享工具函数)。 2196 * 因为分属两个模块,不值得为一次判空抽个共享工具函数)。
2249 * 2197 *
@@ -2253,11 +2201,11 @@ function unclaimSubtask(taskId, agentId) { @@ -2253,11 +2201,11 @@ function unclaimSubtask(taskId, agentId) {
2253 * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,depends_on_hint?:number[]}>}} ctx 2201 * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,depends_on_hint?:number[]}>}} ctx
2254 * @returns {string[]} 物化出的 task id,按 steps 顺序;已物化过 / 无 steps 时返回 [] 2202 * @returns {string[]} 物化出的 task id,按 steps 顺序;已物化过 / 无 steps 时返回 []
2255 */ 2203 */
2256 -function materializeClaimableSubtasks({ task, steps }) { 2204 +function materializeDecomposedSubtasks({ task, steps }) {
2257 if (!task || !Array.isArray(steps) || !steps.length) return []; 2205 if (!task || !Array.isArray(steps) || !steps.length) return [];
2258 2206
2259 const already = getDb() 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 .get(task.id).n; 2209 .get(task.id).n;
2262 if (already > 0) return []; 2210 if (already > 0) return [];
2263 2211
@@ -2268,10 +2216,10 @@ function materializeClaimableSubtasks({ task, steps }) { @@ -2268,10 +2216,10 @@ function materializeClaimableSubtasks({ task, steps }) {
2268 companyId: task.company_id, 2216 companyId: task.company_id,
2269 title: String(step.title || '').slice(0, 200), 2217 title: String(step.title || '').slice(0, 200),
2270 description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''), 2218 description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''),
  2219 + executionMode: 'auto',
2271 createdBy: 'subtask-planning', 2220 createdBy: 'subtask-planning',
2272 parentTaskId: task.id, 2221 parentTaskId: task.id,
2273 - subtaskKind: 'claimable_work',  
2274 - status: 'claimable', 2222 + subtaskKind: 'decomposed_work',
2275 }); 2223 });
2276 seqToId.set(step.seq, row.id); 2224 seqToId.set(step.seq, row.id);
2277 } 2225 }
@@ -2286,6 +2234,47 @@ function materializeClaimableSubtasks({ task, steps }) { @@ -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 * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 2278 * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。
2290 * 2279 *
2291 * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, 2280 * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的,
@@ -2919,10 +2908,9 @@ module.exports = { @@ -2919,10 +2908,9 @@ module.exports = {
2919 settleTaskFromRun, 2908 settleTaskFromRun,
2920 settleSubtaskResult, 2909 settleSubtaskResult,
2921 GRAPH_TAKEOVER_ASSIGNEE, 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 // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 2914 // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」
2927 claimQueuedTask, 2915 claimQueuedTask,
2928 blockQueuedTask, 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(&#39;43 号文档 Phase 3&#39;, () =&gt; { @@ -142,7 +142,7 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; {
142 expect(out.node.status).toBe('success'); 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 + '不影响 acceptance_item 的验收判定——两套子任务完成判定互不干扰', async () => { 146 + '不影响 acceptance_item 的验收判定——两套子任务完成判定互不干扰', async () => {
147 const companyId = 'co-gate-6'; 147 const companyId = 'co-gate-6';
148 const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); 148 const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
@@ -151,11 +151,11 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; { @@ -151,11 +151,11 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; {
151 goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', 151 goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item',
152 }); 152 });
153 wdb.settleSubtaskResult(c1.id, 'completed'); // 验收条目全部完成 153 wdb.settleSubtaskResult(c1.id, 'completed'); // 验收条目全部完成
154 - // claimable_work 子任务还停在 claimable,永远不会 completed(Phase 3 不碰执行)—— 154 + // decomposed_work 子任务还停在 draft,还没被 CEO enqueue、更不会 completed——
155 // 如果闸门没收窄,这条会被当成"未完成的子任务"误伤,把上面本该通过的验收打成失败 155 // 如果闸门没收窄,这条会被当成"未完成的子任务"误伤,把上面本该通过的验收打成失败
156 wdb.createTask({ 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 const out = await runValidator(companyId, parent.id); 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,8 +7,23 @@
7 7
8 - [ ] 查 `GET /tasks?status=blocked` 和 `?status=failed`,看有没有需要处理的 8 - [ ] 查 `GET /tasks?status=blocked` 和 `?status=failed`,看有没有需要处理的
9 - [ ] 查 `GET /goals?status=active`,扫一眼有没有明显该调整优先级的 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 - 具体判断依据见 `tools.md` 的"什么时候该提新任务、什么时候该改优先级"一节 29 - 具体判断依据见 `tools.md` 的"什么时候该提新任务、什么时候该改优先级"一节
system/agents/ceo/tools.md
@@ -45,6 +45,14 @@ curl -s &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId&gt;/goals/ @@ -45,6 +45,14 @@ curl -s &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId&gt;/goals/
45 -H "x-internal-token: $LEMON_INTERNAL_TOKEN" 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 &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId @@ -76,6 +84,10 @@ curl -s -X POST &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId
76 必要的——`enqueue` 之后就会真的排队执行、真的烧预算。判断依据不够就先别 enqueue, 84 必要的——`enqueue` 之后就会真的排队执行、真的烧预算。判断依据不够就先别 enqueue,
77 留着草稿等人类看到也行。 85 留着草稿等人类看到也行。
78 86
  87 +**`decomposed_work` 子任务走同一个端点**——`subtask-planning` 已经把内容拆好了,你不用再判断
  88 +"清不清楚",只判断节奏(现在放还是等)。同一条 task 下的子任务可以一次性全部 enqueue,
  89 +`depends_on` 没完成的会被调度器自动拦住,不用你自己算顺序。
  90 +
79 --- 91 ---
80 92
81 ## 卡住的 run 节点:只能用这一个端点处理,不许自己动手 93 ## 卡住的 run 节点:只能用这一个端点处理,不许自己动手