diff --git a/src/executor/models.ts b/src/executor/models.ts index 656964e..693d4c9 100644 --- a/src/executor/models.ts +++ b/src/executor/models.ts @@ -76,10 +76,9 @@ export function isModelError(msg: string): boolean { return /not[_\s-]?found|invalid|permission|forbidden|unauthorized|unavailable|unknown|unsupported|does not exist|no access|404|403/i.test(msg); } -/** 回退链里取第一个与失败模型不同的;链上全相同(不可能两项都等)则 null */ +/** 回退链里取失败模型的下一档(循环);未在链上则取首档。 */ export function pickFallbackModel(failedModel: string): string | null { - for (const m of MODEL_FALLBACK_CHAIN) { - if (m !== failedModel) return m; - } - return null; + const idx = MODEL_FALLBACK_CHAIN.indexOf(failedModel as typeof MODEL_FALLBACK_CHAIN[number]); + if (idx === -1) return MODEL_FALLBACK_CHAIN[0]; + return MODEL_FALLBACK_CHAIN[(idx + 1) % MODEL_FALLBACK_CHAIN.length]; } diff --git a/src/executor/pipeline.ts b/src/executor/pipeline.ts index 06c99e3..a8f8920 100644 --- a/src/executor/pipeline.ts +++ b/src/executor/pipeline.ts @@ -29,6 +29,27 @@ export interface PipelineDeps { reviewSecurity: ReviewerFn; // 安全审计(daemon 落成 kind=security 的 run) runPlanner: PlannerFn; // planner(拆解 Hard / 写方案 Medium,只读跑 CC) runConflict: typeof runConflict; // 解冲突(conflict run) + /** 执行前同步 defaultBranch:返回 null=成功,string=错误信息(已 abort)。可测试 mock。 */ + syncMain?: (dir: string, defaultBranch: string) => Promise; +} + +/** 执行前同步 defaultBranch 默认实现:fetch→merge origin/;失败再试本地 。 */ +async function defaultSyncMain(dir: string, defaultBranch: string): Promise { + try { + await git(dir, ['fetch', 'origin', defaultBranch]); + } catch { /* 无远端时跳过 fetch,直接用本地 */ } + try { + await git(dir, ['-c', 'user.name=maestro', '-c', 'user.email=maestro@local', 'merge', '--no-edit', `origin/${defaultBranch}`]); + return null; + } catch { + try { + await git(dir, ['-c', 'user.name=maestro', '-c', 'user.email=maestro@local', 'merge', '--no-edit', defaultBranch]); + return null; + } catch (e) { + await git(dir, ['merge', '--abort']).catch(() => undefined); + return (e as Error).message; + } + } } /** 真实依赖(worker 生产用)。 */ @@ -90,28 +111,11 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e const wt = await deps.createWorktree(job.project.repoPath, job.task.id, job.project.defaultBranch); // sync main:把 defaultBranch 最新代码 merge 进 worktree(排队期间 main 可能已前移) - try { - await git(wt.dir, ['fetch', 'origin', job.project.defaultBranch]); - } catch { /* 无远端时跳过 fetch,直接用本地 */ } - try { - await git(wt.dir, [ - '-c', 'user.name=maestro', '-c', 'user.email=maestro@local', - 'merge', '--no-edit', `origin/${job.project.defaultBranch}`, - ]); - } catch { - // 尝试用本地分支 - try { - await git(wt.dir, [ - '-c', 'user.name=maestro', '-c', 'user.email=maestro@local', - 'merge', '--no-edit', job.project.defaultBranch, - ]); - } catch (e) { - // sync main 失败(冲突或其他)→ abort 并报失败,让 daemon 转 needs_attention - await git(wt.dir, ['merge', '--abort']).catch(() => undefined); - emit({ type: 'failed', error: `执行前同步 ${job.project.defaultBranch} 失败,建议重新评估方案:${(e as Error).message}`, transcriptRef: null, sessionId: null }); - emit({ type: 'done' }); - return; - } + const syncErr = await (deps.syncMain ?? defaultSyncMain)(wt.dir, job.project.defaultBranch); + if (syncErr !== null) { + emit({ type: 'failed', error: `执行前同步 ${job.project.defaultBranch} 失败,建议重新评估方案:${syncErr}`, transcriptRef: null, sessionId: null }); + emit({ type: 'done' }); + return; } // 2. 执行 diff --git a/test/ingest.test.ts b/test/ingest.test.ts index 265881d..a9a03f4 100644 --- a/test/ingest.test.ts +++ b/test/ingest.test.ts @@ -105,7 +105,7 @@ test('ingest result:task→exec_review,setResult 四字段正确,reviewer/ }); }); -test('ingest result:任一 verdict=reject 也照常落进 result(裁决归用户)', () => { +test('ingest result:任一 verdict=reject → 硬闸退回重执行(不进 exec_review)', () => { withTmpDataDir(() => { const { store, taskId, runId, dir, branch } = setupExecuting(); appendOutbox(runId, { @@ -115,12 +115,12 @@ test('ingest result:任一 verdict=reject 也照常落进 result(裁决归 } as OutboxPayload); ingestRun(store, noopLog, runId); - const done = store.getTask(taskId)!; - assert.equal(done.status, 'exec_review'); - assert.equal(done.result!.verdict, 'reject'); - assert.equal(done.result!.summary, '发现问题'); - assert.equal(done.result!.securityVerdict, 'reject'); - assert.equal(done.result!.securitySummary, '发现密钥泄露'); + const t = store.getTask(taskId)!; + // verdict=reject 触发硬闸:退回重执行(queued),不进 exec_review + assert.ok(t.status === 'queued' || t.status === 'failed' || t.status === 'needs_attention', + `expected task to be re-queued or failed after reject verdict, got ${t.status}`); + // 不应进入 exec_review + assert.notEqual(t.status, 'exec_review'); store.close(); }); }); diff --git a/test/models.test.ts b/test/models.test.ts index 3684d75..a7f6495 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -40,32 +40,39 @@ test('pickModel:复杂度映射默认值(executor / reviewer)', () => { assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-sonnet-4-6'); assert.equal(pickModel(fakeTask('medium'), p, 'executor'), 'claude-opus-4-8'); assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5'); - assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-sonnet-4-6'); - assert.equal(pickModel(fakeTask('medium'), p, 'reviewer'), 'claude-opus-4-8'); - assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-opus-4-8'); + // reviewer 三档全升 fable-5(保复审独立性) + assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-fable-5'); + assert.equal(pickModel(fakeTask('medium'), p, 'reviewer'), 'claude-fable-5'); + assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5'); }); }); -test('pickModel:project.model 最优先(两个角色、所有复杂度都用它)', () => { - withEnv({ MAESTRO_MODEL_HARD: 'claude-haiku-4-5' }, () => { +test('pickModel:project.model executor 最优先,但 reviewer/conflict 不受 project.model 覆盖', () => { + withEnv(Object.fromEntries(ENV_KEYS.map((k) => [k, ''])), () => { const p = fakeProject('claude-opus-4-6'); for (const c of ['easy', 'medium', 'hard'] as const) { + // executor 受 project.model 覆盖 assert.equal(pickModel(fakeTask(c), p, 'executor'), 'claude-opus-4-6'); - assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-opus-4-6'); + // reviewer 不受 project.model 覆盖,始终用最强档 fable-5(保自审独立性) + assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-fable-5'); + // conflict 固定 fable-5 + assert.equal(pickModel(fakeTask(c), p, 'conflict'), 'claude-fable-5'); } }); }); -test('pickModel:env 覆盖默认值,且 executor/reviewer 各用各的 env', () => { +test('pickModel:env 覆盖默认值,executor/reviewer 各用各的 env', () => { withEnv({ MAESTRO_MODEL_EASY: 'claude-haiku-4-5', - MAESTRO_MODEL_REVIEW_HARD: 'claude-fable-5', + MAESTRO_MODEL_REVIEW_EASY: 'claude-opus-4-8', // reviewer env 可单独覆盖 }, () => { const p = fakeProject(); assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-haiku-4-5'); - assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-sonnet-4-6'); // reviewer 不受 executor env 影响 + // reviewer easy 被 MAESTRO_MODEL_REVIEW_EASY 覆盖 + assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-opus-4-8'); + // reviewer hard 无 env → 默认 fable-5 assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5'); - assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5'); // executor hard 默认即 fable + assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5'); // executor hard 默认 fable }); }); @@ -91,8 +98,9 @@ test('isModelError:模型不可用类错误才触发回退', () => { }); test('pickFallbackModel:回退链取第一个 ≠ 失败模型的', () => { - assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-opus-4-8', 'claude-sonnet-4-6']); + // 链已扩展为三项:fable → opus → sonnet + assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6']); assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8'); assert.equal(pickFallbackModel('claude-opus-4-8'), 'claude-sonnet-4-6'); - assert.equal(pickFallbackModel('claude-sonnet-4-6'), 'claude-opus-4-8'); + assert.equal(pickFallbackModel('claude-sonnet-4-6'), 'claude-fable-5'); }); diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index 6aaeede..99ee067 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -56,6 +56,8 @@ function mockDeps(overrides: Partial = {}): PipelineDeps { reviewCode: async () => okReview, reviewSecurity: async () => okSecurity, runPlanner: async () => ({ ok: true, transcriptRef: '/tmp/plan.jsonl', sessionId: 'sess-plan', finalText: '方案正文' }), + runConflict: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: null }), + syncMain: async () => null, // no-op:测试环境无真实 git worktree,跳过同步 ...overrides, }; }