merge: worker 池化 + SDK resume 抗抖动 [tsk_74Acz1Nh4K1J]

手工正确合并(破除补救死循环):带上 src/executor/cc.ts 池化 + SDK resume + test/cc.test.ts(typecheck + 189 测试全绿)。DESIGN.md 冲突取 main 新版(多进程 re-adopt / worker 不碰 DB 描述),保留 246 行 SDK-resume 新增条目,丢弃分支过时描述。

此前补救任务只啃了 DESIGN.md 冲突文件、没带 cc.ts 代码,导致真代码一直没落地、原任务卡 exec_review、每次重试又生新补救(xbtd6GpS4NYg→Yb8eFAX2VUxZ)的死循环,至此终结。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 20:32:10 +08:00
3 changed files with 185 additions and 11 deletions
+128
View File
@@ -0,0 +1,128 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runClaude, type CCQuery, type CCOptions } from '../src/executor/cc.js';
// cc.ts 的 SDK resume 续跑 / 模型回退逻辑:注入 fake query,不真起 Claude Code。
let dataDir: string;
let prevData: string | undefined;
let prevResume: string | undefined;
before(() => {
dataDir = mkdtempSync(join(tmpdir(), 'maestro-cc-'));
prevData = process.env.MAESTRO_DATA_DIR;
prevResume = process.env.MAESTRO_SDK_RESUME;
process.env.MAESTRO_DATA_DIR = dataDir; // transcript 落临时目录
delete process.env.MAESTRO_SDK_RESUME; // 默认开启 resume
});
after(() => {
if (prevData === undefined) delete process.env.MAESTRO_DATA_DIR; else process.env.MAESTRO_DATA_DIR = prevData;
if (prevResume === undefined) delete process.env.MAESTRO_SDK_RESUME; else process.env.MAESTRO_SDK_RESUME = prevResume;
rmSync(dataDir, { recursive: true, force: true });
});
type Script = (args: { prompt: string; options: Record<string, unknown> }) => AsyncGenerator<unknown>;
/** 串接多段脚本:第 i 次调用走第 i 段(越界用最后一段);记录每次的 options(看 resume)。 */
function makeQuery(scripts: Script[]): { fn: CCQuery; calls: Record<string, unknown>[] } {
const calls: Record<string, unknown>[] = [];
let i = 0;
const fn = ((args: { prompt: string; options: Record<string, unknown> }) => {
calls.push(args.options);
const script = scripts[Math.min(i, scripts.length - 1)];
i += 1;
return script(args);
}) as unknown as CCQuery;
return { fn, calls };
}
function baseOpts(runId: string, timeoutMs = 30_000): CCOptions {
return { prompt: 'do it', cwd: dataDir, model: 'claude-x', runId, maxTurns: 10, timeoutMs, allowedTools: [] };
}
// 段:拿到 sessionId 后流式中途抛错(SDK 抖动)
const transientThrow: Script = async function* (args) {
yield { type: 'system', session_id: 'sess-1' };
void args;
throw new Error('socket hang up');
};
// 段:一条成功 result
const successResult: Script = async function* () {
yield { type: 'result', subtype: 'success', is_error: false, result: '完工', session_id: 'sess-1' };
};
test('SDK 抖动(流中途抛错)+ 有 sessionId → resume 续跑一次并成功', async () => {
const { fn, calls } = makeQuery([transientThrow, successResult]);
const r = await runClaude(baseOpts('r-resume'), fn);
assert.equal(r.ok, true, '续跑后应成功');
assert.equal(r.finalText, '完工');
assert.equal(r.fellBack, false, '这是 resume,不是模型回退');
assert.equal(calls.length, 2, '应正好两次 attempt(首攻 + resume');
assert.equal(calls[0].resume, undefined, '首攻不带 resume');
assert.equal(calls[1].resume, 'sess-1', 'resume 应续上首攻的 sessionId');
});
test('终态错误(result=error_max_turns)→ 不 resume(重来只会再撞墙)', async () => {
const terminal: Script = async function* () {
yield { type: 'result', subtype: 'error_max_turns', is_error: true, session_id: 'sess-1' };
};
const { fn, calls } = makeQuery([terminal, successResult]);
const r = await runClaude(baseOpts('r-terminal'), fn);
assert.equal(r.ok, false);
assert.equal(calls.length, 1, 'max_turns 是终态,不应触发 resume');
assert.match(r.error ?? '', /error_max_turns/);
});
test('首攻无 sessionId(连 system 都没拿到就抛)→ 无可续之 session,不 resume', async () => {
const noSession: Script = async function* () {
throw new Error('connect ECONNREFUSED');
};
const { fn, calls } = makeQuery([noSession, successResult]);
const r = await runClaude(baseOpts('r-nosess'), fn);
assert.equal(r.ok, false);
assert.equal(calls.length, 1, '没有 sessionId 无法 resume');
});
test('MAESTRO_SDK_RESUME=0 → 即便可续也不 resume', async () => {
process.env.MAESTRO_SDK_RESUME = '0';
try {
const { fn, calls } = makeQuery([transientThrow, successResult]);
const r = await runClaude(baseOpts('r-disabled'), fn);
assert.equal(r.ok, false);
assert.equal(calls.length, 1, '开关关闭时不应 resume');
} finally {
delete process.env.MAESTRO_SDK_RESUME;
}
});
test('超时(abort)→ 不 resume(预算已耗尽)', async () => {
// 拿到 session 后挂起,直到 abort 信号触发才抛错;配极小 timeout 强制超时
const hangUntilAbort: Script = async function* (args) {
yield { type: 'system', session_id: 'sess-1' };
await new Promise((_res, rej) => {
const ac = args.options.abortController as AbortController;
ac.signal.addEventListener('abort', () => rej(new Error('aborted')));
});
};
const { fn, calls } = makeQuery([hangUntilAbort, successResult]);
const r = await runClaude(baseOpts('r-timeout', 40), fn);
assert.equal(r.ok, false);
assert.equal(calls.length, 1, '超时导致的 abort 不可 resume');
});
test('模型不可用错误 → 走模型回退(非 resume),续跑标记 fellBack', async () => {
// 首攻报模型不可用(终态 result,非中断)→ 不 resume,应触发模型回退
const modelErr: Script = async function* () {
yield { type: 'result', subtype: 'error_during_execution', is_error: true, session_id: 'sess-1', errors: ['model not_found'] };
};
const { fn, calls } = makeQuery([modelErr, successResult]);
const r = await runClaude(baseOpts('r-modelfb'), fn);
assert.equal(r.ok, true, '回退模型后成功');
assert.equal(r.fellBack, true);
assert.equal(calls.length, 2);
assert.equal(calls[1].resume, undefined, '模型回退是重开会话,不带 resume');
assert.notEqual(calls[1].model, calls[0].model, '回退应换了模型');
});