subtask-planning.js 14.2 KB
/**
 * subtask-planning —— 把一条 task 拆成"第一步/第二步/第三步"的步骤分解(46 号文档 Phase 1)
 *
 * 与 `task-planning`(goal → task)的关键差异:这是**建议性**产出,不落库、不创建 task 行、
 * 没有重试轮次、没有写入前校验会打回 agent —— 唯一的失败模式是"没用",不是"格式错"。
 * 所以本模块单轮调用、宽松解析:挑得出多少条 `steps` 就要多少条,个别字段缺失就地丢弃那一条,
 * 不因为一条不合法就整批作废(`system/agents/subtask-planning/agent.md` 硬约束 8 附近的
 * 「你的产出是建议,不是指令」)。
 *
 * 触发方式:`enqueueTaskRun`(`server/routes/company.js`)用 `setImmediate` 异步触发,
 * 照抄 `runGoalSplit` 的现成模式,不等它、不阻塞排队。**`triggerSubtaskPlanning` 是本模块
 * 唯一允许抛出以外的公开入口,内部任何一步失败都只 `console.warn`,绝不 throw** ——
 * 33/44/47 号文档反复验证过的教训:软数据不能变成硬闸门,这里同理,招进来的这道钩子
 * 更不能变成"入队本身"的新失败点。
 *
 * 产物写共享 `artifacts/` 区、登记进 `graph_files` 索引(`kind='subtask_plan'`,已纳入
 * `graph-engine.TASK_SHARED_KINDS`)——这样任意节点(org-planner 展开出的子图里的
 * brd/prd/sdd 各节点、乃至后续 org-coder)的「上游输入」提示词段落都会自动带上它,
 * 不用为 org-planner 单独写一段 prompt-builder 注入逻辑(46 号文档「与 org-planner 的
 * 衔接点」一节最初设想的软注入方案,改走这条更省事、且是引擎已验证在用的现成机制)。
 */

const path = require('path');
const fs = require('fs');

const REPO_ROOT = path.resolve(__dirname, '../..');

/** 全局唯一的一份步骤分解本体(`system/agents/subtask-planning/`) */
const PLANNING_AGENT_ID = 'subtask-planning';

/** 一条 task 最多切几步。上限是体检指标,不是配额——agent.md 讲得很清楚 */
const MAX_STEPS = 12;

// ────────────────────────────────────────────────────────────────
// 注入点(仿 task-planning.setPlanTasks)
// ────────────────────────────────────────────────────────────────

let planSubtasksImpl = defaultPlanSubtasks;
let isInjected = false;

/** 替换规划实现(传 null 回默认实现)。测试用,避免真起 CLI */
function setPlanSubtasks(fn) {
  planSubtasksImpl = fn || defaultPlanSubtasks;
  isInjected = !!fn;
}

/**
 * 当前生效的规划实现。调用方一律走这里。
 *
 * **踩过的坑,写在这里免得下次重犯**:`task-planning`/`workgraph-planning` 那一整套
 * 「注入点」设计的前提是——调用方要触发真实实现,得先显式 `setPlanXxx(fn)`。这个前提
 * 对 subtask-planning 不成立:本模块的触发点在 `enqueueTaskRun`(`server/routes/company.js`
 * 的 `/goals/:goalId/dispatch` 与 `/tasks/:taskId/enqueue` 两处),而**全仓库有大量单测
 * 会走到这两条路径**——它们测的是排队/预算/状态流转这些跟 subtask-planning 毫无关系的东西,
 * 从没想过要为一个刚加进来的旁支功能显式注入桩。第一次实现时忽略了这一点,`npx vitest run
 * server/tests/unit` 一次性真开了 44 个 `claude` CLI 子进程挂在后台(`ps aux` 现场抓到,
 * 已手动 kill 干净)——正是 `docs/done/claude-cli-session-leak.md` 那份手册想防的事,
 * 而且是本模块自己新引入的一个源头。
 *
 * 修法:`process.env.VITEST`(vitest 对所有测试进程都会自动置位,不是我们自己发明的判据)
 * 为真、且没有人显式调用过 `setPlanSubtasks` 时,直接跳过真实调用——不是「默认桩返回假数据」
 * (那会让没预料到这条路径的测试断言出诡异内容),是「什么都不做」,等价于 47/44 号文档
 * 反复用到的软失败路径(0 条 steps,不写文件,只留一条日志)。真想在测试里验证 subtask-planning
 * 的行为,显式 `setPlanSubtasks(fn)` 就行——那条路径不受这层保护影响。
 */
function planSubtasks(ctx) {
  if (process.env.VITEST && !isInjected) {
    console.log('[SubtaskPlanning] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)');
    return Promise.resolve({ steps: [], tokens: 0 });
  }
  return planSubtasksImpl(ctx);
}

// ────────────────────────────────────────────────────────────────
// 默认实现
// ────────────────────────────────────────────────────────────────

/**
 * 调 subtask-planning 拆一次 task 的步骤。单轮调用,没有重试。
 *
 * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行
 * @returns {Promise<{steps: object[], tokens: number}>}
 *          steps 形如 `[{seq, title, why, depends_on_hint: [seq…]}]`,seq 恒为 1..N 连续(重新编号过)
 */
async function defaultPlanSubtasks(ctx) {
  const AgentManager = require('../src/core/agent-manager');
  const manager = new AgentManager({}, REPO_ROOT);
  const agentName = resolvePlanningAgentName(ctx.companyId);

  let tokens = 0;
  const onAcpEvent = (ev) => {
    if (!ev || ev.method !== 'session/result') return;
    const u = (ev.params && ev.params.usage) || null;
    if (u) tokens += (u.input || 0) + (u.output || 0);
  };

  const prompt = buildSubtaskPlanningPrompt(ctx);
  const res = await manager.callAgent(agentName, prompt, {
    onAcpEvent,
    metadata: {
      // taskId 决定 CLI 的 cwd。这里就是真的那条 task 的 id(不像 task-planning 借用 goalId
      // 顶替——subtask-planning 天然运行在一条具体 task 之下,不用借)。
      taskId: ctx.task.id,
      layer: 'subtask-plan',
      goalId: ctx.task.goal_id,
      companyId: ctx.companyId,
    },
  });

  const rawSteps = extractPlannedSteps(res && res.output);
  return { steps: normalizeSteps(rawSteps), tokens };
}

/**
 * 本体的寻址名。与 `task-planning.resolvePlanningAgentName` 同一个模式:
 * 按需幂等雇一次,判据查 `company_agents` 表(不是目录)。
 */
function resolvePlanningAgentName(companyId) {
  if (!companyId) return PLANNING_AGENT_ID;
  const { CompanyService, listCompanyAgents } = require('./company-service');
  const isHired = () => listCompanyAgents(companyId).some((r) => r.role_name === PLANNING_AGENT_ID);
  if (!isHired()) {
    try {
      new CompanyService().hire(companyId, PLANNING_AGENT_ID, {});
    } catch (e) {
      console.warn(`[SubtaskPlanning] 按需雇佣 ${PLANNING_AGENT_ID} 失败(${companyId}): ${e.message}`);
    }
  }
  return isHired() ? `${companyId}:${PLANNING_AGENT_ID}` : PLANNING_AGENT_ID;
}

// ────────────────────────────────────────────────────────────────
// 提示词
// ────────────────────────────────────────────────────────────────

function buildSubtaskPlanningPrompt(ctx) {
  const t = ctx.task || {};
  return [
    `你正在为公司 **${ctx.companyId}** 的一条 task 想"怎么做"的步骤分解。`,
    '',
    '## task 描述(要拆步骤的对象,task-planning 已经切好的一条边界)',
    t.description || '(调用方没给任务描述)',
    '',
    `- taskId:${t.id}(只读,不要写进你的产出)`,
    `- 标题:${t.title || '(无)'}`,
    '',
    '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)',
    '```json',
    '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}]}',
    '```',
    `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`,
    '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,'
      + '不是硬依赖,没有任何机制会强制执行它。',
    '- 只输出这三个字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。',
    '',
    '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,'
      + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。',
  ].join('\n');
}

// ────────────────────────────────────────────────────────────────
// 产出解析(宽松:单条不合法就丢那一条,不作废整批)
// ────────────────────────────────────────────────────────────────

/** 从回复里取出步骤数组。取法与 task-planning.extractPlannedTasks 同思路(围栏优先、从后往前取) */
function extractPlannedSteps(output) {
  const text = String(output || '');
  if (!text.trim()) return [];

  const candidates = jsonCandidates(text);
  for (let i = candidates.length - 1; i >= 0; i -= 1) {
    const doc = safeJsonParse(candidates[i]);
    if (doc && Array.isArray(doc.steps)) return doc.steps;
    if (Array.isArray(doc)) return doc; // 容错:直接回了裸数组也认
  }
  return [];
}

function jsonCandidates(text) {
  const out = [];
  const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g;
  let m;
  while ((m = fence.exec(text))) out.push(m[1]);
  out.push(...balanced(text, '[', ']'), ...balanced(text, '{', '}'));
  return out;
}

function balanced(text, open, close) {
  const out = [];
  let depth = 0;
  let start = -1;
  for (let i = 0; i < text.length; i += 1) {
    const c = text[i];
    if (c === open) {
      if (depth === 0) start = i;
      depth += 1;
    } else if (c === close && depth > 0) {
      depth -= 1;
      if (depth === 0) out.push(text.slice(start, i + 1));
    }
  }
  return out;
}

function safeJsonParse(s) {
  try {
    return JSON.parse(s);
  } catch {
    return null;
  }
}

/**
 * 宽松归一化:丢掉不合法的单条(没有 title 的、不是对象的),保留下来的重新编号
 * (1..N 连续,不留空洞——跳过的条目不该在 seq 里留坑,下游读起来会以为漏了一条)。
 * `depends_on_hint` 里指向被丢弃条目 / 越界 / 自引用的数字直接过滤掉,不报错。
 */
function normalizeSteps(raw) {
  if (!Array.isArray(raw) || !raw.length) return [];
  const capped = raw.slice(0, MAX_STEPS);

  const out = [];
  capped.forEach((item, i) => {
    if (!item || typeof item !== 'object' || Array.isArray(item)) return;
    const title = String(item.title == null ? '' : item.title).trim();
    if (!title) return;
    out.push({ _origIndex: i + 1, title, item });
  });

  const keptOrigSet = new Set(out.map((o) => o._origIndex));
  return out.map((o, idx) => {
    const rawDeps = o.item.depends_on_hint !== undefined ? o.item.depends_on_hint : o.item.dependsOnHint;
    const deps = [];
    for (const d of Array.isArray(rawDeps) ? rawDeps : []) {
      const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN;
      if (Number.isInteger(n) && keptOrigSet.has(n) && n !== o._origIndex && !deps.includes(n)) deps.push(n);
    }
    return {
      seq: idx + 1,
      title: o.title.slice(0, 200),
      why: String(o.item.why == null ? '' : o.item.why).trim().slice(0, 1000),
      depends_on_hint: deps,
    };
  });
}

// ────────────────────────────────────────────────────────────────
// 触发入口
// ────────────────────────────────────────────────────────────────

/**
 * 触发一次 subtask-planning 并把结果写进这条 task 所属 run 的共享 artifacts 区
 * + 登记进 `graph_files` 索引。**纯 best-effort,绝不 throw**——见文件头注释。
 *
 * @param {{run: object, task: object}} ctx run 是 graph_runs 表整行(createRun 的返回值即可,
 *   只需要 company_id / root_run_id / id),task 是 tasks 表整行
 */
async function triggerSubtaskPlanning({ run, task }) {
  try {
    if (!run || !task) return;
    const { steps, tokens } = await planSubtasks({ companyId: run.company_id, task });
    if (!steps.length) {
      console.log(`[SubtaskPlanning] task ${task.id} 没有可用的步骤分解(0 条),跳过写入`);
      return;
    }

    const { runRootDir } = require('./graph-engine');
    const { companyRootDir } = require('./company-paths');

    const artifactsDir = path.join(runRootDir(run), 'artifacts');
    fs.mkdirSync(artifactsDir, { recursive: true });
    const file = path.join(artifactsDir, 'subtask-plan.json');
    fs.writeFileSync(
      file,
      JSON.stringify({ steps, generated_by: PLANNING_AGENT_ID, task_id: task.id }, null, 2),
      'utf-8'
    );

    const wdb = require('./work-graph-db');
    wdb.recordFile({
      companyId: run.company_id,
      taskId: task.id,
      relPath: path.relative(companyRootDir(run.company_id), file),
      kind: 'subtask_plan',
      runId: run.id,
      nodeRunId: null,
    });

    console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens)`);
  } catch (err) {
    console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`);
  }
}

module.exports = {
  PLANNING_AGENT_ID,
  MAX_STEPS,
  planSubtasks,
  setPlanSubtasks,
  triggerSubtaskPlanning,
  // 下面三个导出是给测试用的
  extractPlannedSteps,
  normalizeSteps,
  buildSubtaskPlanningPrompt,
};