From cecce7a2892105bb418e877a6750fe60ca96ee9a Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 17:44:01 +0800 Subject: [PATCH] feat(task-decomposition): 46号文档 Phase 3(任务池认领)实现 --- server/routes/company.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/services/graph-engine.js | 16 ++++++++++++++-- server/services/subtask-planning.js | 7 ++++++- server/services/work-graph-db.js | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ server/tests/unit/task-decomposition-materialize.test.js | 5 +++++ server/tests/unit/task-decomposition-validator-gate.test.js | 40 +++++++++++++++++++++++++++++++++++----- server/tests/unit/task-pool-claim.test.js | 340 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 635 insertions(+), 14 deletions(-) create mode 100644 server/tests/unit/task-pool-claim.test.js diff --git a/server/routes/company.js b/server/routes/company.js index 654bcbd..47fc274 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1026,6 +1026,9 @@ function taskView(t, progress = null, positions = null) { // 43 号文档:子任务树的父子指针 + 这条 task 走哪种验收模式(看板据此决定要不要画子任务) parentTaskId: t.parent_task_id || null, acceptanceMode: t.acceptance_mode || 'holistic', + // 46 号文档 Phase 3:两种子任务靠这个区分——'acceptance_item' | 'claimable_work' | null + subtaskKind: t.subtask_kind || null, + claimedAt: t.claimed_at || null, createdAt: t.created_at, queuedAt: t.queued_at, startedAt: t.started_at, @@ -2109,6 +2112,8 @@ router.get('/:companyId/tasks', (req, res) => { goalId: req.query.goalId || req.query.goal_id || null, // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务 parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null, + // 46 号文档 Phase 3:`?subtaskKind=claimable_work&status=claimable` = 当前可认领池 + subtaskKind: req.query.subtaskKind || req.query.subtask_kind || null, includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', limit: Number.isInteger(limit) ? limit : null, }); @@ -2386,6 +2391,81 @@ router.post('/:companyId/tasks/:taskId/subtask-result', (req, res) => { }); /** + * 46 号文档 Phase 3:可认领子任务(`subtask_kind='claimable_work'`)先只对这个角色白名单开放。 + * 写死不做能力匹配——系统里没有任何结构化的角色能力字段(调研已确认), + * `org-coder` 是当前唯一的通用"写代码"角色,且物化出的 steps 基本都是实现类工作。 + * 真需要扩展到多角色再加,不为假想的未来预先建模。 + */ +const CLAIMABLE_WORK_ROLES = ['org-coder']; + +/** + * POST /api/company/:companyId/tasks/:taskId/claim — 46 号文档 Phase 3 + * body: { agentId } + * + * 认领一条可认领子任务。全仓库第一个真正的"认领(pull)"语义端点——并发保护见 + * `work-graph-db.claimSubtask`(CAS,`changes===0` 就是没抢到,返回 409 不是 500, + * 这是预期会发生的正常竞争结果)。 + */ +router.post('/:companyId/tasks/:taskId/claim', (req, res) => { + try { + const wdb = require('../services/work-graph-db'); + const task = requireTask(req, res); + if (!task) return; + if (task.subtask_kind !== 'claimable_work') { + return res.status(400).json({ + error: `${task.id} 不是可认领子任务(subtask_kind !== 'claimable_work')`, + }); + } + const { agentId } = req.body || {}; + if (!agentId) return res.status(400).json({ error: 'agentId 必填' }); + + const hired = listCompanyAgents(req.params.companyId) + .find((r) => r.system_agent_id === agentId || r.role_name === agentId); + if (!hired || !CLAIMABLE_WORK_ROLES.includes(hired.role_name)) { + return res.status(403).json({ + error: `${agentId} 不在可认领角色白名单里(${CLAIMABLE_WORK_ROLES.join(', ')})`, + }); + } + + const changed = wdb.claimSubtask(task.id, agentId); + if (!changed) { + return res.status(409).json({ + error: `认领失败:${task.id} 已经不是可认领状态(被别人抢先认领,或状态已变化)`, + }); + } + res.json({ ok: true, task: taskView(wdb.getTask(task.id)) }); + } catch (err) { + console.error('[TaskEntry] Error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +/** + * POST /api/company/:companyId/tasks/:taskId/unclaim — 46 号文档 Phase 3 + * body: { agentId }:只有认领者本人能放弃(`work-graph-db.unclaimSubtask` 的 WHERE 挡着)。 + */ +router.post('/:companyId/tasks/:taskId/unclaim', (req, res) => { + try { + const wdb = require('../services/work-graph-db'); + const task = requireTask(req, res); + if (!task) return; + const { agentId } = req.body || {}; + if (!agentId) return res.status(400).json({ error: 'agentId 必填' }); + + const changed = wdb.unclaimSubtask(task.id, agentId); + if (!changed) { + return res.status(409).json({ + error: `放弃失败:${task.id} 不是被 ${agentId} 认领的可认领子任务`, + }); + } + res.json({ ok: true, task: taskView(wdb.getTask(task.id)) }); + } catch (err) { + console.error('[TaskEntry] Error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +/** * POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3) * * 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列, diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 69a06a0..7cb763f 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -1878,7 +1878,14 @@ function settleNode(runId, nodeRunId, outcome = {}) { // 由 coder-validator 调 /subtask-result 端点逐条写(server/routes/company.js)。 const dRole = def0.nodes.get(nodeRun0.def_node_id); if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) { - const subtasks = getDb().prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ?`).all(run0.task_id); + // 46 号文档 Phase 3:这条闸门只该看 43 号文档的验收条目子任务——`AND subtask_kind = + // 'acceptance_item'` 是必须的收窄,不是可选项。Phase 3 会往同一个 parent_task_id + // 底下物化第二种子任务(claimable_work,等着被认领执行,完成生命周期完全独立、 + // 跟 coder-validator 的验收判定无关),不收窄这条查询会把它们也扫进来,天然不会 + // 是 completed,直接把所有开了 subtask-planning 的 task 验收判定拖垮。 + const subtasks = getDb() + .prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) + .all(run0.task_id); const notDone = subtasks.filter((t) => t.status !== 'completed').map((t) => t.id); if (notDone.length) { to = 'fail'; @@ -2426,7 +2433,11 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, const task = getTask(run.task_id); if (!task || task.acceptance_mode !== 'itemized') return; - const already = getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n; + // 46 号文档 Phase 3:只数 acceptance_item——同一个 task 现在可能同时挂着 subtask-planning + // 物化的 claimable_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。 + const already = getDb() + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) + .get(task.id).n; if (already > 0) return; const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run; @@ -2467,6 +2478,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, description: `判定方式:${row.method || '(未写明)'}\npass 条件:${row.passCondition || '(未写明)'}`, createdBy: 'org-planner', parentTaskId: task.id, + subtaskKind: 'acceptance_item', // 46 号文档 Phase 3:显式打标,见 subtask_kind 列注释 }); } timelineNote({ runId, nodeRunId }, 'node.output', diff --git a/server/services/subtask-planning.js b/server/services/subtask-planning.js index 5bbc98a..857b401 100644 --- a/server/services/subtask-planning.js +++ b/server/services/subtask-planning.js @@ -388,7 +388,12 @@ async function triggerSubtaskPlanning({ run, task }) { nodeRunId: null, }); - console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens)`); + // 46 号文档 Phase 3:把这批 steps 物化成可认领子任务。跟上面写 artifact/登记索引一样, + // 走同一个 try/catch——物化失败不该让 subtask-plan.json 已经写好的部分失效。 + const materializedIds = wdb.materializeClaimableSubtasks({ task, steps }); + + console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),` + + `物化 ${materializedIds.length} 条可认领子任务`); } catch (err) { console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`); } diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index bf2223f..7dcf0ea 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -528,6 +528,7 @@ function getDb() { description TEXT NOT NULL, -- 落进 task-goal.json 的那句;下达后不可改 status TEXT NOT NULL DEFAULT 'draft', -- draft | queued | running | completed | failed | blocked | cancelled + -- | claimable | claimed(46 号文档 Phase 3,只对 subtask_kind='claimable_work' 有意义) -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现: -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释 execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), @@ -559,6 +560,15 @@ function getDb() { -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 —— -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。 parent_task_id TEXT REFERENCES tasks(id), + -- 46 号文档 Phase 3:parent_task_id 非空的子任务分两种,靠这一列区分—— + -- 'acceptance_item' 43 号文档:验收条目物化(coder-validator 逐条上报,见 settleSubtaskResult) + -- 'claimable_work' Phase 3:subtask-planning 步骤分解物化,等着被认领(见 claimSubtask) + -- NULL = 普通 task(不是子任务,或 parent_task_id 也是 NULL)。两套子任务共用同一张表 + -- (而不是各开一张),因为 tasks 表本来就是通用工作项容器,这一列只是给已经混杂的 + -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开 + -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。 + subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','claimable_work') OR subtask_kind IS NULL), + claimed_at TEXT, -- 认领发生的时间;只对 claimable_work 有意义 created_at TEXT NOT NULL, updated_at TEXT NOT NULL, queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) @@ -647,6 +657,16 @@ function getDb() { // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。 db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(company_id, assignee_agent_id, created_at)`); + // ── 批 6:46 号文档 Phase 3,tasks 追加 subtask_kind / claimed_at ──────────── + // 同批 5 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上 subtask_kind 的取值域 + // 只由 createTask 的应用层校验守,与新建库的 CHECK 不同源。 + if (!hasColumn(db, 'tasks', 'subtask_kind')) { + db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`); + } + if (!hasColumn(db, 'tasks', 'claimed_at')) { + db.exec(`ALTER TABLE tasks ADD COLUMN claimed_at TEXT`); + } + // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); @@ -1885,6 +1905,8 @@ function createTask({ createdBy = null, parentTaskId = null, acceptanceMode = 'holistic', + subtaskKind = null, + status = 'draft', } = {}) { if (!goalId) throw new Error('createTask: goalId 必填'); if (!title) throw new Error('createTask: title 必填'); @@ -1895,6 +1917,21 @@ function createTask({ if (parentTaskId && acceptanceMode === 'itemized') { throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分'); } + // 46 号文档 Phase 3:subtask_kind 只对子任务有意义,取值域应用层守(同上 acceptanceMode 那条注释) + if (subtaskKind !== null && !['acceptance_item', 'claimable_work'].includes(subtaskKind)) { + throw new Error(`createTask: subtaskKind 非法: ${subtaskKind}`); + } + if (subtaskKind && !parentTaskId) { + throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义'); + } + // 初始状态只放开 'draft'(现状)和 'claimable'(Phase 3:claimable_work 物化即开放认领, + // 不需要走「先 draft 再手动下达」那一套——这批子任务从来就不打算过 task-scheduler)。 + if (!['draft', 'claimable'].includes(status)) { + throw new Error(`createTask: status 只能是 'draft' 或 'claimable'(初始状态),收到: ${status}`); + } + if (status === 'claimable' && subtaskKind !== 'claimable_work') { + throw new Error(`createTask: status='claimable' 只对 subtaskKind='claimable_work' 有意义`); + } const db = getDb(); const goal = getGoal(goalId); @@ -1926,16 +1963,16 @@ function createTask({ const ins = db.prepare(` INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, execution_mode, depends_on, priority, budget_json, report_to, created_by, - parent_task_id, acceptance_mode, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + parent_task_id, acceptance_mode, subtask_kind, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( - tid, goalId, String(cid), nextSeq, String(title), String(description), + tid, goalId, String(cid), nextSeq, String(title), String(description), status, executionMode, JSON.stringify(deps), priority, budget, // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 // goal 上有就继承下来,别让每个调用方各记一遍。 reportTo || goal.report_to || null, - createdBy, parentTaskId, acceptanceMode, now, now + createdBy, parentTaskId, acceptanceMode, subtaskKind, now, now )); return getTask(id); } @@ -1962,7 +1999,8 @@ function listGoalTasks(goalId, { includeArchived = false } = {}) { * 导致公司失败一次后再也下不了任务,就是这么来的。要位次找调度器要。 */ function listCompanyTasks(companyId, { - status = null, goalId = null, parentTaskId = null, includeArchived = false, limit = null, + status = null, goalId = null, parentTaskId = null, subtaskKind = null, + includeArchived = false, limit = null, } = {}) { const where = ['company_id = @companyId']; const params = { companyId }; @@ -1980,6 +2018,12 @@ function listCompanyTasks(companyId, { where.push('parent_task_id = @parentTaskId'); params.parentTaskId = parentTaskId; } + // 46 号文档 Phase 3:`?subtaskKind=claimable_work&status=claimable` 就是"这家公司当前 + // 可认领池"的查询——不需要专门的池子端点,复用这条通用列表接口加一个过滤参数。 + if (subtaskKind) { + where.push('subtask_kind = @subtaskKind'); + params.subtaskKind = subtaskKind; + } if (!includeArchived) where.push('archived_at IS NULL'); const lim = Number.isInteger(limit) && limit > 0 ? ` LIMIT ${limit}` : ''; return getDb() @@ -2014,7 +2058,10 @@ const TASK_PATCH_COLUMNS = { * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次, * `requeueDerivedBlockedTask` 把它连同 blocked_reason 一起推回 queued。 */ -const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked']; +// 'claimable' 是 46 号文档 Phase 3 加的:claimable_work 子任务物化后要能回填 depends_on +// (steps[].depends_on_hint 转真实 task id,见 materializeClaimableSubtasks),这批子任务 +// 从来不过 task-scheduler,不存在"已经排队、依赖被别处消费过"的顾虑,放开无副作用。 +const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked', 'claimable']; /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; @@ -2141,6 +2188,104 @@ function settleSubtaskResult(taskId, status, summary = null) { } /** + * 46 号文档 Phase 3:认领一条可认领子任务,claimable → claimed。 + * + * 全仓库第一个真正的"认领(pull)"语义写入——`assignee_agent_id` 此前所有写入点 + * (`enqueueTaskRun`/`settleTaskFromRun` 的哨兵值兜底、`agent-chat.saveTaskMeta` 的 + * 派发)都是"派发(push)":发起方单方面指定 `to`,从没有过"多方竞争同一条任务, + * 谁先抢到算谁的"这种场景,所以也没有先例可抄,CAS 写法照抄 `claimQueuedTask`(同一份 + * "changes 数决定谁赢"判据)。 + * + * 认领成功的同时把 `executor` 改成 `'agent'`——claimable_work 子任务生来就是要被某个 + * agent 自主完成的,只是物化时还没有认领对象;一旦认领,它就是一条普通的 + * `executor='agent'` 派单任务,后续走 `META_TO_TASK_STATUS` 那套既有生命周期 + * (`agent-chat.js` 的 `/task/report`),不需要 Phase 3 再发明一套执行触发逻辑。 + * + * @returns {number} 1 = 认领成功;0 = 没抢到(已被认领 / 状态不对 / 不是可认领子任务) + */ +function claimSubtask(taskId, agentId) { + if (!agentId) throw new Error('claimSubtask: agentId 必填'); + const now = new Date().toISOString(); + return getDb() + .prepare(` + UPDATE tasks + SET status = 'claimed', executor = 'agent', assignee_agent_id = @agentId, + claimed_at = @now, updated_at = @now + WHERE id = @id AND subtask_kind = 'claimable_work' AND status = 'claimable' AND archived_at IS NULL + `) + .run({ id: taskId, agentId, now }).changes; +} + +/** + * 放弃认领:claimed → claimable,对称的 CAS。**只有认领者本人能放弃**—— + * `assignee_agent_id = @agentId` 这道 WHERE 挡住"别人代为放弃",避免抢别人手上的活。 + * @returns {number} 1 = 放弃成功;0 = 不是这个 agent 认领的 / 状态不对 + */ +function unclaimSubtask(taskId, agentId) { + if (!agentId) throw new Error('unclaimSubtask: agentId 必填'); + const now = new Date().toISOString(); + return getDb() + .prepare(` + UPDATE tasks + SET status = 'claimable', executor = 'graph', assignee_agent_id = NULL, + claimed_at = NULL, updated_at = @now + WHERE id = @id AND subtask_kind = 'claimable_work' AND status = 'claimed' AND assignee_agent_id = @agentId + `) + .run({ id: taskId, agentId, now }).changes; +} + +/** + * 46 号文档 Phase 3:把 `subtask-planning` 产出的 `steps[]` 物化成可认领子任务。 + * + * 两阶段建法:先把全部行建好拿到真实 task id,再回填 `depends_on`——`steps[].depends_on_hint` + * 是这批 steps 内部的 1-based `seq` 引用,`subtask-planning.js` 的 `normalizeSteps` 只保证 + * 它指向本批次内某个存在的 `seq`,**不保证只能指向更早的 seq**(agent.md 没有强制这条顺序), + * 边建边填会在"前一步依赖后一步"这种前向引用上直接炸(`validateDependsOn` 要求依赖必须已存在)。 + * 先建后填就不用管顺序。 + * + * 幂等:已经物化过 `claimable_work` 子任务的 task 直接跳过,不重复物化(同款判据见 + * `graph-engine.maybeMaterializeItemizedSubtasks` 的 `already` 检查——两处独立各写一份, + * 因为分属两个模块,不值得为一次判空抽个共享工具函数)。 + * + * 找不到某个 `depends_on_hint` 对应的兄弟行(理论上不该发生,防御性处理)就丢那一条引用, + * 不作废整批——延续 Phase 1 定的"宽松解析"哲学。 + * + * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,depends_on_hint?:number[]}>}} ctx + * @returns {string[]} 物化出的 task id,按 steps 顺序;已物化过 / 无 steps 时返回 [] + */ +function materializeClaimableSubtasks({ task, steps }) { + if (!task || !Array.isArray(steps) || !steps.length) return []; + + const already = getDb() + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'claimable_work'`) + .get(task.id).n; + if (already > 0) return []; + + const seqToId = new Map(); + for (const step of steps) { + const row = createTask({ + goalId: task.goal_id, + companyId: task.company_id, + title: String(step.title || '').slice(0, 200), + description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''), + createdBy: 'subtask-planning', + parentTaskId: task.id, + subtaskKind: 'claimable_work', + status: 'claimable', + }); + seqToId.set(step.seq, row.id); + } + + for (const step of steps) { + const hints = Array.isArray(step.depends_on_hint) ? step.depends_on_hint : []; + const deps = hints.map((h) => seqToId.get(h)).filter(Boolean); + if (deps.length) updateTask(seqToId.get(step.seq), { dependsOn: deps }); + } + + return steps.map((s) => seqToId.get(s.seq)); +} + +/** * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 * * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, @@ -2774,6 +2919,10 @@ module.exports = { settleTaskFromRun, settleSubtaskResult, GRAPH_TAKEOVER_ASSIGNEE, + // 46 号文档 Phase 3:任务池认领 + claimSubtask, + unclaimSubtask, + materializeClaimableSubtasks, // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 claimQueuedTask, blockQueuedTask, diff --git a/server/tests/unit/task-decomposition-materialize.test.js b/server/tests/unit/task-decomposition-materialize.test.js index 45048b4..fc2c6bc 100644 --- a/server/tests/unit/task-decomposition-materialize.test.js +++ b/server/tests/unit/task-decomposition-materialize.test.js @@ -152,7 +152,12 @@ describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { expect(children[0].acceptance_mode).toBe('holistic'); expect(children[0].description).toContain('手动操作'); expect(children[0].description).toContain('蛇按方向键移动'); + // 46 号文档 Phase 3:必须打上 subtask_kind='acceptance_item'—— + // 少了它,settleNode 的 SUBTASKS_INCOMPLETE 闸门收窄之后会把这些行当"看不见", + // 验收判定会失效(永远不会因为子任务未完成而拦下 success) + expect(children[0].subtask_kind).toBe('acceptance_item'); expect(children[1].title).toBe('吃到食物加分'); + expect(children[1].subtask_kind).toBe('acceptance_item'); }); it('holistic 任务:org-planner 成功结算,不物化任何子任务', async () => { diff --git a/server/tests/unit/task-decomposition-validator-gate.test.js b/server/tests/unit/task-decomposition-validator-gate.test.js index eaaaef9..40eefe3 100644 --- a/server/tests/unit/task-decomposition-validator-gate.test.js +++ b/server/tests/unit/task-decomposition-validator-gate.test.js @@ -67,7 +67,9 @@ describe('43 号文档 Phase 3', () => { it('settleSubtaskResult:正常上报 + 终态幂等(第二次不同结果上报不生效)', () => { const goal = wdb.createGoal({ companyId: 'co-gate-2', title: 'g', description: 'd' }); const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); - const child = wdb.createTask({ goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id }); + const child = wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', + }); expect(wdb.settleSubtaskResult(child.id, 'completed', '通过')).toBe(1); const after1 = wdb.getTask(child.id); @@ -108,8 +110,12 @@ describe('43 号文档 Phase 3', () => { const companyId = 'co-gate-3'; const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); - const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id }); - wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id }); + const c1 = wdb.createTask({ + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', + }); + wdb.createTask({ + goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', + }); wdb.settleSubtaskResult(c1.id, 'completed'); // 只完成一半 const out = await runValidator(companyId, parent.id); @@ -123,8 +129,12 @@ describe('43 号文档 Phase 3', () => { const companyId = 'co-gate-4'; const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); - const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id }); - const c2 = wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id }); + const c1 = wdb.createTask({ + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', + }); + const c2 = wdb.createTask({ + goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', + }); wdb.settleSubtaskResult(c1.id, 'completed'); wdb.settleSubtaskResult(c2.id, 'completed'); @@ -132,6 +142,26 @@ describe('43 号文档 Phase 3', () => { expect(out.node.status).toBe('success'); }); + it('46 号文档 Phase 3:同一个 task 下还挂着 claimable_work 子任务(未 completed)时,' + + '不影响 acceptance_item 的验收判定——两套子任务完成判定互不干扰', async () => { + const companyId = 'co-gate-6'; + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); + const c1 = wdb.createTask({ + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', + }); + wdb.settleSubtaskResult(c1.id, 'completed'); // 验收条目全部完成 + // claimable_work 子任务还停在 claimable,永远不会 completed(Phase 3 不碰执行)—— + // 如果闸门没收窄,这条会被当成"未完成的子任务"误伤,把上面本该通过的验收打成失败 + wdb.createTask({ + goalId: goal.id, title: 'claim-me', description: 'd', parentTaskId: parent.id, + subtaskKind: 'claimable_work', status: 'claimable', + }); + + const out = await runValidator(companyId, parent.id); + expect(out.node.status).toBe('success'); + }); + it('holistic 任务(没有子任务):闸门不生效,success 照常通过', async () => { const companyId = 'co-gate-5'; const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); diff --git a/server/tests/unit/task-pool-claim.test.js b/server/tests/unit/task-pool-claim.test.js new file mode 100644 index 0000000..e9953d2 --- /dev/null +++ b/server/tests/unit/task-pool-claim.test.js @@ -0,0 +1,340 @@ +/** + * 46 号文档 Phase 3:任务池认领机制。 + * + * 三层各测各的: + * 1. `createTask` 的 subtaskKind/status 校验(纯 DB 层参数校验) + * 2. `materializeClaimableSubtasks`(steps[] → claimable_work 行,含 depends_on 回填) + * 3. `claimSubtask`/`unclaimSubtask` 的 CAS(全仓库第一个"认领"语义的并发保护) + * 4. 路由层 claim/unclaim 端点(角色白名单 + CAS 的 HTTP 翻译) + * + * DB 层沿用 `task-decomposition-validator-gate.test.js` 的隔离手法;路由层沿用 + * `company-goal-task-routes.test.js` 的「真起 express、没有 supertest 用 fetch 打」手法。 + */ +import { + describe, it, expect, beforeAll, afterAll, +} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const TEST_DB_PATH = '/tmp/test-task-pool-claim.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const MODULES = ['../../services/graph-db', '../../services/work-graph-db']; + +describe('46 号文档 Phase 3:DB 层', () => { + let wdb; + + beforeAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + wdb = require('../../services/work-graph-db'); + }); + + afterAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + delete process.env.GRAPH_DB_PATH; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + }); + + // ── createTask 校验 ────────────────────────────────────────────── + + it('subtaskKind 非法值直接拒', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + expect(() => wdb.createTask({ + goalId: goal.id, title: 't', description: 'd', subtaskKind: 'not-a-real-kind', + })).toThrow(/subtaskKind 非法/); + }); + + it('subtaskKind 没有 parentTaskId 时直接拒(只对子任务有意义)', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + expect(() => wdb.createTask({ + goalId: goal.id, title: 't', description: 'd', subtaskKind: 'claimable_work', + })).toThrow(/只对子任务.*有意义/); + }); + + it('status="claimable" 但 subtaskKind 不是 claimable_work 时直接拒', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + expect(() => wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, + subtaskKind: 'acceptance_item', status: 'claimable', + })).toThrow(/status='claimable' 只对/); + }); + + it('status 非 draft/claimable 直接拒', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + expect(() => wdb.createTask({ + goalId: goal.id, title: 't', description: 'd', status: 'running', + })).toThrow(/status 只能是/); + }); + + it('正常创建 claimable_work 子任务:subtask_kind/status 落库正确', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + const child = wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, + subtaskKind: 'claimable_work', status: 'claimable', + }); + expect(child.subtask_kind).toBe('claimable_work'); + expect(child.status).toBe('claimable'); + expect(child.executor).toBe('graph'); // 默认值,认领时才会翻成 'agent' + expect(child.assignee_agent_id).toBeNull(); + }); + + // ── materializeClaimableSubtasks ───────────────────────────────── + + describe('materializeClaimableSubtasks', () => { + function setupTask(companyId) { + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + return wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + } + + it('按 steps 顺序物化,depends_on 正确回填成真实 task id(含前向引用)', () => { + const task = setupTask('co-p3-2'); + const steps = [ + { seq: 1, title: '第一步', depends_on_hint: [3] }, // 前向引用:依赖还没建出来的第三步 + { seq: 2, title: '第二步', depends_on_hint: [1] }, + { seq: 3, title: '第三步', depends_on_hint: [] }, + ]; + const ids = wdb.materializeClaimableSubtasks({ task, steps }); + expect(ids).toHaveLength(3); + + const rows = ids.map((id) => wdb.getTask(id)); + expect(rows.every((r) => r.subtask_kind === 'claimable_work')).toBe(true); + expect(rows.every((r) => r.status === 'claimable')).toBe(true); + expect(rows[0].title).toBe('第一步'); + expect(JSON.parse(rows[0].depends_on)).toEqual([ids[2]]); // 前向引用也正确解析成第三步的真实 id + expect(JSON.parse(rows[1].depends_on)).toEqual([ids[0]]); + expect(JSON.parse(rows[2].depends_on)).toEqual([]); + }); + + it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => { + const task = setupTask('co-p3-3'); + const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99] }]; + const ids = wdb.materializeClaimableSubtasks({ task, steps }); + expect(ids).toHaveLength(1); + expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]); + }); + + it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => { + const task = setupTask('co-p3-4'); + const steps = [{ seq: 1, title: '一步', depends_on_hint: [] }]; + const first = wdb.materializeClaimableSubtasks({ task, steps }); + expect(first).toHaveLength(1); + const second = wdb.materializeClaimableSubtasks({ task, steps }); + expect(second).toEqual([]); + + const rows = wdb.getDb() + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'claimable_work'`) + .get(task.id); + expect(rows.n).toBe(1); + }); + + it('空 steps / 无 task 时返回 [],不报错', () => { + const task = setupTask('co-p3-5'); + expect(wdb.materializeClaimableSubtasks({ task, steps: [] })).toEqual([]); + expect(wdb.materializeClaimableSubtasks({ task: null, steps: [{ seq: 1, title: 'x' }] })).toEqual([]); + }); + + it('与 43 号文档的 acceptance_item 共存互不干扰:各自幂等判据只看自己的 subtask_kind', () => { + const task = setupTask('co-p3-6'); + wdb.createTask({ + goalId: task.goal_id, title: 'a1', description: 'd', parentTaskId: task.id, + subtaskKind: 'acceptance_item', + }); + const ids = wdb.materializeClaimableSubtasks({ + task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }], + }); + expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 + }); + }); + + // ── claimSubtask / unclaimSubtask CAS ──────────────────────────── + + describe('claimSubtask / unclaimSubtask', () => { + function makeClaimable(companyId) { + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + return wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, + subtaskKind: 'claimable_work', status: 'claimable', + }); + } + + it('认领成功:status/executor/assignee/claimed_at 全部正确落库', () => { + const t = makeClaimable('co-p3-7'); + expect(wdb.claimSubtask(t.id, 'org-coder')).toBe(1); + const after = wdb.getTask(t.id); + expect(after.status).toBe('claimed'); + expect(after.executor).toBe('agent'); + expect(after.assignee_agent_id).toBe('org-coder'); + expect(after.claimed_at).toBeTruthy(); + }); + + it('并发认领同一条:只有一方成功(changes CAS)', () => { + const t = makeClaimable('co-p3-8'); + const r1 = wdb.claimSubtask(t.id, 'org-coder'); + const r2 = wdb.claimSubtask(t.id, 'org-coder'); // 模拟第二次竞争(同一行为不同调用) + expect([r1, r2].sort()).toEqual([0, 1]); // 一个 1 一个 0,不是两个都 1 + }); + + it('非 claimable 状态(比如已经是 claimed)认领失败,changes=0', () => { + const t = makeClaimable('co-p3-9'); + wdb.claimSubtask(t.id, 'org-coder'); + expect(wdb.claimSubtask(t.id, 'someone-else')).toBe(0); + expect(wdb.getTask(t.id).assignee_agent_id).toBe('org-coder'); // 没被顶掉 + }); + + it('不是 claimable_work 的行认领失败', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-10', title: 'g', description: 'd' }); + const t = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); // 普通 task + expect(wdb.claimSubtask(t.id, 'org-coder')).toBe(0); + }); + + it('放弃认领:claimed → claimable,executor 退回 graph,assignee 清空', () => { + const t = makeClaimable('co-p3-11'); + wdb.claimSubtask(t.id, 'org-coder'); + expect(wdb.unclaimSubtask(t.id, 'org-coder')).toBe(1); + const after = wdb.getTask(t.id); + expect(after.status).toBe('claimable'); + expect(after.executor).toBe('graph'); + expect(after.assignee_agent_id).toBeNull(); + expect(after.claimed_at).toBeNull(); + }); + + it('放弃认领:不是认领者本人放弃失败', () => { + const t = makeClaimable('co-p3-12'); + wdb.claimSubtask(t.id, 'org-coder'); + expect(wdb.unclaimSubtask(t.id, 'someone-else')).toBe(0); + expect(wdb.getTask(t.id).status).toBe('claimed'); // 没被动 + }); + }); +}); + +describe('46 号文档 Phase 3:路由层 claim/unclaim', () => { + const CID = 'route-p3-co'; + let wdb; + let server; + let base; + let origCompanyGet; + + function hireInto(companyId, agentId, roleName) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + db.prepare(`INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) + .run(agentId, agentId, `system/agents/${agentId}`, now, now); + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) + VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, roleName, now); + } + + async function call(method, url, body) { + const r = await fetch(base + url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return [r.status, await r.json()]; + } + + beforeAll(async () => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3-claim-routes-')); + for (const m of [...MODULES, '../../routes/company']) { + try { delete require.cache[require.resolve(m)]; } catch {} + } + wdb = require('../../services/work-graph-db'); + + const companyService = require('../../services/company-service'); + origCompanyGet = companyService.CompanyService.prototype.get; + companyService.CompanyService.prototype.get = (companyId) => + (companyId === CID ? { id: CID, name: CID, status: 'running' } : null); + + hireInto(CID, 'org-coder', 'org-coder'); + hireInto(CID, 'ceo', 'ceo'); // 用来验证"雇了但不在白名单"这条 403 分支 + + const express = require('express'); + const app = express(); + app.use(express.json()); + app.use('/api/company', require('../../routes/company')); + await new Promise((resolve) => { server = app.listen(0, resolve); }); + base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`; + }); + + afterAll(() => { + if (server) server.close(); + const companyService = require('../../services/company-service'); + if (origCompanyGet) companyService.CompanyService.prototype.get = origCompanyGet; + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_DB_PATH; + delete process.env.GRAPH_RUNS_DIR; + for (const m of [...MODULES, '../../routes/company']) { + try { delete require.cache[require.resolve(m)]; } catch {} + } + }); + + function makeClaimable() { + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + return wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, + subtaskKind: 'claimable_work', status: 'claimable', + }); + } + + it('白名单角色认领成功', async () => { + const t = makeClaimable(); + const [status, body] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); + expect(status).toBe(200); + expect(body.task.status).toBe('claimed'); + expect(body.task.assigneeAgentId).toBe('org-coder'); + expect(body.task.subtaskKind).toBe('claimable_work'); + }); + + it('非白名单角色认领:403', async () => { + const t = makeClaimable(); + const [status, body] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'ceo' }); + expect(status).toBe(403); + expect(body.error).toMatch(/白名单/); + }); + + it('缺 agentId:400', async () => { + const t = makeClaimable(); + const [status] = await call('POST', `/tasks/${t.id}/claim`, {}); + expect(status).toBe(400); + }); + + it('不是可认领子任务(普通 task):400', async () => { + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); + const t = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const [status] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); + expect(status).toBe(400); + }); + + it('已被认领的再认领:409', async () => { + const t = makeClaimable(); + await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); + const [status] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); + expect(status).toBe(409); + }); + + it('认领后放弃:状态正确回退', async () => { + const t = makeClaimable(); + await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); + const [status, body] = await call('POST', `/tasks/${t.id}/unclaim`, { agentId: 'org-coder' }); + expect(status).toBe(200); + expect(body.task.status).toBe('claimable'); + expect(body.task.assigneeAgentId).toBeNull(); + }); + + it('不是认领者本人放弃:409', async () => { + const t = makeClaimable(); + await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' }); + const [status] = await call('POST', `/tasks/${t.id}/unclaim`, { agentId: 'ceo' }); + expect(status).toBe(409); + }); +}); -- libgit2 0.21.4