diff --git a/src/daemon/ingest.ts b/src/daemon/ingest.ts index 355a778..4cc3e10 100644 --- a/src/daemon/ingest.ts +++ b/src/daemon/ingest.ts @@ -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; diff --git a/src/executor/runner.ts b/src/executor/runner.ts index 723f941..03e03bf 100644 --- a/src/executor/runner.ts +++ b/src/executor/runner.ts @@ -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 表格总览:', diff --git a/src/store/store.ts b/src/store/store.ts index 1300f96..93a84b2 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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 待人工重评估。 diff --git a/test/store.test.ts b/test/store.test.ts index b6b6ed4..7bd7176 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -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() });