diff --git a/server/routes/company.js b/server/routes/company.js index 47fc274..3dfe5cf 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1026,9 +1026,8 @@ 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 + // 46 号文档 Phase 3:两种子任务靠这个区分——'acceptance_item' | 'decomposed_work' | null subtaskKind: t.subtask_kind || null, - claimedAt: t.claimed_at || null, createdAt: t.created_at, queuedAt: t.queued_at, startedAt: t.started_at, @@ -1112,9 +1111,20 @@ function isSlotRace(err) { function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = false }) { const wdb = require('../services/work-graph-db'); + // 46 号文档 Phase 3(v2):decomposed_work 子任务不走公司完整 org graph(那是给顶层 task + // 走 ceo→cto→org-planner→org-coder→org-validator 整套规划仪式的),现造一张最小的 + // impl→validate work graph——`subtask-planning` 已经把"怎么做"拆到这一步的粒度了, + // 再走一遍 org-planner 的 brd/prd/sdd 纯属重复劳动。见 `createDecomposedWorkGraph` 的注释, + // 含一条没有真实验证过的假设(org-coder/org-validator 裸调用会不会读不到预期上下文)。 + const isDecomposedSubtask = taskRow.subtask_kind === 'decomposed_work'; + const graphKind = isDecomposedSubtask ? 'work' : 'org'; + const graphId = isDecomposedSubtask + ? wdb.createDecomposedWorkGraph({ companyId, task: taskRow }) + : companyId; // kind='org' 时 graph_id 就是 company_id + const created = wdb.createRun({ - graphKind: 'org', - graphId: companyId, // kind='org' 时 graph_id 就是 company_id + graphKind, + graphId, companyId, goalId: taskRow.goal_id, taskId: taskRow.id, @@ -2112,7 +2122,7 @@ 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` = 当前可认领池 + // 46 号文档 Phase 3:`?subtaskKind=decomposed_work&status=draft` = CEO 心跳巡检对象 subtaskKind: req.query.subtaskKind || req.query.subtask_kind || null, includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', limit: Number.isInteger(limit) ? limit : null, @@ -2391,81 +2401,6 @@ 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 7cb763f..14ca49b 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -1880,9 +1880,10 @@ function settleNode(runId, nodeRunId, outcome = {}) { if (dRole && dRole.systemAgentId === 'coder-validator' && 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 验收判定拖垮。 + // 底下物化第二种子任务(decomposed_work,subtask-planning 拆出来等 CEO 派发执行的, + // 完成生命周期完全独立、跟 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); @@ -2434,7 +2435,7 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, if (!task || task.acceptance_mode !== 'itemized') return; // 46 号文档 Phase 3:只数 acceptance_item——同一个 task 现在可能同时挂着 subtask-planning - // 物化的 claimable_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。 + // 物化的 decomposed_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。 const already = getDb() .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) .get(task.id).n; diff --git a/server/services/subtask-planning.js b/server/services/subtask-planning.js index 857b401..c2cf507 100644 --- a/server/services/subtask-planning.js +++ b/server/services/subtask-planning.js @@ -349,6 +349,14 @@ function normalizeSteps(raw) { async function triggerSubtaskPlanning({ run, task }) { try { if (!run || !task) return; + // 46 号文档 Phase 3:子任务不该被再拆一层——`materializeDecomposedSubtasks` 物化出的行 + // 也会经过 enqueueTaskRun(CEO 派发时),如果不挡住,会对一条已经是"第几步怎么做"粒度的 + // 子任务再跑一次 subtask-planning,浪费一次 LLM 调用还可能无限递归拆下去。 + // 43 号文档的验收条目子任务同理(createTask 本身已经不许它们再声明 itemized)。 + if (task.parent_task_id) { + console.log(`[SubtaskPlanning] task ${task.id} 本身就是子任务(parent_task_id=${task.parent_task_id}),跳过`); + return; + } const { steps, tokens, reasoningSource, experienceRefs, researchUsed, } = await planSubtasks({ companyId: run.company_id, task }); @@ -388,12 +396,12 @@ async function triggerSubtaskPlanning({ run, task }) { nodeRunId: null, }); - // 46 号文档 Phase 3:把这批 steps 物化成可认领子任务。跟上面写 artifact/登记索引一样, - // 走同一个 try/catch——物化失败不该让 subtask-plan.json 已经写好的部分失效。 - const materializedIds = wdb.materializeClaimableSubtasks({ task, steps }); + // 46 号文档 Phase 3:把这批 steps 物化成 decomposed_work 子任务。跟上面写 artifact/登记 + // 索引一样,走同一个 try/catch——物化失败不该让 subtask-plan.json 已经写好的部分失效。 + const materializedIds = wdb.materializeDecomposedSubtasks({ task, steps }); console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),` - + `物化 ${materializedIds.length} 条可认领子任务`); + + `物化 ${materializedIds.length} 条待 CEO 派发的子任务`); } 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 7dcf0ea..c3e2b2d 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -528,7 +528,6 @@ 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')), @@ -562,13 +561,14 @@ function getDb() { 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) + -- 'decomposed_work' Phase 3:subtask-planning 步骤分解物化,等 CEO 心跳判断节奏后 enqueue + -- (v1 设计过"认领"pull 语义,真实回归后改成 CEO 直接派发的 push 语义—— + -- 认领需要的并发抢占场景不存在,CEO 一人拍板成本更低,历史决策见 46 号文档) -- 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 有意义 + subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','decomposed_work') OR subtask_kind IS NULL), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) @@ -657,15 +657,12 @@ 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 ──────────── + // ── 批 6:46 号文档 Phase 3,tasks 追加 subtask_kind ───────────────────── // 同批 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')) { @@ -1906,7 +1903,6 @@ function createTask({ parentTaskId = null, acceptanceMode = 'holistic', subtaskKind = null, - status = 'draft', } = {}) { if (!goalId) throw new Error('createTask: goalId 必填'); if (!title) throw new Error('createTask: title 必填'); @@ -1917,21 +1913,15 @@ 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)) { + // 46 号文档 Phase 3:subtask_kind 只对子任务有意义,取值域应用层守(同上 acceptanceMode 那条注释)。 + // 没有专属初始 status——decomposed_work 子任务就是普通 draft task,物化即走 + // draft→(CEO 判断节奏后)enqueue 这条现成的路,不需要额外的状态。 + if (subtaskKind !== null && !['acceptance_item', 'decomposed_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); @@ -1964,10 +1954,10 @@ function createTask({ 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, subtask_kind, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( - tid, goalId, String(cid), nextSeq, String(title), String(description), status, + tid, goalId, String(cid), nextSeq, String(title), String(description), executionMode, JSON.stringify(deps), priority, budget, // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 // goal 上有就继承下来,别让每个调用方各记一遍。 @@ -2018,8 +2008,8 @@ function listCompanyTasks(companyId, { where.push('parent_task_id = @parentTaskId'); params.parentTaskId = parentTaskId; } - // 46 号文档 Phase 3:`?subtaskKind=claimable_work&status=claimable` 就是"这家公司当前 - // 可认领池"的查询——不需要专门的池子端点,复用这条通用列表接口加一个过滤参数。 + // 46 号文档 Phase 3:`?subtaskKind=decomposed_work&status=draft` 就是 CEO 心跳巡检 + // "有哪些子任务等我判断节奏"的查询——不需要专门的端点,复用这条通用列表接口加一个过滤参数。 if (subtaskKind) { where.push('subtask_kind = @subtaskKind'); params.subtaskKind = subtaskKind; @@ -2058,10 +2048,7 @@ const TASK_PATCH_COLUMNS = { * `blocked` 在里面是「强制放行」那条自救路径的**全部依赖**:改完依赖 route 会立刻 tick 一次, * `requeueDerivedBlockedTask` 把它连同 blocked_reason 一起推回 queued。 */ -// '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']; +const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked']; /** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; @@ -2188,54 +2175,12 @@ 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[]` 物化成可认领子任务。 + * 46 号文档 Phase 3(v2,2026-09-02 改版):把 `subtask-planning` 产出的 `steps[]` 物化成 + * decomposed_work 子任务。**v1 曾经设计成"认领"(pull,`claimable`/`claimed` 两个专属状态 + + * `claimSubtask`/`unclaimSubtask` 两个 CAS 函数),真实回归后拍板改掉**:认领要解决的是 + * "多方竞争同一条任务",但实际执行者只有 CEO 一个人拍板要不要派、派给谁,压根不存在竞争, + * CAS 是给不存在的问题找的解——现在直接砍掉,物化出的子任务就是普通 `draft` task, + * CEO 心跳判断节奏后调现成的 `/tasks/:taskId/enqueue`(旧 API,不用再发明新端点)。 * * 两阶段建法:先把全部行建好拿到真实 task id,再回填 `depends_on`——`steps[].depends_on_hint` * 是这批 steps 内部的 1-based `seq` 引用,`subtask-planning.js` 的 `normalizeSteps` 只保证 @@ -2243,7 +2188,10 @@ function unclaimSubtask(taskId, agentId) { * 边建边填会在"前一步依赖后一步"这种前向引用上直接炸(`validateDependsOn` 要求依赖必须已存在)。 * 先建后填就不用管顺序。 * - * 幂等:已经物化过 `claimable_work` 子任务的 task 直接跳过,不重复物化(同款判据见 + * `executionMode: 'auto'`——CEO 一旦决定 enqueue 就该一路跑完,不需要停在入口等人工 `/next` + * (那是 `manual` 模式的语义,给"人下达"这个场景设计的,不适合"CEO 已经拍过板"这条路)。 + * + * 幂等:已经物化过 `decomposed_work` 子任务的 task 直接跳过,不重复物化(同款判据见 * `graph-engine.maybeMaterializeItemizedSubtasks` 的 `already` 检查——两处独立各写一份, * 因为分属两个模块,不值得为一次判空抽个共享工具函数)。 * @@ -2253,11 +2201,11 @@ function unclaimSubtask(taskId, agentId) { * @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 }) { +function materializeDecomposedSubtasks({ 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'`) + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) .get(task.id).n; if (already > 0) return []; @@ -2268,10 +2216,10 @@ function materializeClaimableSubtasks({ task, steps }) { companyId: task.company_id, title: String(step.title || '').slice(0, 200), description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''), + executionMode: 'auto', createdBy: 'subtask-planning', parentTaskId: task.id, - subtaskKind: 'claimable_work', - status: 'claimable', + subtaskKind: 'decomposed_work', }); seqToId.set(step.seq, row.id); } @@ -2286,6 +2234,47 @@ function materializeClaimableSubtasks({ task, steps }) { } /** + * 46 号文档 Phase 3(v2):给一条 `decomposed_work` 子任务现造一张最小的 work graph—— + * `实现(org-coder)→ 验证(org-validator)`两个节点,`agent_call`(不是 `subgraph`), + * 跳过公司完整 org graph 那套 ceo→cto→org-planner→org-coder→org-validator 的规划仪式。 + * + * 为什么跳得掉:`subtask-planning` 已经把"怎么做"拆到了这一步的粒度,`org-planner` 平时干的 + * brd/prd/sdd 那一套是在回答"这件事怎么做"——对一条已经具体到"实现 add 子命令"的子任务, + * 再走一遍纯属重复劳动,Phase 0 真实测过一条完整 task 走完整 org graph 近 19 分钟, + * 子任务量级不该背这个成本。 + * + * 没有声明任何 `needs`/`produces` 边契约(`declareEdgeContract` 一次都没调)——`agent_call` + * 节点没有声明 needs 就不会触发 `requiredInputsMissing`,没有声明 produces 就不会触发 + * `unsatisfiedRequiredProduces`,这两道闸门天然对这张图是空的,不用额外放行。 + * + * ⚠️ **这是一次没有真实验证过的假设,写在这里免得下一个人以为它是钉死的设计**: + * `org-coder`/`org-validator` 平时只在"公司完整 org graph 的 subgraph 节点"这个上下文里 + * 被调用过,它们的 `agent.md`/`tools.md` 有没有隐含"前面一定有 brd/prd/sdd 产物"这种假设, + * 直接当 `agent_call` 裸调用会不会读不到预期上下文——没有实测过,需要真实回归验证。 + * 用户的决定是"先跑轻量版看效果,不行再上完整 org graph"(46 号文档),这就是那次实验。 + * + * @param {{companyId: string, task: object}} ctx task 是那条 decomposed_work 子任务本身 + * @returns {string} 新建的 work graph id + */ +function createDecomposedWorkGraph({ companyId, task }) { + const g = createWorkGraph({ + companyId, + plannedBy: 'subtask-dispatch', + goalId: task.goal_id, + taskId: task.id, + nodes: [ + { nodeName: 'impl', systemAgentId: 'org-coder', nodeKind: 'agent_call' }, + { nodeName: 'validate', systemAgentId: 'org-validator', nodeKind: 'agent_call' }, + ], + edges: [{ from: 'impl', to: 'validate', condition: 'success' }], + }); + if (!g.ok) { + throw new Error(`createDecomposedWorkGraph: 建图失败: ${JSON.stringify(g.errors)}`); + } + return g.id; +} + +/** * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 * * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, @@ -2919,10 +2908,9 @@ module.exports = { settleTaskFromRun, settleSubtaskResult, GRAPH_TAKEOVER_ASSIGNEE, - // 46 号文档 Phase 3:任务池认领 - claimSubtask, - unclaimSubtask, - materializeClaimableSubtasks, + // 46 号文档 Phase 3(v2):CEO 派发 decomposed_work 子任务 + materializeDecomposedSubtasks, + createDecomposedWorkGraph, // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 claimQueuedTask, blockQueuedTask, diff --git a/server/tests/unit/task-decomposition-dispatch.test.js b/server/tests/unit/task-decomposition-dispatch.test.js new file mode 100644 index 0000000..d666918 --- /dev/null +++ b/server/tests/unit/task-decomposition-dispatch.test.js @@ -0,0 +1,285 @@ +/** + * 46 号文档 Phase 3(v2,2026-09-02 改版):CEO 派发 decomposed_work 子任务。 + * + * v1 做的是"认领"(pull,CAS 抢占),真实回归后拍板改成"CEO 直接派发"(push,不存在 + * 多方竞争,CAS 是在给不存在的问题找解)。这个文件替掉已删除的 task-pool-claim.test.js, + * 覆盖新设计的三层: + * 1. `materializeDecomposedSubtasks`(物化,depends_on 回填,幂等) + * 2. `createDecomposedWorkGraph`(现造的轻量 impl→validate 图,形状对不对) + * 3. 路由层 `enqueueTaskRun`——decomposed_work 走轻量 work graph,普通 task 走公司 org + * graph 不受影响(回归),以及 `triggerSubtaskPlanning` 的递归防护(子任务不会被再拆一层) + */ +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-decomp-dispatch.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const MODULES = ['../../services/graph-db', '../../services/work-graph-db']; + +function ensureAgent(wdb, id) { + 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(id, id, `system/agents/${id}`, now, now); +} + +describe('46 号文档 Phase 3(v2):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'); + ensureAgent(wdb, 'org-coder'); + ensureAgent(wdb, 'org-validator'); + }); + + 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 校验(简化后:没有专属初始 status 了)───────────────── + + 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: 'decomposed_work', + })).toThrow(/只对子任务.*有意义/); + }); + + it('正常创建 decomposed_work 子任务:就是一条普通 draft task,只多了 subtask_kind 标签', () => { + 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: 'decomposed_work', + }); + expect(child.subtask_kind).toBe('decomposed_work'); + expect(child.status).toBe('draft'); // 没有专属状态了 + expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑 + }); + + // ── materializeDecomposedSubtasks ───────────────────────────────── + + describe('materializeDecomposedSubtasks', () => { + function setupTask(companyId) { + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + return wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + } + + it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => { + 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.materializeDecomposedSubtasks({ task, steps }); + expect(ids).toHaveLength(3); + + const rows = ids.map((id) => wdb.getTask(id)); + expect(rows.every((r) => r.subtask_kind === 'decomposed_work')).toBe(true); + expect(rows.every((r) => r.status === 'draft')).toBe(true); + expect(rows.every((r) => r.execution_mode === 'auto')).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.materializeDecomposedSubtasks({ 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.materializeDecomposedSubtasks({ task, steps }); + expect(first).toHaveLength(1); + const second = wdb.materializeDecomposedSubtasks({ task, steps }); + expect(second).toEqual([]); + + const rows = wdb.getDb() + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) + .get(task.id); + expect(rows.n).toBe(1); + }); + + it('空 steps / 无 task 时返回 [],不报错', () => { + const task = setupTask('co-p3-5'); + expect(wdb.materializeDecomposedSubtasks({ task, steps: [] })).toEqual([]); + expect(wdb.materializeDecomposedSubtasks({ 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.materializeDecomposedSubtasks({ + task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }], + }); + expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 + }); + }); + + // ── createDecomposedWorkGraph ────────────────────────────────────── + + describe('createDecomposedWorkGraph', () => { + it('建出一张两节点的轻量图:impl(org-coder) → validate(org-validator),都是 agent_call', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-7', title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-7', task }); + expect(graphId).toBeTruthy(); + + const nodes = wdb.getDb() + .prepare(`SELECT node_name, system_agent_id, node_kind FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`) + .all(graphId); + expect(nodes).toEqual([ + { node_name: 'impl', system_agent_id: 'org-coder', node_kind: 'agent_call' }, + { node_name: 'validate', system_agent_id: 'org-validator', node_kind: 'agent_call' }, + ]); + + const wg = wdb.getDb().prepare(`SELECT task_id, goal_id FROM work_graphs WHERE id = ?`).get(graphId); + expect(wg.task_id).toBe(task.id); + expect(wg.goal_id).toBe(goal.id); + }); + + it('这张图能被 createRun 直接拿去起一条 work run(机制打通,不真的执行)', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-8', title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-8', task }); + const run = wdb.createRun({ + graphKind: 'work', graphId, companyId: 'co-p3-8', goalId: goal.id, taskId: task.id, + }); + expect(run.ok).not.toBe(false); + expect(wdb.getRun(run.id).graph_kind).toBe('work'); + expect(wdb.getRun(run.id).graph_id).toBe(graphId); + }); + }); +}); + +describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', () => { + const CID = 'route-p3v2-co'; + let wdb; + let org; + let server; + let base; + let origCompanyGet; + + 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(), 'p3v2-dispatch-routes-')); + const allModules = [ + ...MODULES, '../../services/graph-service', '../../services/org-graph-service', + '../../services/subtask-planning', '../../routes/company', + ]; + for (const m of allModules) { + try { delete require.cache[require.resolve(m)]; } catch {} + } + wdb = require('../../services/work-graph-db'); + org = require('../../services/org-graph-service'); + ensureAgent(wdb, 'org-planner'); + ensureAgent(wdb, 'org-coder'); + ensureAgent(wdb, 'org-validator'); + + 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); + + // 起 /enqueue 前置门禁要求公司有 org graph——最小起一张(org-planner→org-coder→org-validator) + expect(org.writeGraph(CID, { + nodes: ['org-planner', 'org-coder', 'org-validator'], + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }, + { from: 'org-coder', to: 'org-validator', condition: 'success' }], + }).ok).toBe(true); + + // subtask-planning 的真实调用不走这个测试(VITEST 环境下 planSubtasks 自动短路,见该模块), + // 不用额外注入桩。 + + 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; + const allModules = [ + ...MODULES, '../../services/graph-service', '../../services/org-graph-service', + '../../services/subtask-planning', '../../routes/company', + ]; + for (const m of allModules) { + try { delete require.cache[require.resolve(m)]; } catch {} + } + }); + + it('普通顶层 task:enqueue 后走公司 org graph(graph_kind=org, graph_id=companyId)——回归,行为不变', async () => { + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + + const [status, body] = await call('POST', `/tasks/${task.id}/enqueue`); + expect(status).toBe(201); + const run = wdb.getRun(body.runId); + expect(run.graph_kind).toBe('org'); + expect(run.graph_id).toBe(CID); + }); + + it('decomposed_work 子任务:enqueue 后走轻量 work graph(graph_kind=work,graph_id 不是 companyId)', async () => { + const goal = wdb.createGoal({ companyId: CID, 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: 'decomposed_work', + }); + + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); + expect(status).toBe(201); + const run = wdb.getRun(body.runId); + expect(run.graph_kind).toBe('work'); + expect(run.graph_id).not.toBe(CID); + + const nodes = wdb.getDb() + .prepare(`SELECT node_name, system_agent_id FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`) + .all(run.graph_id); + expect(nodes.map((n) => n.node_name)).toEqual(['impl', 'validate']); + }); +}); diff --git a/server/tests/unit/task-decomposition-validator-gate.test.js b/server/tests/unit/task-decomposition-validator-gate.test.js index 40eefe3..6e47405 100644 --- a/server/tests/unit/task-decomposition-validator-gate.test.js +++ b/server/tests/unit/task-decomposition-validator-gate.test.js @@ -142,7 +142,7 @@ describe('43 号文档 Phase 3', () => { expect(out.node.status).toBe('success'); }); - it('46 号文档 Phase 3:同一个 task 下还挂着 claimable_work 子任务(未 completed)时,' + it('46 号文档 Phase 3:同一个 task 下还挂着 decomposed_work 子任务(未 completed)时,' + '不影响 acceptance_item 的验收判定——两套子任务完成判定互不干扰', async () => { const companyId = 'co-gate-6'; const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); @@ -151,11 +151,11 @@ describe('43 号文档 Phase 3', () => { goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', }); wdb.settleSubtaskResult(c1.id, 'completed'); // 验收条目全部完成 - // claimable_work 子任务还停在 claimable,永远不会 completed(Phase 3 不碰执行)—— + // decomposed_work 子任务还停在 draft,还没被 CEO enqueue、更不会 completed—— // 如果闸门没收窄,这条会被当成"未完成的子任务"误伤,把上面本该通过的验收打成失败 wdb.createTask({ - goalId: goal.id, title: 'claim-me', description: 'd', parentTaskId: parent.id, - subtaskKind: 'claimable_work', status: 'claimable', + goalId: goal.id, title: 'todo-step', description: 'd', parentTaskId: parent.id, + subtaskKind: 'decomposed_work', }); const out = await runValidator(companyId, parent.id); diff --git a/server/tests/unit/task-pool-claim.test.js b/server/tests/unit/task-pool-claim.test.js deleted file mode 100644 index e9953d2..0000000 --- a/server/tests/unit/task-pool-claim.test.js +++ /dev/null @@ -1,340 +0,0 @@ -/** - * 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); - }); -}); diff --git a/system/agents/ceo/heartbeat.md b/system/agents/ceo/heartbeat.md index 8c7c83a..8606a84 100644 --- a/system/agents/ceo/heartbeat.md +++ b/system/agents/ceo/heartbeat.md @@ -7,8 +7,23 @@ - [ ] 查 `GET /tasks?status=blocked` 和 `?status=failed`,看有没有需要处理的 - [ ] 查 `GET /goals?status=active`,扫一眼有没有明显该调整优先级的 +- [ ] 查 `GET /tasks?subtaskKind=decomposed_work&status=draft`,看有没有等你派发的子任务 + (46 号文档 Phase 3:`subtask-planning` 拆出来的步骤,落库就是普通 draft task, + 不会自己跑起来,要你判断"现在放不放") - [ ] 都正常就直接回复"心跳正常",不要为了"用一下工具"而用工具 +## 子任务怎么派(decomposed_work) + +- 看到的每一条都已经是具体到"这一步做什么"的粒度(`subtask-planning` 已经拆过了), + 不需要你再判断"这条该不该做"——**只需要判断节奏**:现在放,还是等前置步骤先跑完再放。 +- 想知道前置步骤是谁:任务详情里的 `dependsOn` 数组。不用你自己排队——`depends_on` + 没完成的,`enqueue` 了也会被调度器自动拦成 `blocked`,一次性把一条 task 下所有 + `draft` 的子任务全部 `enqueue` 是安全的,先后顺序、并发几个都是调度器的活,不是你的。 +- 派一条:`POST /tasks//enqueue`(跟你派自己提的草稿任务是同一个端点,同一条权限)。 +- 这批子任务会走一张**轻量图**(只有实现+验证两步,不是公司完整的组织架构那套), + 成本比一条顶层 task 低很多,但目前还没有正式跑过真实验证——如果发现同一个 task 下的 + 子任务频繁失败或者结果质量不行,在回复里如实说,不要自己想办法绕过去。 + ## 发现问题时 - 具体判断依据见 `tools.md` 的"什么时候该提新任务、什么时候该改优先级"一节 diff --git a/system/agents/ceo/tools.md b/system/agents/ceo/tools.md index 0ed2d14..4895816 100644 --- a/system/agents/ceo/tools.md +++ b/system/agents/ceo/tools.md @@ -45,6 +45,14 @@ curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company//goals/ -H "x-internal-token: $LEMON_INTERNAL_TOKEN" ``` +### 找等你派发的子任务(46 号文档 Phase 3) +```bash +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company//tasks?subtaskKind=decomposed_work&status=draft" \ + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" +``` +每条都带 `parentTaskId`(它是哪条 task 拆出来的)和 `dependsOn`(同批里它依赖哪几条, +数组里是别的子任务的 taskId,不是要你手动排序——只是给你看清楚"这条要等谁先完成")。 + --- ## 写:只在真正判断需要时才用 @@ -76,6 +84,10 @@ curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/