subtask-planning.js
14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
/**
* 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,
};