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:
+13
-1
@@ -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;
|
||||
|
||||
@@ -208,7 +208,13 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project
|
||||
'- complexity:easy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)',
|
||||
'- priority:0=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 表格总览:',
|
||||
|
||||
@@ -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 待人工重评估。
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user