/** * root run 收敛 → task / goal 状态回写 + OKR 写一次 + report_to 唤醒(27-P3C 缺陷 F1 / F2)。 * * ── 为什么要有这个模块(F1)── * 批 1 落地时,`updateTask` 的全部调用方只做三件事:把 task 置 running、写 archived_at、 * 用户 PATCH。**没有任何一条路径写 completed / failed / blocked 或 finished_at。** * 于是一条正常跑完的 run 留下的 task 永远停在 `running`: * · `DELETE /:cid/goals/:goalId` 永久 409「下还有正在执行的 task」, * 而它给的自救 hint(`POST /api/graph-runs/{id}/stop`)对一条 completed 的 run 会因 * `RUN_TRANSITIONS.completed = ['ready']` 抛 InvalidTransitionError → 400。自救路径本身是死路; * · 列表里所有历史任务永远显示「执行中」—— 正是 27-P3C 开篇要修的「历史任务看不见」的镜像版本。 * * ── 为什么挂在 `graph-run-sse.transitionRun` 上而不是枚举路径(§五.1.1)── * 设计文档已经论证过:`stopRun` 与「预算耗尽 → blocked」这两条最常见的释放路径**都不走 settleRun** * (`stopRun` 的注释自己写着「不经过 settleRun」)。枚举一次漏一次,而漏掉的症状是 * 「任务静默躺着、界面上跟正常执行一模一样」。`transitionRun` 是全仓库 run 状态转换的唯一漏斗。 * * ── 为什么 OKR 要挪到 goal 层(F2)── * §四.7 禁令一让新 run 不再带 `okr_node_id`(否则一个 goal 下 N 个 task 会反复覆写同一个 OKR 节点, * 一条 failed 的尾巴能把已经 done 的节点翻掉)。但批 1 只做了「不写」的那一半:`goals.okr_node_id` * 有写侧、有展示,**零读侧** —— 调用方传了、响应回显了(假确认)、run 跑完了,OKR 节点原地不动, * 日志一行都没有。这里补上另一半:goal 收敛时按 goal 的 okr_node_id 写**一次**。 * * 全模块的副作用都由 `work-graph-db` 的两个条件更新(`settleTaskFromRun` / `settleGoalStatus`) * 把闸门,谁抢到 changes=1 谁才继续往下做 —— 并行收敛的多个 task 会同时走到这里。 */ /** * run 终态 → task 状态。 * `stopped → cancelled`:人工主动停止不是执行失败,把它记成 failed 会污染 goal 的验收判定 * (下面 settleGoal 只认「全部 completed 才 done」,两者都进 failed 分支,但 task 行上要分得清)。 * * `blocked → blocked` 保留,是因为界面必须看得见「卡住了,等人加预算」—— * 但它**不是终态**(见 `work-graph-db.TASK_TERMINAL_STATUS`):它只写 task 行,不触发 goal 收敛, * run 恢复后跑完还会被 completed 覆盖回去。 */ const RUN_TO_TASK_STATUS = { completed: 'completed', failed: 'failed', stopped: 'cancelled', blocked: 'blocked', }; /** * 一条 root run 收敛了:回写它的 task,必要时再收敛整个 goal。 * * **不 throw** 是调用方的硬要求(钩子挂在状态转换的尾巴上),但本函数只做同步 DB 写, * 异常统一由 `graph-run-sse` 那层的 try/catch 兜住 —— 在这里再吞一次会让真 bug 连日志都没有。 * * @param {object} run graph_runs 的整行(调用方已保证 parent_run_id 为空 = root run) * @param {string} to 转入的 run 状态 * @returns {{task: string, goal: string|null}|null} null = 这次没有产生任何回写(幂等 no-op) */ function settleRootRun(run, to) { const wdb = require('./work-graph-db'); const taskStatus = RUN_TO_TASK_STATUS[to]; // task_id 为空的 root run 是存量数据(goals/tasks 表出现之前建的),没有回写对象 if (!taskStatus || !run || !run.task_id) return null; if (!wdb.settleTaskFromRun(run.task_id, run.id, taskStatus)) return null; const task = wdb.getTask(run.task_id); return { task: taskStatus, goal: task && task.goal_id ? settleGoalIfAllTasksDone(task.goal_id) : null }; } /** * goal 级收敛:这个 goal 的 task 是不是都到终态了?是则定判词并做**一次**对外汇报。 * * 判词口径(§四.10 的验收本体属批 2,这里只做状态收敛): * · 全部 completed → `done` * · 有任何一条不是 completed(failed / cancelled)→ `failed` * draft 的 task 算「还没到终态」,所以批 1 那种「一次只跑一条、其余留 draft」的 goal * 会一直等到最后一条也跑完才收敛 —— 这正是想要的:需求没做完就不该判它达成了。 * * **`blocked` 同样算「还没到终态」**(第二轮复核 缺陷 2,靠 `TASK_TERMINAL_STATUS` 不含它实现): * 挂起等人加预算 ≠ 需求失败。收敛一次是**不可撤销**的 —— 它写 OKR 节点、唤醒上级 agent, * 而 goal 收敛后进 GOAL_TERMINAL_STATUS,人加完预算把 run 跑完也再改不回 done。 * 所以有 blocked 在就一条都不写:goal 停在 `active`,等它恢复后跑完再一次性收敛。 * * @returns {string|null} 本次定下的 goal 状态;null = 还没到终态,或收敛权被别人抢走 */ function settleGoalIfAllTasksDone(goalId) { const wdb = require('./work-graph-db'); const tasks = wdb.listGoalTasks(goalId); if (!tasks.length) return null; if (tasks.some((t) => !wdb.TASK_TERMINAL_STATUS.includes(t.status))) return null; const status = tasks.every((t) => t.status === 'completed') ? 'done' : 'failed'; // CAS:并行收敛的多个 task 会同时算出「全都到终态了」,只有抢到的这一个才做下面的副作用 if (!wdb.settleGoalStatus(goalId, status)) return null; const goal = wdb.getGoal(goalId); if (goal) reportGoalOutcome(goal, status, tasks); return status; } /** * goal 收敛的对外汇报 —— 粒度是 **goal**:「人提的这个需求到底算不算做到了」。 * 上级 agent 收到的 taskId 是 `G-xxx`。 * * 沿用它那个 either/or:有 `report_to` 就走 `/api/agent/task/report`(那条链路自己会更新 OKR 节点、 * 推进 Pipeline、唤醒上级),没有就直接 `OkrService.updateNode`。两条分支都会写到 OKR, * 所以这里绝不能写成「先写 OKR 再汇报」—— 那就是同一个节点被写两次。 * * **这是全系统唯一的对外汇报出口**(第二轮复核 缺陷 3)。company.js 里那条 run 级的 * `reportRunOutcome` 已经删了:批 1 的唯一形态是 1 goal : 1 task(`POST /:cid/tasks` 自动建 goal、 * `createTask` 继承 `goal.report_to`),两条汇报会先后打到同一个 `/api/agent/task/report`, * 而它第 5 步会 `setImmediate` 自调 `/api/agent/{to}/chat` —— 同一件事把上级叫醒两次、 * 白烧一轮 LLM,其中一次带的还是它从没见过的 `G-` id。留 goal 级的理由见 company.js 那段注释。 * * `from` = **交付者**,取 org graph 的末节点(`terminalAgentId`),不是 `created_by`: * `created_by` 是「谁下的单」,`POST /:cid/tasks` 里默认就是字符串 `'human'`, * 而下游拿它去 `readStateFile('human','todo.md')` / `findActiveRunStep('human')` 全部落空 —— * 不报错,只是汇报里的执行者信息整段是假的。图为空时退回 `report_to`(自己汇报给自己, * 至少是个真实存在的 agent),绝不退回 `created_by`。 */ function reportGoalOutcome(goal, status, tasks) { const bad = tasks.filter((t) => t.status !== 'completed'); const summary = ( `goal ${goal.id}「${goal.title}」收敛为 ${status}:共 ${tasks.length} 个 task,` + (bad.length ? `未完成 ${bad.map((t) => `${t.id}=${t.status}`).join('、')}` : '全部完成') ).slice(0, 500); // okr_node_id 形如 O-001.1.1,第一段就是 objectiveId(同 OkrService.parseNodeId 口径) const okrNodeId = goal.okr_node_id || null; const objectiveId = okrNodeId ? String(okrNodeId).split('.')[0] : null; if (goal.report_to) { const from = require('./org-graph-service').terminalAgentId(goal.company_id) || goal.report_to; const PORT = require('../config').port || 3000; fetch(`http://127.0.0.1:${PORT}/api/agent/task/report`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: goal.id, from, to: goal.report_to, status, summary, objectiveId, nodeId: okrNodeId, }), }) // fetch 不因 4xx/5xx reject,不显式看 res.ok 会把地址写错这类问题完全静默吞掉 .then((r) => { if (!r.ok) console.error(`[TaskSettle] goal 汇报失败: HTTP ${r.status} goal=${goal.id}`); }) .catch((e) => console.error(`[TaskSettle] goal 汇报失败(网络): ${e.message}`)); return; } if (objectiveId && okrNodeId) { const { OkrService } = require('./okr-service'); new OkrService(goal.company_id).updateNode(objectiveId, okrNodeId, { status, summary }); console.log(`[TaskSettle] 🎯 ${goal.company_id} OKR ${okrNodeId} → ${status}(goal ${goal.id})`); } } module.exports = { settleRootRun, settleGoalIfAllTasksDone, RUN_TO_TASK_STATUS, };