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:
+128
@@ -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, '回退应换了模型');
|
||||
});
|
||||
Reference in New Issue
Block a user