feat(retry+decompose): 范围型驳回不重试 + 拆解 prompt 补 scope 铁律

- ingest:复审 reject 时若任务有 scopeFiles 且理由命中越界/范围外/声明范围等标记 → 判为范围型驳回,调 store.failScopeReject 直接转 needs_attention(清退避、不进重试链),因 executor 碰不了范围外文件、重试必然同样被驳回;- store.failScopeReject:补记 failed run 承载原因 + 直转 needs_attention(带单测);- runner 拆解 prompt:加「files 范围铁律」——覆盖全跨层足迹 / 后端能力别塞前端范围 / 宁空勿窄 / 全或空。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 23:32:10 +08:00
parent 1c62059424
commit 5a609a8675
4 changed files with 57 additions and 2 deletions
+13 -1
View File
@@ -216,7 +216,19 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
transcriptRef: rec.executor.transcriptRef ?? undefined,
claudeSessionId: rec.executor.sessionId ?? undefined,
});
// 退回重执行(带复审意见)
// 范围型驳回检测:任务有非空 scopeFiles 且复审拒因「需求超出声明范围/越界/范围外」——重试 executor 必然无解
// (它碰不了范围外文件,只会同样被驳回),故停止重试、直接转 needs_attention,待人工放宽 scopeFiles 或回 planner 重拆。
const task = store.getTask(taskId);
const scoped = !!(task?.scopeFiles && task.scopeFiles.length);
const fullReview = `${rec.code.summary || ''}\n${rec.security.summary || ''}`;
const scopeReject = scoped
&& /out of scope|越界|范围外|声明范围|文件闸|超出.{0,6}范围|scope[^\n]{0,40}(undone|outside|out of)/i.test(fullReview);
if (scopeReject) {
store.failScopeReject(taskId, `复审因范围不足驳回(停止重试,待放宽 scopeFiles 或重新拆解):${reason}`);
log.info(`task=${taskId} run=${runId} 范围型驳回 → needs_attention(停止重试)`);
return;
}
// 普通复审驳回 → 退回重执行(带复审意见)
store.failTaskAttempt(taskId, null, `自动复审拒绝,退回重执行:${reason}`);
log.info(`task=${taskId} run=${runId} 复审拒绝 → 退回重执行`);
return;
+7 -1
View File
@@ -208,7 +208,13 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project
'- complexityeasy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)',
'- priority0=P0最紧急 / 1=P1中 / 2=P2最低',
'- deps:依赖本列表中其他子任务的 0-based 序号(无依赖填 []',
'- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。尽量精确——执行时改动越界文件会被硬闸拦截。无法确定就留空数组(不限范围)。',
'- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。执行时改动越界文件会被硬闸拦截,所以这个范围是给 executor 的【硬约束】,不是建议。',
'',
'### files 范围铁律(设窄会让任务做不完、卡死成 needs_attention,务必遵守)',
'- **必须覆盖该子任务全部跨层足迹**:把它真正要碰的【所有层】都列进去——前端(design/、app/)、后端(src/)、接线、测试。漏掉任一层,executor 就碰不了那层、只能交半成品被驳回。',
'- **隐含后端的能力别塞进纯前端范围**:删除 / 去重 / 持久化 / 列表查询 等通常需要后端端点(src/api、src/store)。若一个子任务含这类能力,files 必须同时包含后端路径,否则就【拆成两个子任务】(如「后端 CRUD 端点」+「前端 UI」,用 deps 串联)。',
'- **宁可留空,不可设窄**:拿不准完整范围时,**留空数组**(= 不限、不触发越界闸)远好于猜一个偏窄的范围——窄而错会直接卡死任务,空只是放权。',
'- 一句话:要么把范围列【全】,要么【留空】,绝不要列【一半】。',
'',
'## 输出格式(重要,必须严格遵守)',
'**第一步**:先输出一个 Markdown 表格总览:',
+18
View File
@@ -834,6 +834,24 @@ export class Store {
return this.getTask(taskId)!;
}
/**
* 范围型驳回:复审因「任务需求超出其声明范围(scopeFiles)」而拒——重试 executor 必然无解(它碰不了范围外文件,
* 只会同样被驳回,盲目重试到 needs_attention 纯属浪费)。故不进重试链:补记一条 failed executor run 承载原因
* (供 UI lastRunError 兜底显示),直接转 needs_attention,待人工放宽 scopeFiles 或回 planner 重新拆解。
*/
failScopeReject(taskId: string, reason: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const r = this.startRun(taskId, 'executor');
this.finishRun(r.id, 'failed', { error: reason });
if ((this.getTaskRow(taskId)!.status as TaskStatus) !== 'failed') {
this.transition(taskId, 'failed', { by: 'failScopeReject', error: reason });
}
this.setNextEligibleAt(taskId, null);
this.transition(taskId, 'needs_attention', { by: 'failScopeReject', reason });
return this.getTask(taskId)!;
}
/**
* 通用规则13 执行前分歧重评估:sync main 发现 defaultBranch 改动与任务声明范围(scopeFiles)重叠,
* 原方案可能过时 → 不重试、不计失败次数,直接把任务转 needs_attention 待人工重评估。
+19
View File
@@ -163,6 +163,25 @@ test('markReeval(规则13):executing → needs_attention,收尾 run 为
s.close();
});
test('failScopeReject(范围型驳回):直接 needs_attention、不进重试链、清退避,记一条 failed run 承载原因', () => {
const s = freshStore();
const p = s.createProject({ name: 'scoperej', repoPath: '/tmp/scoperej-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['design/**'] });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
const after = s.failScopeReject(t.id, '复审因范围不足驳回(停止重试):Scope A entirely undone');
assert.equal(after.status, 'needs_attention', '范围型驳回应直接转 needs_attention(不重试)');
assert.equal(after.nextEligibleAt, null, '应清退避、不进重试链');
// 补记一条 failed executor run 承载原因(供 UI lastRunError 兜底)
const failed = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed');
assert.equal(failed.length, 1, '应补记一条 failed executor run');
assert.match(failed[0].error ?? '', /范围不足/);
s.close();
});
test('reconcileInterrupted:执行中被 cancel 的任务仍挂 started run → 仅收尾不崩(绝不非法 cancelled→failed', () => {
const s = freshStore();
const p = s.createProject({ name: 'recon', repoPath: '/tmp/recon-' + Math.random() });