Commit cecce7a2892105bb418e877a6750fe60ca96ee9a
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
Showing
7 changed files
with
635 additions
and
14 deletions
server/routes/company.js
| @@ -1026,6 +1026,9 @@ function taskView(t, progress = null, positions = null) { | @@ -1026,6 +1026,9 @@ 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 | ||
| 1030 | + subtaskKind: t.subtask_kind || null, | ||
| 1031 | + claimedAt: t.claimed_at || null, | ||
| 1029 | createdAt: t.created_at, | 1032 | createdAt: t.created_at, |
| 1030 | queuedAt: t.queued_at, | 1033 | queuedAt: t.queued_at, |
| 1031 | startedAt: t.started_at, | 1034 | startedAt: t.started_at, |
| @@ -2109,6 +2112,8 @@ router.get('/:companyId/tasks', (req, res) => { | @@ -2109,6 +2112,8 @@ router.get('/:companyId/tasks', (req, res) => { | ||
| 2109 | goalId: req.query.goalId || req.query.goal_id || null, | 2112 | goalId: req.query.goalId || req.query.goal_id || null, |
| 2110 | // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务 | 2113 | // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务 |
| 2111 | parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null, | 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 | includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', | 2117 | includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', |
| 2113 | limit: Number.isInteger(limit) ? limit : null, | 2118 | limit: Number.isInteger(limit) ? limit : null, |
| 2114 | }); | 2119 | }); |
| @@ -2386,6 +2391,81 @@ router.post('/:companyId/tasks/:taskId/subtask-result', (req, res) => { | @@ -2386,6 +2391,81 @@ router.post('/:companyId/tasks/:taskId/subtask-result', (req, res) => { | ||
| 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 | * POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3) | 2469 | * POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3) |
| 2390 | * | 2470 | * |
| 2391 | * 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列, | 2471 | * 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列, |
server/services/graph-engine.js
| @@ -1878,7 +1878,14 @@ function settleNode(runId, nodeRunId, outcome = {}) { | @@ -1878,7 +1878,14 @@ function settleNode(runId, nodeRunId, outcome = {}) { | ||
| 1878 | // 由 coder-validator 调 /subtask-result 端点逐条写(server/routes/company.js)。 | 1878 | // 由 coder-validator 调 /subtask-result 端点逐条写(server/routes/company.js)。 |
| 1879 | const dRole = def0.nodes.get(nodeRun0.def_node_id); | 1879 | const dRole = def0.nodes.get(nodeRun0.def_node_id); |
| 1880 | if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) { | 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 | const notDone = subtasks.filter((t) => t.status !== 'completed').map((t) => t.id); | 1889 | const notDone = subtasks.filter((t) => t.status !== 'completed').map((t) => t.id); |
| 1883 | if (notDone.length) { | 1890 | if (notDone.length) { |
| 1884 | to = 'fail'; | 1891 | to = 'fail'; |
| @@ -2426,7 +2433,11 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, | @@ -2426,7 +2433,11 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, | ||
| 2426 | const task = getTask(run.task_id); | 2433 | const task = getTask(run.task_id); |
| 2427 | if (!task || task.acceptance_mode !== 'itemized') return; | 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 | if (already > 0) return; | 2441 | if (already > 0) return; |
| 2431 | 2442 | ||
| 2432 | const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run; | 2443 | const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run; |
| @@ -2467,6 +2478,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, | @@ -2467,6 +2478,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, | ||
| 2467 | description: `判定方式:${row.method || '(未写明)'}\npass 条件:${row.passCondition || '(未写明)'}`, | 2478 | description: `判定方式:${row.method || '(未写明)'}\npass 条件:${row.passCondition || '(未写明)'}`, |
| 2468 | createdBy: 'org-planner', | 2479 | createdBy: 'org-planner', |
| 2469 | parentTaskId: task.id, | 2480 | parentTaskId: task.id, |
| 2481 | + subtaskKind: 'acceptance_item', // 46 号文档 Phase 3:显式打标,见 subtask_kind 列注释 | ||
| 2470 | }); | 2482 | }); |
| 2471 | } | 2483 | } |
| 2472 | timelineNote({ runId, nodeRunId }, 'node.output', | 2484 | timelineNote({ runId, nodeRunId }, 'node.output', |
server/services/subtask-planning.js
| @@ -388,7 +388,12 @@ async function triggerSubtaskPlanning({ run, task }) { | @@ -388,7 +388,12 @@ async function triggerSubtaskPlanning({ run, task }) { | ||
| 388 | nodeRunId: null, | 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 | } catch (err) { | 397 | } catch (err) { |
| 393 | console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`); | 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,6 +528,7 @@ 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' 有意义) | ||
| 531 | -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现: | 532 | -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现: |
| 532 | -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释 | 533 | -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释 |
| 533 | execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), | 534 | execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), |
| @@ -559,6 +560,15 @@ function getDb() { | @@ -559,6 +560,15 @@ function getDb() { | ||
| 559 | -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 —— | 560 | -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 —— |
| 560 | -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。 | 561 | -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。 |
| 561 | parent_task_id TEXT REFERENCES tasks(id), | 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 | created_at TEXT NOT NULL, | 572 | created_at TEXT NOT NULL, |
| 563 | updated_at TEXT NOT NULL, | 573 | updated_at TEXT NOT NULL, |
| 564 | queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) | 574 | queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) |
| @@ -647,6 +657,16 @@ function getDb() { | @@ -647,6 +657,16 @@ function getDb() { | ||
| 647 | // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。 | 657 | // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。 |
| 648 | 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)`); |
| 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 | // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 | 670 | // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 |
| 651 | if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { | 671 | if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { |
| 652 | db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); | 672 | db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); |
| @@ -1885,6 +1905,8 @@ function createTask({ | @@ -1885,6 +1905,8 @@ function createTask({ | ||
| 1885 | createdBy = null, | 1905 | createdBy = null, |
| 1886 | parentTaskId = null, | 1906 | parentTaskId = null, |
| 1887 | acceptanceMode = 'holistic', | 1907 | acceptanceMode = 'holistic', |
| 1908 | + subtaskKind = null, | ||
| 1909 | + status = 'draft', | ||
| 1888 | } = {}) { | 1910 | } = {}) { |
| 1889 | if (!goalId) throw new Error('createTask: goalId 必填'); | 1911 | if (!goalId) throw new Error('createTask: goalId 必填'); |
| 1890 | if (!title) throw new Error('createTask: title 必填'); | 1912 | if (!title) throw new Error('createTask: title 必填'); |
| @@ -1895,6 +1917,21 @@ function createTask({ | @@ -1895,6 +1917,21 @@ function createTask({ | ||
| 1895 | if (parentTaskId && acceptanceMode === 'itemized') { | 1917 | if (parentTaskId && acceptanceMode === 'itemized') { |
| 1896 | throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分'); | 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 | const db = getDb(); | 1936 | const db = getDb(); |
| 1900 | const goal = getGoal(goalId); | 1937 | const goal = getGoal(goalId); |
| @@ -1926,16 +1963,16 @@ function createTask({ | @@ -1926,16 +1963,16 @@ function createTask({ | ||
| 1926 | const ins = db.prepare(` | 1963 | const ins = db.prepare(` |
| 1927 | INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, | 1964 | INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, |
| 1928 | execution_mode, depends_on, priority, budget_json, report_to, created_by, | 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 | const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( | 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 | executionMode, JSON.stringify(deps), priority, budget, | 1971 | executionMode, JSON.stringify(deps), priority, budget, |
| 1935 | // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 | 1972 | // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 |
| 1936 | // goal 上有就继承下来,别让每个调用方各记一遍。 | 1973 | // goal 上有就继承下来,别让每个调用方各记一遍。 |
| 1937 | reportTo || goal.report_to || null, | 1974 | reportTo || goal.report_to || null, |
| 1938 | - createdBy, parentTaskId, acceptanceMode, now, now | 1975 | + createdBy, parentTaskId, acceptanceMode, subtaskKind, now, now |
| 1939 | )); | 1976 | )); |
| 1940 | return getTask(id); | 1977 | return getTask(id); |
| 1941 | } | 1978 | } |
| @@ -1962,7 +1999,8 @@ function listGoalTasks(goalId, { includeArchived = false } = {}) { | @@ -1962,7 +1999,8 @@ function listGoalTasks(goalId, { includeArchived = false } = {}) { | ||
| 1962 | * 导致公司失败一次后再也下不了任务,就是这么来的。要位次找调度器要。 | 1999 | * 导致公司失败一次后再也下不了任务,就是这么来的。要位次找调度器要。 |
| 1963 | */ | 2000 | */ |
| 1964 | function listCompanyTasks(companyId, { | 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 | const where = ['company_id = @companyId']; | 2005 | const where = ['company_id = @companyId']; |
| 1968 | const params = { companyId }; | 2006 | const params = { companyId }; |
| @@ -1980,6 +2018,12 @@ function listCompanyTasks(companyId, { | @@ -1980,6 +2018,12 @@ function listCompanyTasks(companyId, { | ||
| 1980 | where.push('parent_task_id = @parentTaskId'); | 2018 | where.push('parent_task_id = @parentTaskId'); |
| 1981 | params.parentTaskId = parentTaskId; | 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 | if (!includeArchived) where.push('archived_at IS NULL'); | 2027 | if (!includeArchived) where.push('archived_at IS NULL'); |
| 1984 | const lim = Number.isInteger(limit) && limit > 0 ? ` LIMIT ${limit}` : ''; | 2028 | const lim = Number.isInteger(limit) && limit > 0 ? ` LIMIT ${limit}` : ''; |
| 1985 | return getDb() | 2029 | return getDb() |
| @@ -2014,7 +2058,10 @@ const TASK_PATCH_COLUMNS = { | @@ -2014,7 +2058,10 @@ const TASK_PATCH_COLUMNS = { | ||
| 2014 | * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次, | 2058 | * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次, |
| 2015 | * `requeueDerivedBlockedTask` 把它连同 blocked_reason 一起推回 queued。 | 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 | /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ | 2066 | /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ |
| 2020 | const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; | 2067 | const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; |
| @@ -2141,6 +2188,104 @@ function settleSubtaskResult(taskId, status, summary = null) { | @@ -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 | * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 | 2289 | * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 |
| 2145 | * | 2290 | * |
| 2146 | * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, | 2291 | * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, |
| @@ -2774,6 +2919,10 @@ module.exports = { | @@ -2774,6 +2919,10 @@ module.exports = { | ||
| 2774 | settleTaskFromRun, | 2919 | settleTaskFromRun, |
| 2775 | settleSubtaskResult, | 2920 | settleSubtaskResult, |
| 2776 | GRAPH_TAKEOVER_ASSIGNEE, | 2921 | GRAPH_TAKEOVER_ASSIGNEE, |
| 2922 | + // 46 号文档 Phase 3:任务池认领 | ||
| 2923 | + claimSubtask, | ||
| 2924 | + unclaimSubtask, | ||
| 2925 | + materializeClaimableSubtasks, | ||
| 2777 | // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 | 2926 | // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 |
| 2778 | claimQueuedTask, | 2927 | claimQueuedTask, |
| 2779 | blockQueuedTask, | 2928 | blockQueuedTask, |
server/tests/unit/task-decomposition-materialize.test.js
| @@ -152,7 +152,12 @@ describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { | @@ -152,7 +152,12 @@ describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { | ||
| 152 | expect(children[0].acceptance_mode).toBe('holistic'); | 152 | expect(children[0].acceptance_mode).toBe('holistic'); |
| 153 | expect(children[0].description).toContain('手动操作'); | 153 | expect(children[0].description).toContain('手动操作'); |
| 154 | expect(children[0].description).toContain('蛇按方向键移动'); | 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 | expect(children[1].title).toBe('吃到食物加分'); | 159 | expect(children[1].title).toBe('吃到食物加分'); |
| 160 | + expect(children[1].subtask_kind).toBe('acceptance_item'); | ||
| 156 | }); | 161 | }); |
| 157 | 162 | ||
| 158 | it('holistic 任务:org-planner 成功结算,不物化任何子任务', async () => { | 163 | it('holistic 任务:org-planner 成功结算,不物化任何子任务', async () => { |
server/tests/unit/task-decomposition-validator-gate.test.js
| @@ -67,7 +67,9 @@ describe('43 号文档 Phase 3', () => { | @@ -67,7 +67,9 @@ describe('43 号文档 Phase 3', () => { | ||
| 67 | it('settleSubtaskResult:正常上报 + 终态幂等(第二次不同结果上报不生效)', () => { | 67 | it('settleSubtaskResult:正常上报 + 终态幂等(第二次不同结果上报不生效)', () => { |
| 68 | const goal = wdb.createGoal({ companyId: 'co-gate-2', title: 'g', description: 'd' }); | 68 | const goal = wdb.createGoal({ companyId: 'co-gate-2', title: 'g', description: 'd' }); |
| 69 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); | 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 | expect(wdb.settleSubtaskResult(child.id, 'completed', '通过')).toBe(1); | 74 | expect(wdb.settleSubtaskResult(child.id, 'completed', '通过')).toBe(1); |
| 73 | const after1 = wdb.getTask(child.id); | 75 | const after1 = wdb.getTask(child.id); |
| @@ -108,8 +110,12 @@ describe('43 号文档 Phase 3', () => { | @@ -108,8 +110,12 @@ describe('43 号文档 Phase 3', () => { | ||
| 108 | const companyId = 'co-gate-3'; | 110 | const companyId = 'co-gate-3'; |
| 109 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | 111 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); |
| 110 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); | 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 | wdb.settleSubtaskResult(c1.id, 'completed'); // 只完成一半 | 119 | wdb.settleSubtaskResult(c1.id, 'completed'); // 只完成一半 |
| 114 | 120 | ||
| 115 | const out = await runValidator(companyId, parent.id); | 121 | const out = await runValidator(companyId, parent.id); |
| @@ -123,8 +129,12 @@ describe('43 号文档 Phase 3', () => { | @@ -123,8 +129,12 @@ describe('43 号文档 Phase 3', () => { | ||
| 123 | const companyId = 'co-gate-4'; | 129 | const companyId = 'co-gate-4'; |
| 124 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | 130 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); |
| 125 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); | 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 | wdb.settleSubtaskResult(c1.id, 'completed'); | 138 | wdb.settleSubtaskResult(c1.id, 'completed'); |
| 129 | wdb.settleSubtaskResult(c2.id, 'completed'); | 139 | wdb.settleSubtaskResult(c2.id, 'completed'); |
| 130 | 140 | ||
| @@ -132,6 +142,26 @@ describe('43 号文档 Phase 3', () => { | @@ -132,6 +142,26 @@ describe('43 号文档 Phase 3', () => { | ||
| 132 | expect(out.node.status).toBe('success'); | 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 | it('holistic 任务(没有子任务):闸门不生效,success 照常通过', async () => { | 165 | it('holistic 任务(没有子任务):闸门不生效,success 照常通过', async () => { |
| 136 | const companyId = 'co-gate-5'; | 166 | const companyId = 'co-gate-5'; |
| 137 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | 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 | +}); |