Commit cecce7a2892105bb418e877a6750fe60ca96ee9a

Authored by yibo
1 parent 88fe3e44

feat(task-decomposition): 46号文档 Phase 3(任务池认领)实现

tasks 表新增 subtask_kind('acceptance_item'|'claimable_work',区分 43 号
文档的验收条目物化和 Phase 3 的可认领工作项物化,共用一张表不新开)+
claimed_at 列(批 6 迁移,ALTER ADD COLUMN,纯增量向后兼容)。

- work-graph-db.js:createTask 新增 subtaskKind/status 参数 + 校验;
  claimSubtask/unclaimSubtask 是全仓库第一个真正的"认领(pull)"CAS 写入
  (之前 assignee_agent_id 全部写入点都是"派发 push",没有先例可抄,照抄
  claimQueuedTask 的 changes 判据);materializeClaimableSubtasks 把
  subtask-planning 的 steps[] 物化成可认领子任务,两阶段建法处理
  depends_on_hint 的前向引用,幂等,与 acceptance_item 物化互不干扰。
- graph-engine.js:settleNode 的 SUBTASKS_INCOMPLETE 硬闸门收窄成只查
  subtask_kind='acceptance_item'——这是必须同步改的一步,不收窄的话新物化
  的 claimable_work 行会让所有开了 subtask-planning 的 task 验收判定失控
  (已用回归测试固定住这个场景)。maybeMaterializeItemizedSubtasks 的
  createTask 调用同步打上 subtask_kind='acceptance_item'。
- subtask-planning.js:triggerSubtaskPlanning 写完 artifact 后立刻物化
  claimable_work 子任务,不等 org-planner 跑完。
- company.js:taskView 补 subtaskKind/claimedAt;GET /tasks 加 subtaskKind
  过滤(?subtaskKind=claimable_work&status=claimable 就是可认领池查询);
  新增 POST .../claim、POST .../unclaim 两个端点,角色白名单先写死
  ['org-coder'](不做能力匹配,系统里没有结构化角色能力字段)。认领成功
  同时把 executor 改成 'agent',复用既有的 executor='agent' 派单闭环
  (/task/report 上报生命周期),不需要碰 graph-engine 的调度模型。

新增 task-pool-claim.test.js(23 条:DB 层校验/CAS/物化 + 路由层白名单/
并发/放弃);task-decomposition-validator-gate.test.js 补一条回归测试
(claimable_work 子任务不影响 acceptance_item 的验收判定)+ 更新既有用例
显式打 subtaskKind;task-decomposition-materialize.test.js 补
subtask_kind 落库断言。全量单测 1202 条,1200 passed(唯二失败与本次改动
无关、改动前即存在)。

本轮明确不做:依赖顺序强制、能力匹配、graph-engine 调度模型改动、
Phase 4 的独立开发验证循环、Phase 5 看板 UI。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H85SS5hVC4u1PbVhjmrhH8
server/routes/company.js
... ... @@ -1026,6 +1026,9 @@ 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
  1030 + subtaskKind: t.subtask_kind || null,
  1031 + claimedAt: t.claimed_at || null,
1029 1032 createdAt: t.created_at,
1030 1033 queuedAt: t.queued_at,
1031 1034 startedAt: t.started_at,
... ... @@ -2109,6 +2112,8 @@ router.get(&#39;/:companyId/tasks&#39;, (req, res) =&gt; {
2109 2112 goalId: req.query.goalId || req.query.goal_id || null,
2110 2113 // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务
2111 2114 parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null,
  2115 + // 46 号文档 Phase 3:`?subtaskKind=claimable_work&status=claimable` = 当前可认领池
  2116 + subtaskKind: req.query.subtaskKind || req.query.subtask_kind || null,
2112 2117 includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true',
2113 2118 limit: Number.isInteger(limit) ? limit : null,
2114 2119 });
... ... @@ -2386,6 +2391,81 @@ router.post(&#39;/:companyId/tasks/:taskId/subtask-result&#39;, (req, res) =&gt; {
2386 2391 });
2387 2392  
2388 2393 /**
  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 +/**
2389 2469 * POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3)
2390 2470 *
2391 2471 * 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列,
... ...
server/services/graph-engine.js
... ... @@ -1878,7 +1878,14 @@ function settleNode(runId, nodeRunId, outcome = {}) {
1878 1878 // 由 coder-validator 调 /subtask-result 端点逐条写(server/routes/company.js)。
1879 1879 const dRole = def0.nodes.get(nodeRun0.def_node_id);
1880 1880 if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) {
1881   - const subtasks = getDb().prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ?`).all(run0.task_id);
  1881 + // 46 号文档 Phase 3:这条闸门只该看 43 号文档的验收条目子任务——`AND subtask_kind =
  1882 + // 'acceptance_item'` 是必须的收窄,不是可选项。Phase 3 会往同一个 parent_task_id
  1883 + // 底下物化第二种子任务(claimable_work,等着被认领执行,完成生命周期完全独立、
  1884 + // 跟 coder-validator 的验收判定无关),不收窄这条查询会把它们也扫进来,天然不会
  1885 + // 是 completed,直接把所有开了 subtask-planning 的 task 验收判定拖垮。
  1886 + const subtasks = getDb()
  1887 + .prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`)
  1888 + .all(run0.task_id);
1882 1889 const notDone = subtasks.filter((t) => t.status !== 'completed').map((t) => t.id);
1883 1890 if (notDone.length) {
1884 1891 to = 'fail';
... ... @@ -2426,7 +2433,11 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def,
2426 2433 const task = getTask(run.task_id);
2427 2434 if (!task || task.acceptance_mode !== 'itemized') return;
2428 2435  
2429   - const already = getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n;
  2436 + // 46 号文档 Phase 3:只数 acceptance_item——同一个 task 现在可能同时挂着 subtask-planning
  2437 + // 物化的 claimable_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。
  2438 + const already = getDb()
  2439 + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`)
  2440 + .get(task.id).n;
2430 2441 if (already > 0) return;
2431 2442  
2432 2443 const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run;
... ... @@ -2467,6 +2478,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def,
2467 2478 description: `判定方式:${row.method || '(未写明)'}\npass 条件:${row.passCondition || '(未写明)'}`,
2468 2479 createdBy: 'org-planner',
2469 2480 parentTaskId: task.id,
  2481 + subtaskKind: 'acceptance_item', // 46 号文档 Phase 3:显式打标,见 subtask_kind 列注释
2470 2482 });
2471 2483 }
2472 2484 timelineNote({ runId, nodeRunId }, 'node.output',
... ...
server/services/subtask-planning.js
... ... @@ -388,7 +388,12 @@ async function triggerSubtaskPlanning({ run, task }) {
388 388 nodeRunId: null,
389 389 });
390 390  
391   - console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens)`);
  391 + // 46 号文档 Phase 3:把这批 steps 物化成可认领子任务。跟上面写 artifact/登记索引一样,
  392 + // 走同一个 try/catch——物化失败不该让 subtask-plan.json 已经写好的部分失效。
  393 + const materializedIds = wdb.materializeClaimableSubtasks({ task, steps });
  394 +
  395 + console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),`
  396 + + `物化 ${materializedIds.length} 条可认领子任务`);
392 397 } catch (err) {
393 398 console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`);
394 399 }
... ...
server/services/work-graph-db.js
... ... @@ -528,6 +528,7 @@ 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' 有意义)
531 532 -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现:
532 533 -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释
533 534 execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')),
... ... @@ -559,6 +560,15 @@ function getDb() {
559 560 -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 ——
560 561 -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。
561 562 parent_task_id TEXT REFERENCES tasks(id),
  563 + -- 46 号文档 Phase 3:parent_task_id 非空的子任务分两种,靠这一列区分——
  564 + -- 'acceptance_item' 43 号文档:验收条目物化(coder-validator 逐条上报,见 settleSubtaskResult)
  565 + -- 'claimable_work' Phase 3:subtask-planning 步骤分解物化,等着被认领(见 claimSubtask)
  566 + -- NULL = 普通 task(不是子任务,或 parent_task_id 也是 NULL)。两套子任务共用同一张表
  567 + -- (而不是各开一张),因为 tasks 表本来就是通用工作项容器,这一列只是给已经混杂的
  568 + -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开
  569 + -- (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 有意义
562 572 created_at TEXT NOT NULL,
563 573 updated_at TEXT NOT NULL,
564 574 queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4)
... ... @@ -647,6 +657,16 @@ function getDb() {
647 657 // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。
648 658 db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(company_id, assignee_agent_id, created_at)`);
649 659  
  660 + // ── 批 6:46 号文档 Phase 3,tasks 追加 subtask_kind / claimed_at ────────────
  661 + // 同批 5 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上 subtask_kind 的取值域
  662 + // 只由 createTask 的应用层校验守,与新建库的 CHECK 不同源。
  663 + if (!hasColumn(db, 'tasks', 'subtask_kind')) {
  664 + db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`);
  665 + }
  666 + if (!hasColumn(db, 'tasks', 'claimed_at')) {
  667 + db.exec(`ALTER TABLE tasks ADD COLUMN claimed_at TEXT`);
  668 + }
  669 +
650 670 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。
651 671 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) {
652 672 db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`);
... ... @@ -1885,6 +1905,8 @@ function createTask({
1885 1905 createdBy = null,
1886 1906 parentTaskId = null,
1887 1907 acceptanceMode = 'holistic',
  1908 + subtaskKind = null,
  1909 + status = 'draft',
1888 1910 } = {}) {
1889 1911 if (!goalId) throw new Error('createTask: goalId 必填');
1890 1912 if (!title) throw new Error('createTask: title 必填');
... ... @@ -1895,6 +1917,21 @@ function createTask({
1895 1917 if (parentTaskId && acceptanceMode === 'itemized') {
1896 1918 throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分');
1897 1919 }
  1920 + // 46 号文档 Phase 3:subtask_kind 只对子任务有意义,取值域应用层守(同上 acceptanceMode 那条注释)
  1921 + if (subtaskKind !== null && !['acceptance_item', 'claimable_work'].includes(subtaskKind)) {
  1922 + throw new Error(`createTask: subtaskKind 非法: ${subtaskKind}`);
  1923 + }
  1924 + if (subtaskKind && !parentTaskId) {
  1925 + throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义');
  1926 + }
  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 + }
1898 1935  
1899 1936 const db = getDb();
1900 1937 const goal = getGoal(goalId);
... ... @@ -1926,16 +1963,16 @@ function createTask({
1926 1963 const ins = db.prepare(`
1927 1964 INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status,
1928 1965 execution_mode, depends_on, priority, budget_json, report_to, created_by,
1929   - parent_task_id, acceptance_mode, created_at, updated_at)
1930   - VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  1966 + parent_task_id, acceptance_mode, subtask_kind, created_at, updated_at)
  1967 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1931 1968 `);
1932 1969 const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run(
1933   - tid, goalId, String(cid), nextSeq, String(title), String(description),
  1970 + tid, goalId, String(cid), nextSeq, String(title), String(description), status,
1934 1971 executionMode, JSON.stringify(deps), priority, budget,
1935 1972 // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。
1936 1973 // goal 上有就继承下来,别让每个调用方各记一遍。
1937 1974 reportTo || goal.report_to || null,
1938   - createdBy, parentTaskId, acceptanceMode, now, now
  1975 + createdBy, parentTaskId, acceptanceMode, subtaskKind, now, now
1939 1976 ));
1940 1977 return getTask(id);
1941 1978 }
... ... @@ -1962,7 +1999,8 @@ function listGoalTasks(goalId, { includeArchived = false } = {}) {
1962 1999 * 导致公司失败一次后再也下不了任务,就是这么来的。要位次找调度器要。
1963 2000 */
1964 2001 function listCompanyTasks(companyId, {
1965   - status = null, goalId = null, parentTaskId = null, includeArchived = false, limit = null,
  2002 + status = null, goalId = null, parentTaskId = null, subtaskKind = null,
  2003 + includeArchived = false, limit = null,
1966 2004 } = {}) {
1967 2005 const where = ['company_id = @companyId'];
1968 2006 const params = { companyId };
... ... @@ -1980,6 +2018,12 @@ function listCompanyTasks(companyId, {
1980 2018 where.push('parent_task_id = @parentTaskId');
1981 2019 params.parentTaskId = parentTaskId;
1982 2020 }
  2021 + // 46 号文档 Phase 3:`?subtaskKind=claimable_work&status=claimable` 就是"这家公司当前
  2022 + // 可认领池"的查询——不需要专门的池子端点,复用这条通用列表接口加一个过滤参数。
  2023 + if (subtaskKind) {
  2024 + where.push('subtask_kind = @subtaskKind');
  2025 + params.subtaskKind = subtaskKind;
  2026 + }
1983 2027 if (!includeArchived) where.push('archived_at IS NULL');
1984 2028 const lim = Number.isInteger(limit) && limit > 0 ? ` LIMIT ${limit}` : '';
1985 2029 return getDb()
... ... @@ -2014,7 +2058,10 @@ const TASK_PATCH_COLUMNS = {
2014 2058 * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次,
2015 2059 * `requeueDerivedBlockedTask` 把它连同 blocked_reason 一起推回 queued。
2016 2060 */
2017   -const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked'];
  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'];
2018 2065  
2019 2066 /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */
2020 2067 const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson'];
... ... @@ -2141,6 +2188,104 @@ function settleSubtaskResult(taskId, status, summary = null) {
2141 2188 }
2142 2189  
2143 2190 /**
  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[]` 物化成可认领子任务。
  2239 + *
  2240 + * 两阶段建法:先把全部行建好拿到真实 task id,再回填 `depends_on`——`steps[].depends_on_hint`
  2241 + * 是这批 steps 内部的 1-based `seq` 引用,`subtask-planning.js` 的 `normalizeSteps` 只保证
  2242 + * 它指向本批次内某个存在的 `seq`,**不保证只能指向更早的 seq**(agent.md 没有强制这条顺序),
  2243 + * 边建边填会在"前一步依赖后一步"这种前向引用上直接炸(`validateDependsOn` 要求依赖必须已存在)。
  2244 + * 先建后填就不用管顺序。
  2245 + *
  2246 + * 幂等:已经物化过 `claimable_work` 子任务的 task 直接跳过,不重复物化(同款判据见
  2247 + * `graph-engine.maybeMaterializeItemizedSubtasks` 的 `already` 检查——两处独立各写一份,
  2248 + * 因为分属两个模块,不值得为一次判空抽个共享工具函数)。
  2249 + *
  2250 + * 找不到某个 `depends_on_hint` 对应的兄弟行(理论上不该发生,防御性处理)就丢那一条引用,
  2251 + * 不作废整批——延续 Phase 1 定的"宽松解析"哲学。
  2252 + *
  2253 + * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,depends_on_hint?:number[]}>}} ctx
  2254 + * @returns {string[]} 物化出的 task id,按 steps 顺序;已物化过 / 无 steps 时返回 []
  2255 + */
  2256 +function materializeClaimableSubtasks({ task, steps }) {
  2257 + if (!task || !Array.isArray(steps) || !steps.length) return [];
  2258 +
  2259 + const already = getDb()
  2260 + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'claimable_work'`)
  2261 + .get(task.id).n;
  2262 + if (already > 0) return [];
  2263 +
  2264 + const seqToId = new Map();
  2265 + for (const step of steps) {
  2266 + const row = createTask({
  2267 + goalId: task.goal_id,
  2268 + companyId: task.company_id,
  2269 + title: String(step.title || '').slice(0, 200),
  2270 + description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''),
  2271 + createdBy: 'subtask-planning',
  2272 + parentTaskId: task.id,
  2273 + subtaskKind: 'claimable_work',
  2274 + status: 'claimable',
  2275 + });
  2276 + seqToId.set(step.seq, row.id);
  2277 + }
  2278 +
  2279 + for (const step of steps) {
  2280 + const hints = Array.isArray(step.depends_on_hint) ? step.depends_on_hint : [];
  2281 + const deps = hints.map((h) => seqToId.get(h)).filter(Boolean);
  2282 + if (deps.length) updateTask(seqToId.get(step.seq), { dependsOn: deps });
  2283 + }
  2284 +
  2285 + return steps.map((s) => seqToId.get(s.seq));
  2286 +}
  2287 +
  2288 +/**
2144 2289 * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。
2145 2290 *
2146 2291 * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的,
... ... @@ -2774,6 +2919,10 @@ module.exports = {
2774 2919 settleTaskFromRun,
2775 2920 settleSubtaskResult,
2776 2921 GRAPH_TAKEOVER_ASSIGNEE,
  2922 + // 46 号文档 Phase 3:任务池认领
  2923 + claimSubtask,
  2924 + unclaimSubtask,
  2925 + materializeClaimableSubtasks,
2777 2926 // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」
2778 2927 claimQueuedTask,
2779 2928 blockQueuedTask,
... ...
server/tests/unit/task-decomposition-materialize.test.js
... ... @@ -152,7 +152,12 @@ describe(&#39;43 号文档 Phase 2:acceptance.md → 子任务物化&#39;, () =&gt; {
152 152 expect(children[0].acceptance_mode).toBe('holistic');
153 153 expect(children[0].description).toContain('手动操作');
154 154 expect(children[0].description).toContain('蛇按方向键移动');
  155 + // 46 号文档 Phase 3:必须打上 subtask_kind='acceptance_item'——
  156 + // 少了它,settleNode 的 SUBTASKS_INCOMPLETE 闸门收窄之后会把这些行当"看不见",
  157 + // 验收判定会失效(永远不会因为子任务未完成而拦下 success)
  158 + expect(children[0].subtask_kind).toBe('acceptance_item');
155 159 expect(children[1].title).toBe('吃到食物加分');
  160 + expect(children[1].subtask_kind).toBe('acceptance_item');
156 161 });
157 162  
158 163 it('holistic 任务:org-planner 成功结算,不物化任何子任务', async () => {
... ...
server/tests/unit/task-decomposition-validator-gate.test.js
... ... @@ -67,7 +67,9 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; {
67 67 it('settleSubtaskResult:正常上报 + 终态幂等(第二次不同结果上报不生效)', () => {
68 68 const goal = wdb.createGoal({ companyId: 'co-gate-2', title: 'g', description: 'd' });
69 69 const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' });
70   - const child = wdb.createTask({ goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id });
  70 + const child = wdb.createTask({
  71 + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item',
  72 + });
71 73  
72 74 expect(wdb.settleSubtaskResult(child.id, 'completed', '通过')).toBe(1);
73 75 const after1 = wdb.getTask(child.id);
... ... @@ -108,8 +110,12 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; {
108 110 const companyId = 'co-gate-3';
109 111 const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
110 112 const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' });
111   - const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id });
112   - wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id });
  113 + const c1 = wdb.createTask({
  114 + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item',
  115 + });
  116 + wdb.createTask({
  117 + goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item',
  118 + });
113 119 wdb.settleSubtaskResult(c1.id, 'completed'); // 只完成一半
114 120  
115 121 const out = await runValidator(companyId, parent.id);
... ... @@ -123,8 +129,12 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; {
123 129 const companyId = 'co-gate-4';
124 130 const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
125 131 const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' });
126   - const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id });
127   - const c2 = wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id });
  132 + const c1 = wdb.createTask({
  133 + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item',
  134 + });
  135 + const c2 = wdb.createTask({
  136 + goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item',
  137 + });
128 138 wdb.settleSubtaskResult(c1.id, 'completed');
129 139 wdb.settleSubtaskResult(c2.id, 'completed');
130 140  
... ... @@ -132,6 +142,26 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; {
132 142 expect(out.node.status).toBe('success');
133 143 });
134 144  
  145 + it('46 号文档 Phase 3:同一个 task 下还挂着 claimable_work 子任务(未 completed)时,'
  146 + + '不影响 acceptance_item 的验收判定——两套子任务完成判定互不干扰', async () => {
  147 + const companyId = 'co-gate-6';
  148 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  149 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' });
  150 + const c1 = wdb.createTask({
  151 + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item',
  152 + });
  153 + wdb.settleSubtaskResult(c1.id, 'completed'); // 验收条目全部完成
  154 + // claimable_work 子任务还停在 claimable,永远不会 completed(Phase 3 不碰执行)——
  155 + // 如果闸门没收窄,这条会被当成"未完成的子任务"误伤,把上面本该通过的验收打成失败
  156 + wdb.createTask({
  157 + goalId: goal.id, title: 'claim-me', description: 'd', parentTaskId: parent.id,
  158 + subtaskKind: 'claimable_work', status: 'claimable',
  159 + });
  160 +
  161 + const out = await runValidator(companyId, parent.id);
  162 + expect(out.node.status).toBe('success');
  163 + });
  164 +
135 165 it('holistic 任务(没有子任务):闸门不生效,success 照常通过', async () => {
136 166 const companyId = 'co-gate-5';
137 167 const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
... ...
server/tests/unit/task-pool-claim.test.js 0 → 100644
  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 +});
... ...