task-settle.js 8.97 KB
/**
 * 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,
};