From 81e28e56bffeaa20dd2b58ce48f729b2851078eb Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 31 Aug 2026 11:01:26 +0800 Subject: [PATCH] feat(task-decomposition): goal→task 支持子任务(43 号文档 Phase 0-3) --- bin/lemon.js | 19 +++++++++++++++++++ server/routes/company.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ server/services/graph-engine.js | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/services/task-planning.js | 38 +++++++++++++++++++++++++++++++------- server/services/work-graph-db.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- server/tests/unit/company-goal-task-routes.test.js | 6 +++++- server/tests/unit/graph-engine-fail-reason.test.js | 5 ++++- server/tests/unit/task-acceptance-mode.test.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/task-decomposition-materialize.test.js | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/task-decomposition-validator-gate.test.js | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/agents/coder-validator/agent.md | 27 +++++++++++++++++++++++++++ system/agents/task-planning/agent.md | 34 ++++++++++++++++++++++++++++++---- 12 files changed, 730 insertions(+), 17 deletions(-) create mode 100644 server/tests/unit/task-acceptance-mode.test.js create mode 100644 server/tests/unit/task-decomposition-materialize.test.js create mode 100644 server/tests/unit/task-decomposition-validator-gate.test.js diff --git a/bin/lemon.js b/bin/lemon.js index e8c78cf..64c850d 100755 --- a/bin/lemon.js +++ b/bin/lemon.js @@ -195,6 +195,25 @@ async function run() { }); } catch { /* best-effort,静默丢弃 */ } } + // 43 号文档 Phase 3:itemized 任务列出它物化出的子任务(coder-validator 逐条验收前先拿列表) + else if (command === 'task subtasks') { + const { values } = parseArgs({ args: args.slice(2), options: { + companyId: { type: 'string' }, taskId: { type: 'string' } + }}); + const res = await request('GET', `/company/${values.companyId}/tasks?parentTaskId=${encodeURIComponent(values.taskId)}`); + console.log(JSON.stringify(res.data, null, 2)); + } + // 43 号文档 Phase 3:逐条上报一个子任务的验收结果(每判完一条验收项调一次) + else if (command === 'task subtask-result') { + const { values } = parseArgs({ args: args.slice(2), options: { + companyId: { type: 'string' }, taskId: { type: 'string' }, + status: { type: 'string' }, summary: { type: 'string', default: '' } + }}); + const res = await request('POST', `/company/${values.companyId}/tasks/${values.taskId}/subtask-result`, { + status: values.status, summary: values.summary + }); + console.log(JSON.stringify(res.data, null, 2)); + } else if (command === 'pipeline create') { const { values } = parseArgs({ args: args.slice(2), options: { companyId: { type: 'string' }, file: { type: 'string' } diff --git a/server/routes/company.js b/server/routes/company.js index 0b31f88..36cdab9 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1023,6 +1023,9 @@ function taskView(t, progress = null, positions = null) { blockedReason: t.blocked_reason, reportTo: t.report_to, assigneeAgentId: t.assignee_agent_id || null, + // 43 号文档:子任务树的父子指针 + 这条 task 走哪种验收模式(看板据此决定要不要画子任务) + parentTaskId: t.parent_task_id || null, + acceptanceMode: t.acceptance_mode || 'holistic', createdAt: t.created_at, queuedAt: t.queued_at, startedAt: t.started_at, @@ -1409,10 +1412,19 @@ function buildTaskPlans(specs, { priority, budget, mode, seqBase = 0 } = {}) { if (rawDeps !== undefined && rawDeps !== null && !Array.isArray(rawDeps)) { return { error: `tasks[${i}].depends_on 必须是数组` }; } + // 43 号文档:跟 mode/priority/budget 不同——这个字段**透传**、不继承 goal 级默认值。 + // 要不要按验收条目拆子任务是语义判断,task-planning 已经在 specs 里判过一次; + // 没给就是没判断要拆,缺省 holistic(保守),不是「服务端注入默认值」那类字段。 + const rawAMode = s.acceptance_mode !== undefined ? s.acceptance_mode : s.acceptanceMode; + const acceptanceMode = rawAMode == null ? 'holistic' : rawAMode; + if (!['holistic', 'itemized'].includes(acceptanceMode)) { + return { error: `tasks[${i}].acceptance_mode 只能是 'holistic' 或 'itemized',收到: ${JSON.stringify(rawAMode)}` }; + } plans.push({ seq: base + i + 1, // seq 显式给,别靠 MAX+1 —— depends_on 的 seq 引用要能对得上服务端分配的号 title: String(s.title || '').trim() || deriveTitle(d), description: d, + acceptanceMode, // 优先级:task 自己的 mode > goal 级 mode(第三个候选)> 'manual'。 // 「继承 goal」这一层是批 2 加的,对显式传 tasks[] 的调用方是行为变化(此前恒 manual), // 保留它是因为反过来更荒谬:用户在表单上把整个 goal 选成 auto,拆出来的 task 却全是 @@ -1450,6 +1462,7 @@ function insertTasksForGoal(wdb, goal, plans, createdBy) { priority: p.priority, budgetJson: p.budget, // 建行时就存下来:草稿路径上请求体活不到 dispatch(缺陷 F3) createdBy, + acceptanceMode: p.acceptanceMode, }) ); const bySeq = new Map(created.map((t) => [String(t.seq), t.id])); @@ -2065,6 +2078,8 @@ router.get('/:companyId/tasks', (req, res) => { const tasks = wdb.listCompanyTasks(req.params.companyId, { status: req.query.status || null, goalId: req.query.goalId || req.query.goal_id || null, + // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务 + parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null, includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', limit: Number.isInteger(limit) ? limit : null, }); @@ -2308,6 +2323,40 @@ router.patch('/:companyId/tasks/:taskId', (req, res) => { }); /** + * POST /api/company/:companyId/tasks/:taskId/subtask-result — 43 号文档 Phase 3 + * body: { status: 'completed' | 'failed', summary? } + * + * `coder-validator` 逐条验收 itemized 任务的验收条目时,每判完一条就调一次这个端点上报 + * 那一条子任务的结果——不是解析它写的 checks[]/verdict 自由文本 JSON(那份格式在几个 + * validator 之间只是约定俗成,从没被任何服务端代码强制校验过,见 graph-engine.js 的 + * unsatisfiedRequiredProduces 一带)。状态改变走真实工具调用,这是本仓库一贯的做法。 + * + * 只接受**子任务**(`parent_task_id` 非空):顶层 task 的终态由 run 收敛走 + * `settleTaskFromRun` 那条路,这个端点不是它的替代品。 + */ +router.post('/:companyId/tasks/:taskId/subtask-result', (req, res) => { + try { + const wdb = require('../services/work-graph-db'); + const task = requireTask(req, res); + if (!task) return; + if (!task.parent_task_id) { + return res.status(400).json({ + error: `${task.id} 不是子任务(没有 parent_task_id)——这个端点只服务 43 号文档物化出来的子任务,顶层任务的终态由 run 收敛决定`, + }); + } + const { status, summary } = req.body || {}; + if (!['completed', 'failed'].includes(status)) { + return res.status(400).json({ error: `status 只能是 'completed' 或 'failed',收到: ${JSON.stringify(status)}` }); + } + const changed = wdb.settleSubtaskResult(task.id, status, summary || null); + res.json({ ok: true, changed: changed > 0, 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 a867b32..cae1226 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -62,6 +62,8 @@ const { // 39 号文档「确定性通路」:回退熔断收敛时顺手开一条 draft task,见 maybeProposeReplanTask createTask, getGoal, + // 43 号文档:settleNode 成功路径顺手物化 itemized 子任务,见 maybeMaterializeItemizedSubtasks + getTask, } = require('./work-graph-db'); /** @@ -221,6 +223,11 @@ const ENGINE_FAIL_REASONS = Object.freeze({ // 会静默拿一份没有角色指导的提示词去跑真实 CLI(真实样本见 34 号文档 §五)。 // 字符串与招聘路径的 'body_not_ready' 同名同义,不是两套判词。 BODY_NOT_READY: 'body_not_ready', + // 43 号文档 Phase 3:coder-validator 判自己 success,但它验的 task 是 itemized 且 + // 子任务还没全部 completed——agent 自己写的 checks[]/verdict 没人解析(见下面 + // maybeCheckSubtasksComplete 的注释),真正说了算的是 tasks.status,这里同样是 + // 把一个此前只进提示词的软约束收成硬闸门,不是发明新语义。 + SUBTASKS_INCOMPLETE: 'subtasks_incomplete', }); /** 归因/结构化产出文件名(agent.md「产出格式」:runs/{rootRunId}/nodes/{nodeRunId}/attempt-{n}.json) */ @@ -1845,9 +1852,35 @@ function settleNode(runId, nodeRunId, outcome = {}) { timelineNote({ runId, nodeRunId }, 'node.output', `节点声明必需的上游产物 ${missingInputs.join(', ')} 在索引里查不到实例,按 fail 结算`, { node_run_id: nodeRunId, missing_kinds: missingInputs, degraded: 'missing_required_input' }); + } else { + // 43 号文档 Phase 3:coder-validator 判自己 success 之前,再核一遍它管的 task 是不是 + // itemized 且有子任务——有的话必须全部 completed 才认这次 success。coder-validator + // 自己写的 checks[]/verdict 是没人解析的自由文本(同一段代码里 unsatisfiedRequiredProduces + // 的教训:软产物必须收成硬闸门,不能只指望 agent 自觉),真正说了算的是 tasks.status, + // 由 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); + const notDone = subtasks.filter((t) => t.status !== 'completed').map((t) => t.id); + if (notDone.length) { + to = 'fail'; + failReason = ENGINE_FAIL_REASONS.SUBTASKS_INCOMPLETE; + timelineNote({ runId, nodeRunId }, 'node.output', + `这条 task 是 itemized,还有 ${notDone.length}/${subtasks.length} 条子任务没有上报 completed(${notDone.join(', ')}),不认这次 success`, + { node_run_id: nodeRunId, unresolved_subtasks: notDone, degraded: 'subtasks_incomplete' }); + } + } } } } + // 43 号文档:故意放在 !isSubgraphNode 这道闸门**之外**——org-planner 在 org 层的节点 + // 类型是 subgraph(org 层节点全是 subgraph,见上面 isSubgraphNode 的注释),真正核实 + // 生产数据发现它当前总是靠 workgraph-planning 展开成 planner.json 那张 5 节点子图, + // 不是直接一个 agent_call;acceptance.md(如果有)落在子 run 里,不在这个节点自己的 + // 私有目录(subgraph 节点的 output_ref 恒为 null)。两种形态都要认,见函数内部。 + if (to === 'success' && run0 && nodeRun0 && def0) { + maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run: run0, nodeRun: nodeRun0, def: def0, isSubgraphNode }); + } } const changed = sse.transitionNode(runId, nodeRunId, 'running', to, { @@ -2207,6 +2240,109 @@ function maybeProposeReplanTask(runId, rootRunId, nodeRun, def, remediationNote) } /** + * 解析 org-planner 写的 acceptance.md(`| # | 验收项 | 判定方式 | pass 条件 |` 表格, + * 见 `system/agents/org-planner/tools.md` §3 的模板)。只信表格形状,不信位置—— + * 列数不足 4、分隔行(`---`)、编号列不是纯数字的行一律跳过,不抛异常:格式不对 + * 就是「没有可用的验收条目」,交给调用方按空数组处理。 + */ +function parseAcceptanceMd(text) { + const rows = []; + for (const line of String(text || '').split('\n')) { + const m = line.match(/^\s*\|(.*)\|\s*$/); + if (!m) continue; + const cells = m[1].split('|').map((c) => c.trim()); + if (cells.length < 4) continue; + const [seqCell, item, method, passCondition] = cells; + if (!/^\d+$/.test(seqCell) || !item) continue; // 表头 / 分隔行 / 编号写歪的行 + rows.push({ seq: Number(seqCell), item, method, passCondition }); + } + return rows; +} + +/** + * 在一条 run 名下**全部**节点的私有目录里找 acceptance.md,返回第一份找到的绝对路径。 + * + * 不假设固定节点名/固定模板:org-planner 到底展开成哪张 work graph(`planner.json` 的 + * 5 节点 fan-out,还是别的骨架)是 workgraph-planning 现场选的,谁写 acceptance.md + * 因模板而异(43 号文档 Phase 2 核实真实生产数据后的结论),扫全部节点比认一个固定 + * 节点名更不容易随模板改动跟着碎。 + */ +function findAcceptanceMdFile(run) { + try { + const rows = getDb().prepare(`SELECT id FROM graph_run_nodes WHERE run_id = ?`).all(run.id); + for (const r of rows) { + const file = path.join(nodeArtifactDir(run, r.id), 'acceptance.md'); + if (fs.existsSync(file)) return file; + } + } catch { /* 目录读不出来就当没有,best-effort */ } + return null; +} + +/** + * 43 号文档 Phase 2:`task.acceptance_mode='itemized'` 时,org-planner 这一轮真正判定 + * success 之后,把它(或它展开出的子图)写的 acceptance.md 逐行物化成子任务。 + * + * org 层节点全是 `nodeKind='subgraph'`(见上面 isSubgraphNode 的注释),真实核实生产数据 + * 发现 org-planner 现在总是靠 workgraph-planning 展开成子图在跑,不是直接一个 agent_call—— + * 两种形态都要认:agent_call 就在它自己的私有目录找,subgraph 就去它的子 run 里全节点扫 + * (见 findAcceptanceMdFile)。 + * + * 纯 best-effort:查不到 task / 不是 itemized / 文件不存在 / 解析不出条目,一律安静跳过 + * 并留一条 timeline note——**这一步失败绝不能把 org-planner 这一轮真正有价值的产出打成 + * fail**,物化子任务是「顺手做的」,不是节点判定的一部分(33 号文档的教训:把一个软产物 + * 当成硬闸门,会把整条链路拖进死循环)。 + * + * 幂等:父任务名下已经有子任务就不再重复建——覆盖的是节点被 retry 后二次结算 success + * 这种情况,不是想同步 acceptance.md 后续的编辑(那需要人工介入,不是这一步的职责)。 + */ +function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, isSubgraphNode }) { + try { + const d = def && nodeRun && def.nodes.get(nodeRun.def_node_id); + if (!d || d.systemAgentId !== 'org-planner') return; + if (!run.task_id) return; + + 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; + if (already > 0) return; + + const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run; + const file = searchRun ? findAcceptanceMdFile(searchRun) : null; + if (!file) { + timelineNote({ runId, nodeRunId }, 'node.output', + `task.acceptance_mode='itemized' 但没找到 acceptance.md,跳过子任务物化(仍按 holistic 收尾)`, + { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_missing' }); + return; + } + + const rows = parseAcceptanceMd(fs.readFileSync(file, 'utf-8')); + if (!rows.length) { + timelineNote({ runId, nodeRunId }, 'node.output', + `acceptance.md 存在但解析不出任何验收条目(表格形状不对?),跳过子任务物化`, + { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_unparseable' }); + return; + } + + for (const row of rows) { + createTask({ + goalId: task.goal_id, + companyId: task.company_id, + title: row.item.slice(0, 120), + description: `判定方式:${row.method || '(未写明)'}\npass 条件:${row.passCondition || '(未写明)'}`, + createdBy: 'org-planner', + parentTaskId: task.id, + }); + } + timelineNote({ runId, nodeRunId }, 'node.output', + `按 acceptance.md 物化了 ${rows.length} 条子任务`, + { node_run_id: nodeRunId, task_id: task.id, subtask_count: rows.length }); + } catch (err) { + console.error('[graph-engine] maybeMaterializeItemizedSubtasks 失败(不影响节点结算):', err.message); + } +} + +/** * 解析跨层地址 → 「该退到哪个子 run 的哪一行」(**纯查询,不改任何状态**)。 * * 输入的地址来自失败节点行的 `fail_node_ref`(三值透传的第四值,见 normalizeNodeRef 那段)。 diff --git a/server/services/task-planning.js b/server/services/task-planning.js index a61702d..90c993c 100644 --- a/server/services/task-planning.js +++ b/server/services/task-planning.js @@ -42,8 +42,14 @@ const PLAN_MAX_ROUNDS = 3; */ const MAX_TASKS = 8; -/** 产出里允许出现的键。多余键一律按 schema 打回:那些字段要么服务端生成、要么人在界面上定 */ -const ALLOWED_KEYS = new Set(['seq', 'title', 'description', 'depends_on', 'dependsOn']); +/** 产出里允许出现的键。多余键一律按 schema 打回:那些字段要么服务端生成、要么人在界面上定 + * + * 43 号文档:`acceptance_mode` 是这四个字段之外**唯一**的例外——要不要按验收条目拆子任务, + * 是一次语义判断(跟「要不要拆多个 task」用的是同一种判断力),没法像 mode/priority/budget + * 那样交给服务端按 goal 级默认值机械注入,所以必须让 agent 自己决定、自己写出来。 + */ +const ALLOWED_KEYS = new Set(['seq', 'title', 'description', 'depends_on', 'dependsOn', 'acceptance_mode', 'acceptanceMode']); +const ACCEPTANCE_MODES = new Set(['holistic', 'itemized']); // ──────────────────────────────────────────────────────────────── // 注入点(仿 graph-engine.setPlanWorkGraph) @@ -76,7 +82,7 @@ function planTasks(ctx) { * * @param {{companyId: string, goal: object}} ctx goal 是 goals 表的整行 * @returns {Promise<{specs: object[], tokens: number, rounds: number}>} - * specs 形如 `[{seq, title, description, depends_on: [seq…]}]`,seq 恒为 1..N 连续 + * specs 形如 `[{seq, title, description, depends_on: [seq…], acceptance_mode}]`,seq 恒为 1..N 连续 * @throws 轮次耗尽 / 调用本身炸了;两种情况都在 error 上挂了 `tokens`(已烧掉的那部分) */ async function defaultPlanTasks(ctx) { @@ -203,14 +209,20 @@ function buildTaskPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = ' describeHiredRoles(ctx.companyId), '', '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', - '顶层是**数组**,每个元素恰好四个字段:', + '顶层是**数组**,每个元素四个必填字段 + 一个可选字段:', '```json', - '[{"seq":1,"title":"…","description":"…","depends_on":[]}]', + '[{"seq":1,"title":"…","description":"…","depends_on":[],"acceptance_mode":"holistic"}]', '```', `- \`seq\`:1..N 连续整数、不重复、与数组顺序一致;条数上限 **${MAX_TASKS}**。`, '- `depends_on`:只能写本次产出的 `seq` 数字(1-based)。**不许写 task id** —— id 由服务端' + '在你交回之后才生成,你根本看不到;写字符串一定被打回。没有依赖就写 `[]`(不能省)。', - '- 只输出这四个字段。`id` / `status` / `mode` / `budget` / `priority` / `agent` 一律不许出现。', + '- `acceptance_mode`(可选,缺省 `"holistic"`):这条 task 的验收后续要不要按验收条目拆成' + + '子任务逐条判——判断轴跟 Step 2 判「要不要拆多个 task」是同一种(够不够独立成立、值不值得' + + '单独验收)。多数任务留空/写 `"holistic"` 即可;只有一件对外是单一交付物、但内部有多条' + + '可以各自独立验收的验收标准的任务,才写 `"itemized"`。不确定就留 `"holistic"`——错误地' + + '拆细比保守合并的代价更高。', + '- 只输出这五个字段(`acceptance_mode` 可以不写)。`id` / `status` / `mode` / `budget`' + + ' / `priority` / `agent` 一律不许出现。', '- 每条 `description` 必须**自包含**:下游每条 task 起独立的 run,那个 run 只看得到自己' + '这一条描述(它会成为该 run 的 task-goal.json),不许写「同上」「见 task 1」这类跨条引用。', '- 拆分理由写在 JSON 代码块**前面**的正文里;代码块之后不要再写任何字。', @@ -370,7 +382,18 @@ function validatePlannedTasks(raw) { } const extra = Object.keys(item).filter((k) => !ALLOWED_KEYS.has(k)); if (extra.length) { - errors.push({ rule: 'schema', detail: `${at} 有多余键 ${extra.join(' / ')}:只许 seq / title / description / depends_on` }); + errors.push({ rule: 'schema', detail: `${at} 有多余键 ${extra.join(' / ')}:只许 seq / title / description / depends_on / acceptance_mode` }); + } + + // 没写就是没判断要拆——缺省 holistic(保守,不引入新行为),不是错误 + const rawMode = item.acceptance_mode !== undefined ? item.acceptance_mode : item.acceptanceMode; + let acceptanceMode = 'holistic'; + if (rawMode !== undefined && rawMode !== null) { + if (!ACCEPTANCE_MODES.has(rawMode)) { + errors.push({ rule: 'schema', detail: `${at} 的 acceptance_mode 只能是 "holistic" 或 "itemized",收到 ${JSON.stringify(rawMode)}` }); + } else { + acceptanceMode = rawMode; + } } const description = String(item.description == null ? '' : item.description).trim(); @@ -406,6 +429,7 @@ function validatePlannedTasks(raw) { title: String(item.title == null ? '' : item.title).trim(), description, depends_on: deps, + acceptance_mode: acceptanceMode, }); }); diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 88f4136..bf2223f 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -531,6 +531,10 @@ function getDb() { -- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现: -- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释 execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), + -- 43 号文档:itemized 的 task 由 org-planner 收尾时按验收条目物化出子任务 + -- (parent_task_id 指回这一行);holistic 保持现状「一次性给总分」不物化任何东西。 + -- 子任务本身恒为 holistic(不允许递归再拆一层),见 createTask 里的校验。 + acceptance_mode TEXT NOT NULL DEFAULT 'holistic' CHECK (acceptance_mode IN ('holistic','itemized')), depends_on TEXT NOT NULL DEFAULT '[]', -- JSON 数组,同 goal 内的 task id budget_json TEXT, current_run_id TEXT, -- 最近一次 root run(逻辑外键,见 §四.6) @@ -632,6 +636,11 @@ function getDb() { if (!hasColumn(db, 'tasks', 'execution_conv_id')) { db.exec(`ALTER TABLE tasks ADD COLUMN execution_conv_id TEXT`); } + // 43 号文档:ADD COLUMN 加不了 CHECK(同上 executor 那条注释的 SQLite 限制), + // 存量库上 acceptance_mode 的取值域只由 createTask 的应用层校验守。 + if (!hasColumn(db, 'tasks', 'acceptance_mode')) { + db.exec(`ALTER TABLE tasks ADD COLUMN acceptance_mode TEXT NOT NULL DEFAULT 'holistic'`); + } // ⚠️ 这条索引**必须建在 ALTER 之后**,不能放进上面的建表块: // 建表块整体先于迁移块执行,而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op —— // 表还是批 4 的形态、没有 assignee_agent_id,那一句 CREATE INDEX 会直接抛 @@ -1856,6 +1865,10 @@ function validateDependsOn(goalId, taskId, dependsOn) { * **必须能在建行时就存下来**(缺陷 F3):草稿路径(`POST /:cid/tasks` 撞 409 → 存草稿 → * 之后 dispatch)上,请求体里的 budget 早就不在了。只有写侧没有读侧的列 = 用户填了、 * 接口回了 200、run 却拿着 DEFAULT_TASK_BUDGET 在跑,中途预算耗尽 blocked 且没人知道为什么。 + * @param {string|null} [p.parentTaskId] 43 号文档:子任务指回父任务。图流水线(`insertTasksForGoal`) + * 建的顶层 task 恒为 null;只有 org-planner 收尾物化子任务时才传。 + * @param {string} [p.acceptanceMode='holistic'] 'holistic' | 'itemized'。子任务恒为 'holistic' + * (不允许递归再拆一层)——传 `parentTaskId` 时若给了 'itemized' 会被拒。 * @returns {object} 刚插入的整行 */ function createTask({ @@ -1870,14 +1883,25 @@ function createTask({ budgetJson = null, reportTo = null, createdBy = null, + parentTaskId = null, + acceptanceMode = 'holistic', } = {}) { if (!goalId) throw new Error('createTask: goalId 必填'); if (!title) throw new Error('createTask: title 必填'); if (!description) throw new Error('createTask: description 必填'); + if (!['holistic', 'itemized'].includes(acceptanceMode)) { + throw new Error(`createTask: acceptanceMode 非法: ${acceptanceMode}`); + } + if (parentTaskId && acceptanceMode === 'itemized') { + throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分'); + } const db = getDb(); const goal = getGoal(goalId); if (!goal) throw new Error(`createTask: goal 不存在: ${goalId}`); + if (parentTaskId && !getTask(parentTaskId)) { + throw new Error(`createTask: parentTaskId 不存在: ${parentTaskId}`); + } // company_id 这一列冗余存在的**唯一**理由是让调度器不必 JOIN;让调用方各传各的, // 就等于给这份冗余留了一条能对不上的路。默认从 goal 抄,传了也以 goal 为准的那种「以防万一」 @@ -1902,8 +1926,8 @@ 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, - created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?) + parent_task_id, acceptance_mode, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( tid, goalId, String(cid), nextSeq, String(title), String(description), @@ -1911,7 +1935,7 @@ function createTask({ // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 // goal 上有就继承下来,别让每个调用方各记一遍。 reportTo || goal.report_to || null, - createdBy, now, now + createdBy, parentTaskId, acceptanceMode, now, now )); return getTask(id); } @@ -1937,7 +1961,9 @@ function listGoalTasks(goalId, { includeArchived = false } = {}) { * 这里再写一份就是第二个持有者 —— canDispatch 的取值域被前端复述了一遍、还多写一个值, * 导致公司失败一次后再也下不了任务,就是这么来的。要位次找调度器要。 */ -function listCompanyTasks(companyId, { status = null, goalId = null, includeArchived = false, limit = null } = {}) { +function listCompanyTasks(companyId, { + status = null, goalId = null, parentTaskId = null, includeArchived = false, limit = null, +} = {}) { const where = ['company_id = @companyId']; const params = { companyId }; if (status) { @@ -1948,6 +1974,12 @@ function listCompanyTasks(companyId, { status = null, goalId = null, includeArch where.push('goal_id = @goalId'); params.goalId = goalId; } + // 43 号文档 Phase 3:coder-validator 用它列出「这个 itemized 任务物化出了哪些子任务」, + // 才知道要逐条上报哪几个 taskId + if (parentTaskId) { + where.push('parent_task_id = @parentTaskId'); + params.parentTaskId = parentTaskId; + } if (!includeArchived) where.push('archived_at IS NULL'); const lim = Number.isInteger(limit) && limit > 0 ? ` LIMIT ${limit}` : ''; return getDb() @@ -2084,6 +2116,31 @@ function settleTaskFromRun(taskId, runId, status) { } /** + * 43 号文档 Phase 3:coder-validator 逐条上报子任务验收结果的落库口。 + * + * 与 `settleTaskFromRun` 同款条件更新(终态幂等闸门):子任务可能因为 coder-validator + * 被 retry 而重复上报,已经是终态的行不该被后一次调用覆盖。**只服务子任务**—— + * `parent_task_id IS NOT NULL` 这道 WHERE 挡住顶层 task(它的终态走 run 收敛那条路, + * 不该被这个端点绕开)。 + * + * @returns {number} 受影响行数;0 = 已经是终态 / 不是子任务,调用方据此判断这次上报有没有生效 + */ +function settleSubtaskResult(taskId, status, summary = null) { + if (!['completed', 'failed'].includes(status)) { + throw new Error(`settleSubtaskResult: status 只能是 'completed' 或 'failed',收到: ${status}`); + } + const now = new Date().toISOString(); + return getDb() + .prepare(` + UPDATE tasks SET status = @status, summary = @summary, finished_at = @now, updated_at = @now + WHERE id = @id + AND parent_task_id IS NOT NULL + AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) + `) + .run({ id: taskId, status, summary, now }).changes; +} + +/** * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 * * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, @@ -2715,6 +2772,7 @@ module.exports = { taskProgress, maxTaskSeq, settleTaskFromRun, + settleSubtaskResult, GRAPH_TAKEOVER_ASSIGNEE, // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 claimQueuedTask, diff --git a/server/tests/unit/company-goal-task-routes.test.js b/server/tests/unit/company-goal-task-routes.test.js index 037dd8b..6447ff2 100644 --- a/server/tests/unit/company-goal-task-routes.test.js +++ b/server/tests/unit/company-goal-task-routes.test.js @@ -400,7 +400,8 @@ describe('company goal/task routes(27-P3C §六)', () => { const ok = validatePlannedTasks([{ seq: 1, title: 'a', description: 'A', depends_on: [] }]); expect(ok.ok).toBe(true); - expect(ok.specs[0]).toEqual({ seq: 1, title: 'a', description: 'A', depends_on: [] }); + // 43 号文档:acceptance_mode 没写就缺省 'holistic' + expect(ok.specs[0]).toEqual({ seq: 1, title: 'a', description: 'A', depends_on: [], acceptance_mode: 'holistic' }); const rules = (raw) => validatePlannedTasks(raw).errors.map((e) => e.rule); // seq 与数组顺序错位:错位后 depends_on 引的是另一条 task,而那条错误依赖完全合法, @@ -415,6 +416,9 @@ describe('company goal/task routes(27-P3C §六)', () => { expect(rules(Array.from({ length: 9 }, (_, i) => ({ seq: i + 1, description: 'x', depends_on: [] })))) .toContain('limit'); expect(rules([{ seq: 1, description: 'A', depends_on: [], mode: 'auto' }])).toContain('schema'); + // 43 号文档:acceptance_mode 是允许出现的第五个键,itemized 合法、乱写值按 schema 打回 + expect(validatePlannedTasks([{ seq: 1, description: 'A', depends_on: [], acceptance_mode: 'itemized' }]).ok).toBe(true); + expect(rules([{ seq: 1, description: 'A', depends_on: [], acceptance_mode: 'bogus' }])).toContain('schema'); // 正向引用(第 1 条依赖第 2 条)不成环,是合法输入 —— 批 1 缺陷 4 就是被它咬的 expect(validatePlannedTasks([ diff --git a/server/tests/unit/graph-engine-fail-reason.test.js b/server/tests/unit/graph-engine-fail-reason.test.js index 6afbe21..89caabd 100644 --- a/server/tests/unit/graph-engine-fail-reason.test.js +++ b/server/tests/unit/graph-engine-fail-reason.test.js @@ -123,7 +123,7 @@ describe('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9 // W12 补完 · 三类引擎自判失败都在 fail_reason 上留名 // ──────────────────────────────────────────────────────────────── - it('取值域:六个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => { + it('取值域:七个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => { const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS); expect(engineReasons).toEqual([ 'node_error', 'boundary_escape', 'child_run_failed', @@ -131,6 +131,9 @@ describe('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9 'missing_required_input', 'produces_missing', // 35 号文档提案 A:本体完整性闸门(runClaimedNode 派发前检查)新增的取值 'body_not_ready', + // 43 号文档 Phase 3:coder-validator 判自己 success 但 itemized 任务的子任务没全部 + // completed 时新增的取值(settleNode 的 success 判定里,与上面两个契约闸门同一处) + 'subtasks_incomplete', ]); // infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它 // 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。 diff --git a/server/tests/unit/task-acceptance-mode.test.js b/server/tests/unit/task-acceptance-mode.test.js new file mode 100644 index 0000000..8b16178 --- /dev/null +++ b/server/tests/unit/task-acceptance-mode.test.js @@ -0,0 +1,72 @@ +/** + * 43 号文档 Phase 0:`tasks.acceptance_mode` + `createTask({parentTaskId})` 的落库校验。 + * + * 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 全程不碰 + * ——写法与 graph-edge-contracts.test.js 同源。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; + +const TEST_DB_PATH = '/tmp/test-task-acceptance-mode.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; + +describe('43 号文档 Phase 0:acceptance_mode / parentTaskId', () => { + let wdb; + + beforeAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + delete require.cache[require.resolve('../../services/graph-db')]; + delete require.cache[require.resolve('../../services/work-graph-db')]; + wdb = require('../../services/work-graph-db'); + }); + + afterAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + delete process.env.GRAPH_DB_PATH; + delete require.cache[require.resolve('../../services/graph-db')]; + delete require.cache[require.resolve('../../services/work-graph-db')]; + }); + + function goalId() { + const g = wdb.createGoal({ companyId: 'co-amode', title: 'g', description: 'd' }); + return g.id; + } + + it('缺省 acceptance_mode=holistic、parent_task_id=null(图流水线现状不变)', () => { + const t = wdb.createTask({ goalId: goalId(), title: 't', description: 'd' }); + expect(t.acceptance_mode).toBe('holistic'); + expect(t.parent_task_id).toBeNull(); + }); + + it('可以显式建一条 itemized 的顶层 task', () => { + const t = wdb.createTask({ goalId: goalId(), title: 't', description: 'd', acceptanceMode: 'itemized' }); + expect(t.acceptance_mode).toBe('itemized'); + }); + + it('非法 acceptanceMode 直接拒', () => { + expect(() => wdb.createTask({ goalId: goalId(), title: 't', description: 'd', acceptanceMode: 'bogus' })) + .toThrow(/acceptanceMode 非法/); + }); + + it('子任务:parentTaskId 指回父任务,acceptance_mode 恒 holistic', () => { + const gid = goalId(); + const parent = wdb.createTask({ goalId: gid, title: 'parent', description: 'd', acceptanceMode: 'itemized' }); + const child = wdb.createTask({ goalId: gid, title: 'child A.1', description: 'd', parentTaskId: parent.id }); + expect(child.parent_task_id).toBe(parent.id); + expect(child.acceptance_mode).toBe('holistic'); + }); + + it('子任务不允许递归声明 itemized(不支持二级拆分)', () => { + const gid = goalId(); + const parent = wdb.createTask({ goalId: gid, title: 'parent', description: 'd' }); + expect(() => wdb.createTask({ + goalId: gid, title: 'child', description: 'd', parentTaskId: parent.id, acceptanceMode: 'itemized', + })).toThrow(/不支持递归拆分/); + }); + + it('parentTaskId 指向不存在的 task 直接拒,不静默建成孤儿行', () => { + expect(() => wdb.createTask({ goalId: goalId(), title: 't', description: 'd', parentTaskId: 'T-not-exist' })) + .toThrow(/parentTaskId 不存在/); + }); +}); diff --git a/server/tests/unit/task-decomposition-materialize.test.js b/server/tests/unit/task-decomposition-materialize.test.js new file mode 100644 index 0000000..d2b8aee --- /dev/null +++ b/server/tests/unit/task-decomposition-materialize.test.js @@ -0,0 +1,152 @@ +/** + * 43 号文档 Phase 2:org-planner 节点结算 success 后,把 acceptance.md 物化成子任务。 + * + * org 层节点全是 subgraph(isSubgraphNode 恒真),真正的产出在 workgraph-planning 展开出的 + * 子 run 里——所以这里跟 goal-task-acceptance.test.js 同源,用 `setPlanWorkGraph` 注入假 + * 规划(返回一张手造的单节点 work graph),再用 `setAgentExecutor` 注入那个节点的假执行, + * 不真起 Claude CLI。 + */ +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-materialize.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const MODULES = [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/graph-engine', +]; + +const ORG_NODES = ['org-planner', 'org-coder', 'org-validator']; +const ORG_EDGES = [ + { from: 'org-planner', to: 'org-coder', condition: 'success' }, + { from: 'org-coder', to: 'org-validator', condition: 'success' }, +]; + +const ACCEPTANCE_MD = `# 验收清单 + +| # | 验收项 | 判定方式 | pass 条件 | +|---|--------|----------|-----------| +| 1 | 方向键控制移动 | 手动操作 | 蛇按方向键移动 | +| 2 | 吃到食物加分 | 手动操作 | 分数 +1 | +`; + +describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { + let wdb; + let org; + let engine; + let runsDir; + + beforeAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-decomp-runs-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + wdb = require('../../services/work-graph-db'); + org = require('../../services/org-graph-service'); + engine = require('../../services/graph-engine'); + }); + + afterAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_RUNS_DIR; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + }); + + function ensureAgent(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); + } + + /** 建一家公司的 org 图 + 一条 task,起一条 org run,跑到 org-planner(含它展开的子图)结算为止 */ + async function setupAndRunPlanner(companyId, { acceptanceMode = 'itemized', writeFile = true } = {}) { + expect(org.writeGraph(companyId, { nodes: ORG_NODES, edges: ORG_EDGES }).ok).toBe(true); + ensureAgent('planner-worker'); + + const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' }); + const task = wdb.createTask({ + goalId: goal.id, title: '实现贪吃蛇', description: 'd', acceptanceMode, + }); + + const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, taskId: task.id }); + engine.markRunReady(run.id); + engine.startRun(run.id, { mode: 'manual' }); + + engine.setPlanWorkGraph(async () => { + const g = wdb.createWorkGraph({ + companyId, nodes: [{ nodeName: 'planner-worker', systemAgentId: 'planner-worker' }], edges: [], + }); + expect(g.errors || []).toEqual([]); + return { workGraphId: g.id }; + }); + let childRunId; + try { + const planned = await engine.nextStep(run.id); // org-planner:规划出子图,父节点转 running 等子 run + childRunId = planned.node && planned.node.childRunId; + } finally { + engine.setPlanWorkGraph(null); + } + expect(childRunId).toBeTruthy(); + + engine.setAgentExecutor(async (ctx) => { + const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId); + fs.mkdirSync(dir, { recursive: true }); + if (writeFile) fs.writeFileSync(path.join(dir, 'acceptance.md'), ACCEPTANCE_MD); + return { status: 'success', outputRef: dir }; + }); + try { + await engine.advanceOne(childRunId); // 子图里唯一的那个节点 + } finally { + engine.setAgentExecutor(null); + } + return { task, run, childRunId }; + } + + it('itemized + acceptance.md 存在 → org-planner 结算 success 后按表格行数物化子任务', async () => { + const { task, run } = await setupAndRunPlanner('co-materialize-1'); + const plannerNode = wdb.getDb().prepare(` + SELECT status FROM graph_run_nodes WHERE run_id = ? + AND def_node_id = (SELECT id FROM org_graph_nodes WHERE company_id='co-materialize-1' AND role_name='org-planner') + `).get(run.id); + expect(plannerNode.status).toBe('success'); // 前提:子图真的收敛回了父节点 + + const children = wdb.getDb() + .prepare(`SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at`) + .all(task.id); + expect(children).toHaveLength(2); + expect(children[0].title).toBe('方向键控制移动'); + expect(children[0].acceptance_mode).toBe('holistic'); + expect(children[0].description).toContain('手动操作'); + expect(children[0].description).toContain('蛇按方向键移动'); + expect(children[1].title).toBe('吃到食物加分'); + }); + + it('holistic 任务:org-planner 成功结算,不物化任何子任务', async () => { + const { task } = await setupAndRunPlanner('co-materialize-2', { acceptanceMode: 'holistic' }); + const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n; + expect(n).toBe(0); + }); + + it('itemized 但 acceptance.md 不存在:org-planner 仍判 success,不物化子任务(软失败,不拖累主产出)', async () => { + const { task, run } = await setupAndRunPlanner('co-materialize-3', { writeFile: false }); + const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n; + expect(n).toBe(0); + const plannerNode = wdb.getDb().prepare(` + SELECT status FROM graph_run_nodes WHERE run_id = ? + AND def_node_id = (SELECT id FROM org_graph_nodes WHERE company_id='co-materialize-3' AND role_name='org-planner') + `).get(run.id); + expect(plannerNode.status).toBe('success'); // 找不到 acceptance.md 不该把这一步打成 fail + }); +}); diff --git a/server/tests/unit/task-decomposition-validator-gate.test.js b/server/tests/unit/task-decomposition-validator-gate.test.js new file mode 100644 index 0000000..eaaaef9 --- /dev/null +++ b/server/tests/unit/task-decomposition-validator-gate.test.js @@ -0,0 +1,143 @@ +/** + * 43 号文档 Phase 3:coder-validator 判自己 success 之前,子任务必须全部 completed + * 才认;以及 settleSubtaskResult 这个落库口本身的条件更新/幂等/校验。 + */ +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-validator-gate.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const MODULES = [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/graph-engine', +]; + +describe('43 号文档 Phase 3', () => { + let wdb; + let engine; + let runsDir; + + beforeAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-decomp-gate-runs-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + wdb = require('../../services/work-graph-db'); + engine = require('../../services/graph-engine'); + }); + + afterAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_RUNS_DIR; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + }); + + function ensureAgent(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); + } + + // ── settleSubtaskResult 本身 ───────────────────────────────────── + + it('settleSubtaskResult:非法 status 直接拒', () => { + expect(() => wdb.settleSubtaskResult('T-whatever', 'running')).toThrow(/status 只能是/); + }); + + it('settleSubtaskResult:不是子任务(parent_task_id 为空)不生效,changes=0', () => { + const goal = wdb.createGoal({ companyId: 'co-gate-1', title: 'g', description: 'd' }); + const top = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const changed = wdb.settleSubtaskResult(top.id, 'completed'); + expect(changed).toBe(0); + expect(wdb.getTask(top.id).status).toBe('draft'); // 没被动过 + }); + + 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 }); + + expect(wdb.settleSubtaskResult(child.id, 'completed', '通过')).toBe(1); + const after1 = wdb.getTask(child.id); + expect(after1.status).toBe('completed'); + expect(after1.summary).toBe('通过'); + expect(after1.finished_at).toBeTruthy(); + + // 已经是终态,再上报 failed 不该覆盖——防的是 coder-validator 因 retry 重复上报 + expect(wdb.settleSubtaskResult(child.id, 'failed', '改判')).toBe(0); + expect(wdb.getTask(child.id).status).toBe('completed'); + }); + + // ── 引擎侧的闸门:coder-validator 判 success 前核子任务 ───────────── + + async function runValidator(companyId, taskId) { + ensureAgent('coder-validator'); + const g = wdb.createWorkGraph({ + companyId, + taskId, + nodes: [{ nodeName: 'v', systemAgentId: 'coder-validator' }], + edges: [], + }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId }); + engine.markRunReady(run.id); + engine.startRun(run.id, { mode: 'manual' }); + engine.setAgentExecutor(async () => ({ status: 'success' })); + let out; + try { + out = await engine.advanceOne(run.id); + } finally { + engine.setAgentExecutor(null); + } + return out; + } + + it('itemized 任务 + 子任务还没全部 completed → coder-validator 判自己 success 但被引擎打回 fail', async () => { + 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 }); + wdb.settleSubtaskResult(c1.id, 'completed'); // 只完成一半 + + const out = await runValidator(companyId, parent.id); + // 单节点图没有兜底边可退,fail 之后引擎会继续把它收敛成 blocked(等人)—— + // 这里要验的是「没被判 success」+「归因是 subtasks_incomplete」,不是具体停在哪个终态 + expect(out.node.status).not.toBe('success'); + expect(out.node.failReason).toBe('subtasks_incomplete'); + }); + + it('itemized 任务 + 子任务全部 completed → coder-validator 正常判 success', async () => { + 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 }); + wdb.settleSubtaskResult(c1.id, 'completed'); + wdb.settleSubtaskResult(c2.id, 'completed'); + + 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' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); // 缺省 holistic + + const out = await runValidator(companyId, task.id); + expect(out.node.status).toBe('success'); + }); +}); diff --git a/system/agents/coder-validator/agent.md b/system/agents/coder-validator/agent.md index f9fd1f6..5be8600 100644 --- a/system/agents/coder-validator/agent.md +++ b/system/agents/coder-validator/agent.md @@ -68,6 +68,31 @@ node server/services/goal-contract.js run --target <你正在验的交付物路å **写了几条就验几条,不抽查。** 每条给出:通过 / 不通过 + 证据(真实输出片段或 `文件:行号`)。 契约声明的每个分支都要有对应断言——声明了三个状态码就要三条,「主流程通了就行」不成立。 +### Step 4bis · 这条 task 是 itemized 的话,子任务要逐条上报(43 号文档) + +先查一下你正在验的这条 task 有没有被物化过子任务(`itemized` 的任务才有,`holistic` 没有): + +```bash +node bin/lemon.js task subtasks --companyId <公司ID> --taskId <任务ID> +``` + +返回空列表 = 这条 task 是 `holistic`,跳过这一步,直接按 Step 4 的 acceptance_criteria 走。 + +返回非空列表时,**每条子任务的标题就对应一条验收条目**(org-planner 收尾时按 acceptance.md +逐行物化的)——按 Step 2/3 的方法(真跑测试、抓假绿)逐条判定,然后**每判完一条就上报一次**: + +```bash +node bin/lemon.js task subtask-result --companyId <公司ID> --taskId <子任务ID> --status completed --summary "证据摘要" +node bin/lemon.js task subtask-result --companyId <公司ID> --taskId <子任务ID> --status failed --summary "文件:行号 + 不通过原因" +``` + +🚨 **这一步不是可选的礼貌,是硬闸门**:引擎在你写完判决之后,会再核一遍这条 task 名下的子任务 +是不是**全部**收到了 `completed` 上报——少一条没报、或者报了 `failed`,你这一轮判决无论写 +`"verdict": "passed"` 还是别的,都会被引擎**强制改判失败**(这条归因是引擎自己判的,不用你写, +你也不会在这份表里看到它——见下面「`fail_reason` 怎么填」那张表,取值域不包含它)。 +`attempt-N.json` 里的 `checks[]` 只是给人看的审计文本,**没有任何服务端代码解析它**—— +真正说了算的是你有没有真的调用上面这条命令逐条上报。 + ### Step 5 · 写判决产出 fail 时 `remediation_note` 必须能让 coder 直接开工:**`文件:行号` + 期望行为 + 怎么算改好了**。 @@ -170,6 +195,8 @@ fail 时 `remediation_note` 必须能让 coder 直接开工:**`文件:行号` mock 掉了被测对象 / 覆盖堆在无关文件? 4. acceptance_criteria 我是逐条过的,还是抽查了几条就放行? 契约声明的每个状态码/错误分支都有断言吗? +4bis. 我查过 `task subtasks` 了吗?有子任务的话,我是不是每条都调了 `task subtask-result` + 上报,还是只在 attempt-N.json 里写了 checks[] 就以为完事了? 5. 我有没有手贱顺手改了一行代码?(不许——改了就没有第二双眼睛看它) 6. fail_reason 我填的是 impl_bug / input_underspecified / infra_flake / null 之一吗? 填 infra_flake 的证据是进程级的,还是我在拿它掩盖 impl_bug? diff --git a/system/agents/task-planning/agent.md b/system/agents/task-planning/agent.md index b6cebdd..fac0b22 100644 --- a/system/agents/task-planning/agent.md +++ b/system/agents/task-planning/agent.md @@ -3,11 +3,11 @@ ## ⚡ 硬约束(这 9 条排在最前面,因为它们是判「这一轮算不算数」的全部依据) 1. **你的产出是一个 JSON 数组,不是别的任何东西。** 形状固定: - `[{ "seq": 1, "title": "…", "description": "…", "depends_on": [] }]` + `[{ "seq": 1, "title": "…", "description": "…", "depends_on": [], "acceptance_mode": "holistic" }]` 2. **`depends_on` 里只能出现本次产出的 `seq` 数字**(1-based)。**不许写 task id** —— id 由服务端在你交回之后才生成,你根本看不到。写字符串 id 一定被打回。 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。** 别跳号,别从 0 开始。 4. **条数上限 8。** 超过 8 条,说明你在拆步骤而不是拆任务(见「Step 2」)。 -5. **只输出这四个字段。** 不许产出 `id` / `taskId` / `status` / `mode` / `budget` / `priority` / `agent` / `assignee` —— 这些要么由服务端生成,要么由人在界面上定,你写了会被当成脏字段打回。 +5. **只输出这五个字段,其中 `acceptance_mode` 可以不写(缺省 `"holistic"`)。** 不许产出 `id` / `taskId` / `status` / `mode` / `budget` / `priority` / `agent` / `assignee` —— 这些要么由服务端生成,要么由人在界面上定,你写了会被当成脏字段打回。`acceptance_mode` 是唯一的例外:它需要你的语义判断,服务端给不出默认值以外的答案,见「Step 2bis」。 6. **JSON 代码块必须是你回复的最后一段。** 拆分理由写在它**前面**的正文里。代码块之后不要再写任何字。 7. **一条也算合法产出。** goal 本身就是一件事时,输出**恰好 1 条** `seq:1 / depends_on:[]`。这不是失败,是正确答案(见「Step 2」的不拆信号)。 8. **不许发明需求。** goal 描述里没有的东西,一个字都不许出现在你的 task 里。拆分是**切分**,不是扩写、不是补全、不是「顺便建议」。 @@ -89,6 +89,19 @@ --- +## Step 2bis · 这条 task 的验收要不要拆成子任务逐条判(`acceptance_mode`) + +这是**这条 task 内部**的判断,跟 Step 2「要不要拆成多条 task」是同一个轴(够不够独立成立、值不值得单独验收),只是尺度缩小了一级——Step 2 判的是「这个 goal 该拆成几条 task」,这里判的是「这一条 task 的验收标准该不该拆成几条子任务」。 + +- **`"holistic"`(缺省,不确定就选它)**:这条 task 是一个不可再分的交付物,验收时一次性给总分。绝大多数 task 都是这个。 +- **`"itemized"`**:这条 task 对外是**单一交付物**(不满足 Step 2 拆多条 task 的条件),但它的验收标准天然由**多条互相独立、可以各自单独判定通过与否**的条目组成——拿掉其中一条,剩下的验收条目仍然各自成立。org-planner 收尾时会把这些验收条目物化成子任务,挂在看板上,coder-validator 会逐条判而不是给总分。 + +例子:「实现一个贪吃蛇小游戏」是一个交付物(不该按 Step 2 拆成「方向控制」「计分」「碰撞」「重开」四条 task——那是同一个交付物的四个功能点,不是四个可以分开验收的交付物),但它的验收标准(方向键控制、吃到食物加分、碰撞结束游戏、可以重新开始)彼此独立、可以逐条判定——这种情况写 `"itemized"`。反例:「产出可跑通的后端服务」这条 task 的验收标准可能也有好几条(能启动、接口能上传、接口能下载、错误有正确返回码),但如果这些条目本身互相依赖、拆开单独判没有意义(比如"能上传"离开"能启动"根本无法验证),就该留 `"holistic"`。 + +**判断错的代价是不对称的**:错误地写 `"itemized"`(验收标准其实互相依赖)会让后续验收拆出没法独立成立的子任务,比漏写、保守选 `"holistic"` 的代价更高。拿不准就留 `"holistic"`。 + +--- + ## Step 3 · 定依赖 `depends_on` 只写**真的拿不到前一条的产物就开不了工**的那种依赖。 @@ -100,7 +113,7 @@ --- -## Step 4 · 自查(六条,逐条对着你写好的 JSON 过一遍再交) +## Step 4 · 自查(七条,逐条对着你写好的 JSON 过一遍再交) | # | 检查 | 不过怎么办 | |---|---|---| @@ -108,8 +121,9 @@ | 2 | `depends_on` 每个元素都是 1..N 里的整数、不等于自己、无环 | 删掉引用不到的 | | 3 | N ≤ 8 | 回 Step 2 合并 | | 4 | 每条 `description` 单独读得懂、写清了「做完什么算达成」 | 补全背景,别写跨条引用 | -| 5 | 四个字段之外没有多余键;`depends_on` 是数组(哪怕是空的 `[]`)也必须在 | 删掉多余键,补上 `[]` | +| 5 | 五个字段之外没有多余键;`depends_on` 是数组(哪怕是空的 `[]`)也必须在 | 删掉多余键,补上 `[]` | | 6 | N 条加起来正好覆盖 goal,没漏、**也没多发明** | 漏了补一条;多了删掉 | +| 7 | 写了 `"itemized"` 的每一条,验收标准确实由几条互相独立的条目组成,不是硬凑的 | 拿不准就改回 `"holistic"`(省略也一样) | --- @@ -142,6 +156,18 @@ ] ``` +`acceptance_mode` 不写就是 `"holistic"`,上面三条都省了。只有验收标准天然拆成几条互相独立条目的单一交付物才需要显式写 `"itemized"`(见 Step 2bis 的贪吃蛇例子): + +```json +[{ + "seq": 1, + "title": "实现可在浏览器直接玩的贪吃蛇游戏", + "description": "用 HTML/CSS/JS 实现一个完整的贪吃蛇小游戏。达成判据:方向键控制移动、吃到食物加分、撞墙或撞自己游戏结束、可以重新开始。", + "depends_on": [], + "acceptance_mode": "itemized" +}] +``` + 最小合法产出(goal 本身就是一件事时,**这是正确答案,不是偷懒**): ```json -- libgit2 0.21.4