feat(executor): 人工驳回意见喂回返工 agent prompt (tsk_BJLguXuWkyq4)

照搬现有 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-30 09:08:26 +08:00
parent e115e065e6
commit 42f9788b0a
5 changed files with 107 additions and 1 deletions
+61
View File
@@ -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 再 rejectexec 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('buildPrompttask.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => {
const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] }));
assert.match(withScope, /声明的改动文件范围/);