feat(agent): L3 记忆注入——子任务带父任务拆解意图 + 兄弟状态
- types.TaskContext + Task.context(daemon 装配、不持久化) - store.taskContextOf(taskId):父 plan + 同层兄弟概览(标题/状态/复杂度,排除自身) - orchestrator.claimOne:executor 子任务填充 context 下发(planner 跑在父任务上不需要) - runner.buildPrompt 注入「## 拆解背景」段(顺序:规范→背景→任务内容) - 仅 DB 读,不做 git/RAG 检索(成本控制) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -176,10 +176,12 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
|
|||||||
|
|
||||||
// L1 记忆注入:重试任务带上「上次失败原因」,worker 端拼进 prompt(见 runner.buildPrompt*)
|
// L1 记忆注入:重试任务带上「上次失败原因」,worker 端拼进 prompt(见 runner.buildPrompt*)
|
||||||
const lastRunError = store.lastRunErrorOf(task.id);
|
const lastRunError = store.lastRunErrorOf(task.id);
|
||||||
|
// L3 拆解背景:executor 子任务带父任务意图 + 兄弟状态(planner 跑在父任务上,不需要)
|
||||||
|
const context = isPlanner ? null : store.taskContextOf(task.id);
|
||||||
|
|
||||||
d.writeJobSpec({
|
d.writeJobSpec({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError },
|
task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError, context },
|
||||||
project, worktreeDir: dir, branch, runKind,
|
project, worktreeDir: dir, branch, runKind,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ export function rulesHeaderLines(project?: Project, globalRules?: string | null)
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L3 拆解背景注入:若任务带 context(父任务拆解意图 + 兄弟任务状态),生成背景段;否则空数组。
|
||||||
|
* 由 daemon 在 claimOne 时把 store.taskContextOf 填进 task.context 下发(仅 executor 子任务)。
|
||||||
|
*/
|
||||||
|
function contextLines(task: Task): string[] {
|
||||||
|
const ctx = task.context;
|
||||||
|
if (!ctx) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const plan = ctx.parentPlan?.trim();
|
||||||
|
const sibs = ctx.siblings ?? [];
|
||||||
|
if (!plan && sibs.length === 0) return [];
|
||||||
|
out.push('## 拆解背景(你是某个大任务拆出的子任务)');
|
||||||
|
if (plan) out.push('父任务的拆解意图:', plan, '');
|
||||||
|
if (sibs.length) {
|
||||||
|
out.push('同批兄弟子任务(注意衔接,不要重复或冲突):');
|
||||||
|
for (const s of sibs) out.push(`- 「${s.title}」(${s.complexity},状态:${s.status})`);
|
||||||
|
out.push('');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* L1 记忆注入:若任务带「上次失败原因」(重试任务),生成一段提醒;否则空数组。
|
* L1 记忆注入:若任务带「上次失败原因」(重试任务),生成一段提醒;否则空数组。
|
||||||
* 由 daemon 在 claimOne 时把 store.lastRunErrorOf 填进 task.lastRunError 下发(见 orchestrator)。
|
* 由 daemon 在 claimOne 时把 store.lastRunErrorOf 填进 task.lastRunError 下发(见 orchestrator)。
|
||||||
@@ -64,6 +85,7 @@ export function buildPrompt(task: Task, project?: Project, globalRules?: string
|
|||||||
`任务 ID:${task.id}`,
|
`任务 ID:${task.id}`,
|
||||||
'',
|
'',
|
||||||
...rulesHeaderLines(project, globalRules),
|
...rulesHeaderLines(project, globalRules),
|
||||||
|
...contextLines(task),
|
||||||
'## 任务内容',
|
'## 任务内容',
|
||||||
body || '(无详细说明,按标题完成)',
|
body || '(无详细说明,按标题完成)',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export interface TaskResult {
|
|||||||
mergeTaskId: string | null; // 自动合并失败时建的最高优先级补救任务 id(幂等标记)
|
mergeTaskId: string | null; // 自动合并失败时建的最高优先级补救任务 id(幂等标记)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** L3 拆解背景:父任务拆解意图 + 同层兄弟任务概览(daemon 装配,注入子任务执行 prompt) */
|
||||||
|
export interface TaskContext {
|
||||||
|
parentPlan?: string | null;
|
||||||
|
siblings?: Array<{ title: string; status: string; complexity: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: Id;
|
id: Id;
|
||||||
projectId: Id;
|
projectId: Id;
|
||||||
@@ -85,6 +91,8 @@ export interface Task {
|
|||||||
retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用)
|
retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用)
|
||||||
nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领
|
nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领
|
||||||
lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示)
|
lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示)
|
||||||
|
/** L3 拆解背景:daemon 在 claimOne 时装配进 job(父任务意图 + 兄弟任务状态),仅注入 prompt,不持久化 */
|
||||||
|
context?: TaskContext | null;
|
||||||
attachments?: Attachment[]; // 随任务提交的图片/文件(存 <data>/tasks/<id>/attachments/)
|
attachments?: Attachment[]; // 随任务提交的图片/文件(存 <data>/tasks/<id>/attachments/)
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -887,6 +887,23 @@ export class Store {
|
|||||||
return row?.error ?? null;
|
return row?.error ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L3 拆解背景:取某任务的父任务拆解意图(parent.plan)+ 同层兄弟任务概览(标题/状态/复杂度)。
|
||||||
|
* 无 parentId → null。仅做便宜的 DB 读,不做 git/RAG 检索。
|
||||||
|
*/
|
||||||
|
taskContextOf(taskId: string): import('../model/types.js').TaskContext | null {
|
||||||
|
const row = this.getTaskRow(taskId);
|
||||||
|
if (!row || !row.parent_id) return null;
|
||||||
|
const parent = this.db.prepare(`SELECT plan FROM tasks WHERE id = ?`).get(row.parent_id) as { plan?: string | null } | undefined;
|
||||||
|
const sibs = this.db.prepare(
|
||||||
|
`SELECT title, status, complexity FROM tasks WHERE parent_id = ? AND id != ? ORDER BY created_at`,
|
||||||
|
).all(row.parent_id, taskId) as Array<{ title: string; status: string; complexity: string }>;
|
||||||
|
return {
|
||||||
|
parentPlan: parent?.plan ?? null,
|
||||||
|
siblings: sibs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 列出所有处于审批闸状态(plan_review/spec_review/exec_review)的任务。 */
|
/** 列出所有处于审批闸状态(plan_review/spec_review/exec_review)的任务。 */
|
||||||
pendingApprovals(projectId?: string): Task[] {
|
pendingApprovals(projectId?: string): Task[] {
|
||||||
// 包含 needs_attention:让审核区展示失败任务供人工确认/重排
|
// 包含 needs_attention:让审核区展示失败任务供人工确认/重排
|
||||||
|
|||||||
@@ -69,6 +69,35 @@ test('L4: globalRules 注入且排在项目规范之前', () => {
|
|||||||
assert.ok(p.indexOf('GLOBAL_RULE') < p.indexOf('任务内容'));
|
assert.ok(p.indexOf('GLOBAL_RULE') < p.indexOf('任务内容'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('L3: context 注入父任务意图 + 兄弟状态,无 context 不注入', () => {
|
||||||
|
const t = fakeTask({
|
||||||
|
context: {
|
||||||
|
parentPlan: '把登录拆成前后端',
|
||||||
|
siblings: [{ title: '前端表单', status: 'done', complexity: 'easy' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const p = buildPrompt(t);
|
||||||
|
assert.ok(p.includes('拆解背景'));
|
||||||
|
assert.ok(p.includes('把登录拆成前后端'));
|
||||||
|
assert.ok(p.includes('前端表单'));
|
||||||
|
assert.ok(p.indexOf('拆解背景') < p.indexOf('任务内容'), '背景在任务内容之前');
|
||||||
|
assert.ok(!buildPrompt(fakeTask()).includes('拆解背景'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('taskContextOf 取父 plan + 兄弟概览(排除自身)', () => {
|
||||||
|
const s = new Store(':memory:');
|
||||||
|
const pj = s.createProject({ name: 'l3', repoPath: '/tmp/l3-' + Math.random() });
|
||||||
|
const parent = s.createTask({ projectId: pj.id, title: '大', complexity: 'hard' });
|
||||||
|
s.setPlan(parent.id, '拆成 a 和 b');
|
||||||
|
const a = s.createTask({ projectId: pj.id, parentId: parent.id, title: 'a', complexity: 'easy' });
|
||||||
|
s.createTask({ projectId: pj.id, parentId: parent.id, title: 'b', complexity: 'easy' });
|
||||||
|
assert.equal(s.taskContextOf(parent.id), null, '根任务无 parent → null');
|
||||||
|
const ctxA = s.taskContextOf(a.id)!;
|
||||||
|
assert.equal(ctxA.parentPlan, '拆成 a 和 b');
|
||||||
|
assert.deepEqual(ctxA.siblings!.map((x) => x.title), ['b'], '兄弟里不含自身 a');
|
||||||
|
s.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind)', () => {
|
test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind)', () => {
|
||||||
const s = new Store(':memory:');
|
const s = new Store(':memory:');
|
||||||
const p = s.createProject({ name: 'l1', repoPath: '/tmp/l1-' + Math.random() });
|
const p = s.createProject({ name: 'l1', repoPath: '/tmp/l1-' + Math.random() });
|
||||||
|
|||||||
Reference in New Issue
Block a user