From 42f9788b0ac152a6b394b9fc54e0226e4a9fb809 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 30 Jun 2026 09:08:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(executor):=20=E4=BA=BA=E5=B7=A5=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E6=84=8F=E8=A7=81=E5=96=82=E5=9B=9E=E8=BF=94=E5=B7=A5?= =?UTF-8?q?=20agent=20prompt=20(tsk=5FBJLguXuWkyq4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照搬现有 L1「上次失败原因」链路,新增平行的「人工驳回意见」注入链路: - store.lastRejectReasonOf(taskId, gate?):从 approvals 取最近一次 reject reason, 支持按闸过滤(exec/spec/plan),无 reject 返回 null - orchestrator.claimOne:按返工角色对应闸装配 task.lastRejectReason 下发 job (executor→exec / planner-spec→spec / planner-decompose→plan) - runner.lastRejectLines + buildPrompt/buildPlannerPrompt 拼接驳回段 (紧接失败段之后,2000 字截断);buildConflictPrompt 不注入 - types.Task 新增 lastRejectReason 临时装配字段(仅注入 prompt,不持久化) - test/memory-inject.test.ts:覆盖 store gate 过滤、三处注入、截断、边界、顺序 Co-Authored-By: Claude Opus 4.8 --- src/daemon/orchestrator.ts | 7 ++++- src/executor/runner.ts | 21 +++++++++++++ src/model/types.ts | 2 ++ src/store/store.ts | 17 +++++++++++ test/memory-inject.test.ts | 61 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/daemon/orchestrator.ts b/src/daemon/orchestrator.ts index 82605b3..4101221 100644 --- a/src/daemon/orchestrator.ts +++ b/src/daemon/orchestrator.ts @@ -183,10 +183,15 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps: const lastRunError = store.lastRunErrorOf(task.id); // L3 拆解背景:executor 子任务带父任务意图 + 兄弟状态(planner 跑在父任务上,不需要) const context = isPlanner ? null : store.taskContextOf(task.id); + // 人工驳回意见:返工角色 → 对应闸。executor 返工读 exec 闸; + // planner-spec 读 spec 闸;planner-decompose 读 plan 闸。 + const rejectGate = runKind === 'planner-spec' ? 'spec' + : runKind === 'planner-decompose' ? 'plan' : 'exec'; + const lastRejectReason = store.lastRejectReasonOf(task.id, rejectGate); d.writeJobSpec({ runId: run.id, - task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError, context }, + task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError, context, lastRejectReason }, project, worktreeDir: dir, branch, runKind, }); diff --git a/src/executor/runner.ts b/src/executor/runner.ts index 03e03bf..fad3539 100644 --- a/src/executor/runner.ts +++ b/src/executor/runner.ts @@ -77,6 +77,23 @@ function lastErrorLines(task: Task): string[] { ]; } +/** + * 人工驳回意见注入:返工任务带「上次被人审驳回的改进意见」时,生成一段强提醒;否则空数组。 + * 由 daemon 在 claimOne 时把 store.lastRejectReasonOf 填进 task.lastRejectReason 下发。 + */ +function lastRejectLines(task: Task): string[] { + const r = task.lastRejectReason?.trim(); + if (!r) return []; + return [ + '## 上次被人审驳回的意见(这是返工任务,必须针对性修正)', + '上一版产出被人工审核驳回,驳回意见如下。请把它当作硬性整改要求,逐条落实,不要重复同样的问题:', + '```', + r.length > 2000 ? r.slice(0, 2000) + '\n…(已截断)' : r, + '```', + '', + ]; +} + /** 声明文件范围约束(task.scopeFiles 非空时注入):改动越界文件会被硬闸拦截、任务打回。 */ function scopeConstraintLines(task: Task): string[] { const scope = task.scopeFiles?.filter((s) => s.trim()); @@ -99,6 +116,7 @@ export function buildPrompt(task: Task, project?: Project, globalRules?: string body || '(无详细说明,按标题完成)', '', ...lastErrorLines(task), + ...lastRejectLines(task), '## 执行约束(必须遵守)', '- 只在当前工作目录(git worktree)内改动文件,不得读写或修改 worktree 之外的任何文件。', ...scopeConstraintLines(task), @@ -183,11 +201,13 @@ export type PlannerFn = (task: Task, project: Project, kind: PlanKind, runId: st export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project, globalRules?: string | null): string { const head = [`# 任务:${task.title}`, `任务 ID:${task.id}`, '', ...rulesHeaderLines(project, globalRules)]; const errLines = lastErrorLines(task); + const rejectLines = lastRejectLines(task); if (kind === 'spec') { if (task.spec) head.push('## 现有方案草稿(可改进/替换)', task.spec, ''); return [ ...head, ...errLines, + ...rejectLines, '## 你的角色:方案作者(只读,不改任何文件)', '只读这个代码库,为上述任务写一份「具体改动方案」。不要编辑文件、不要执行有副作用的命令。', '## 输出(markdown 正文)', @@ -201,6 +221,7 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project return [ ...head, '', ...errLines, + ...rejectLines, '## 你的角色:任务拆解者(只读,不改任何文件)', `当前任务层级 depth=${task.depth ?? 1}(根=1),最多拆到 depth=4(剩余可拆层数 ${Math.max(0, 4 - (task.depth ?? 1))} 层)。`, '只读这个代码库,分析上述(复杂)任务,拆成 2–6 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。', diff --git a/src/model/types.ts b/src/model/types.ts index 3df9f17..be6406c 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -105,6 +105,8 @@ export interface Task { retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用) nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领 lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示) + /** 人工驳回意见:daemon 在 claimOne 时从 approvals 取最近一次 reject reason 装配进 job,仅注入 prompt,不持久化 */ + lastRejectReason?: string | null; /** L3 拆解背景:daemon 在 claimOne 时装配进 job(父任务意图 + 兄弟任务状态),仅注入 prompt,不持久化 */ context?: TaskContext | null; attachments?: Attachment[]; // 随任务提交的图片/文件(存 /tasks//attachments/) diff --git a/src/store/store.ts b/src/store/store.ts index 014339c..72b737a 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1070,6 +1070,23 @@ export class Store { return row?.error ?? null; } + /** + * 取某任务最近一次「被人审驳回」的改进意见(approvals 里 action='reject')。 + * 供把人工驳回意见喂回返工 agent 的 prompt,避免重复犯同样的问题。 + * gate 可选:传入则只取该闸的最近一次 reject(exec/spec/plan),与返工角色对齐; + * 不传则取任意闸最近一次。无 reject 返回 null。 + */ + lastRejectReasonOf(taskId: string, gate?: GateKind): string | null { + const row = (gate + ? this.db.prepare( + `SELECT reason FROM approvals WHERE task_id = ? AND action = 'reject' AND gate = ? AND reason IS NOT NULL ORDER BY at DESC LIMIT 1`, + ).get(taskId, gate) + : this.db.prepare( + `SELECT reason FROM approvals WHERE task_id = ? AND action = 'reject' AND reason IS NOT NULL ORDER BY at DESC LIMIT 1`, + ).get(taskId)) as { reason?: string | null } | undefined; + return row?.reason ?? null; + } + /** * L3 拆解背景:取某任务的父任务拆解意图(parent.plan)+ 同层兄弟任务概览(标题/状态/复杂度)。 * 无 parentId → null。仅做便宜的 DB 读,不做 git/RAG 检索。 diff --git a/test/memory-inject.test.ts b/test/memory-inject.test.ts index 5757f5b..dc63098 100644 --- a/test/memory-inject.test.ts +++ b/test/memory-inject.test.ts @@ -113,6 +113,67 @@ test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind)', () = s.close(); }); +const REJECT_SECTION = '上次被人审驳回的意见'; + +test('REJECT: 无 lastRejectReason 时三处 prompt 都不含驳回段', () => { + assert.ok(!buildPrompt(fakeTask()).includes(REJECT_SECTION)); + assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'medium' }), 'spec').includes(REJECT_SECTION)); + assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose').includes(REJECT_SECTION)); +}); + +test('REJECT: 有 lastRejectReason 时 buildPrompt/buildPlannerPrompt(spec/decompose) 都注入驳回意见原文', () => { + const reason = '错误地动了 web/ 老仪表盘,请改到 design/ui_kits/console/'; + for (const p of [ + buildPrompt(fakeTask({ lastRejectReason: reason })), + buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: reason }), 'spec'), + buildPlannerPrompt(fakeTask({ complexity: 'hard', lastRejectReason: reason }), 'decompose'), + ]) { + assert.ok(p.includes(REJECT_SECTION), '应含驳回段标题'); + assert.ok(p.includes(reason), '应含驳回意见原文'); + } +}); + +test('REJECT: buildConflictPrompt 始终不注入驳回段(守边界)', () => { + const reason = '某条驳回意见'; + assert.ok(!buildConflictPrompt(fakeTask({ lastRejectReason: reason }), ['a.ts']).includes(REJECT_SECTION)); +}); + +test('REJECT: 超长驳回意见被截断到 2000 字符', () => { + const reason = 'Y'.repeat(5000); + const p = buildPrompt(fakeTask({ lastRejectReason: reason })); + assert.ok(p.includes('已截断')); + assert.ok(!p.includes('Y'.repeat(2100))); +}); + +test('REJECT: 顺序——驳回段在角色说明之前;与失败段并存时失败段在前', () => { + // planner 中驳回段排在「你的角色」之前 + const sp = buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: 'RJ' }), 'spec'); + assert.ok(sp.indexOf(REJECT_SECTION) < sp.indexOf('你的角色'), '驳回段在角色说明之前'); + // 失败段与驳回段并存:失败在前、驳回在后 + const both = buildPrompt(fakeTask({ lastRunError: 'ERR', lastRejectReason: 'RJ' })); + assert.ok(both.indexOf(SECTION) < both.indexOf(REJECT_SECTION), '失败段在驳回段之前'); +}); + +test('lastRejectReasonOf 取最近一次 reject 的 reason,支持 gate 过滤,无 reject → null', () => { + const s = new Store(':memory:'); + const p = s.createProject({ name: 'rj', repoPath: '/tmp/rj-' + Math.random() }); + const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' }); + assert.equal(s.lastRejectReasonOf(t.id), null, '无 reject → null'); + + // 推到 exec_review 再 reject(exec reject 落 ready) + s.setOperations(t.id, 'do it'); + s.transition(t.id, 'queued'); + s.transition(t.id, 'executing'); + s.transition(t.id, 'exec_review'); + const reason = '代码动了越界文件,请收敛范围'; + s.decide(t.id, 'reject', 'alice', reason); + + assert.equal(s.lastRejectReasonOf(t.id), reason, '不带 gate → 取最近一次'); + assert.equal(s.lastRejectReasonOf(t.id, 'exec'), reason, 'exec 闸能取到'); + assert.equal(s.lastRejectReasonOf(t.id, 'plan'), null, 'plan 闸取不到该 exec reject'); + s.close(); +}); + test('buildPrompt:task.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => { const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] })); assert.match(withScope, /声明的改动文件范围/);