Files
maestro/test/worker-integration.test.ts
T
wangjia 70d996110d phase3(集成测试): 真 worker 进程 spawn → 重启 re-adopt 存活 worker → ingest;死亡→回收重试
用假 worker(走 outbox 协议、不烧 SDK token)验证 daemon↔worker 真进程 seam:
- 头条: spawn 真进程→等 started→换 Store 重连 reconcile(真判活)→re-adopt(任务仍 executing 不重跑)
  →finish 哨兵→ingest 到 exec_review(四字段 + reviewer/security/executor run 就位)
- 回收: 杀进程组 + 心跳调陈旧→reconcile 回收→failTaskAttempt 退避重入队

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:07:42 +08:00

141 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync, writeFileSync, utimesSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Store } from '../src/store/index.js';
import { createOrchestrator } from '../src/daemon/orchestrator.js';
import { ingestAll } from '../src/daemon/ingest.js';
import {
readOutboxAll, heartbeatAgeMs, isWorkerAlive, runDir, heartbeatPath, pidAlive,
} from '../src/executor/protocol.js';
import type { Run } from '../src/model/types.js';
const log = { info: (): void => undefined, error: (): void => undefined };
const FIXTURE = resolve(process.cwd(), 'test/fixtures/fake-worker.ts');
/** 真判活:与 daemon index.ts 注入 reconcileInterrupted 的一致 */
function realIsAlive(run: Run | null): boolean {
return run !== null && isWorkerAlive({
pid: run.workerPid,
heartbeatAgeMs: heartbeatAgeMs(run.id),
startedAgeMs: Date.now() - Date.parse(run.startedAt),
});
}
async function waitFor(pred: () => boolean, timeoutMs = 20_000, stepMs = 150): Promise<boolean> {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
if (pred()) return true;
await new Promise((r) => setTimeout(r, stepMs));
}
return false;
}
function makeRepo(dir: string): void {
const g = (args: string[]): void => { execFileSync('git', args, { cwd: dir }); };
g(['init', '-b', 'main']); g(['config', 'user.name', 't']); g(['config', 'user.email', 't@t']);
writeFileSync(join(dir, 'README.md'), '# demo\n');
g(['add', '-A']); g(['commit', '-m', 'init']);
}
/** 公共 setup:临时 data dir + repo + MAESTRO_WORKER_CMD 指向假 worker;返回 store/task/cleanup */
function setupReal(t: { after: (fn: () => void) => void }): { store: Store; dbFile: string; taskId: string; repo: string } {
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wi-data-'));
const repo = mkdtempSync(join(tmpdir(), 'maestro-wi-repo-'));
makeRepo(repo);
const prevData = process.env.MAESTRO_DATA_DIR;
const prevCmd = process.env.MAESTRO_WORKER_CMD;
process.env.MAESTRO_DATA_DIR = dataDir;
process.env.MAESTRO_WORKER_CMD = `npx tsx ${FIXTURE}`;
const dbFile = join(dataDir, 'maestro.sqlite');
const store = new Store(dbFile);
const p = store.createProject({ name: 'wi', repoPath: repo, autonomy: 'auto-approved', concurrency: 1 });
const task = store.createTask({ projectId: p.id, title: '集成任务', complexity: 'easy' });
store.setOperations(task.id, '改一行'); // → ready
t.after(() => {
// 收尾:杀掉可能残留的 worker
try {
const run = store.listRuns(task.id).find((r) => r.kind === 'executor');
if (run?.workerPid && pidAlive(run.workerPid)) process.kill(run.workerPid, 'SIGKILL');
} catch { /* noop */ }
try { store.close(); } catch { /* noop */ }
if (prevData === undefined) delete process.env.MAESTRO_DATA_DIR; else process.env.MAESTRO_DATA_DIR = prevData;
if (prevCmd === undefined) delete process.env.MAESTRO_WORKER_CMD; else process.env.MAESTRO_WORKER_CMD = prevCmd;
rmSync(dataDir, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true });
});
return { store, dbFile, taskId: task.id, repo };
}
test('集成:spawn 真 worker 进程 → daemon「重启」中 re-adopt 存活 worker → finish → ingest 到 exec_review', async (t) => {
const { store, dbFile, taskId } = setupReal(t);
// 1) 监工领取 → 真 spawn 假 worker 进程(经 MAESTRO_WORKER_CMD
createOrchestrator(store, log).claimTick();
const run = store.listRuns(taskId).find((r) => r.kind === 'executor');
assert.ok(run, '应建了 executor run');
assert.equal(store.getTask(taskId)!.status, 'executing');
assert.ok(run!.workerPid, 'setWorkerPid 已写 worker 进程 pid');
// 2) 等 worker 进程启动并 emit startednpx tsx 冷启动较慢)
const started = await waitFor(() => readOutboxAll(run!.id).some((r) => r.type === 'started'));
assert.ok(started, 'worker 应在超时内 emit started');
assert.ok(pidAlive(run!.workerPid!), 'worker 进程应存活');
// 3) 模拟 daemon 重启:换一个 Store 连同一个 dbreconcile 用真判活
store.close();
const store2 = new Store(dbFile);
t.after(() => { try { store2.close(); } catch { /* noop */ } });
const rec = store2.reconcileInterrupted(realIsAlive);
assert.equal(rec.readopted, 1, '存活 worker 应被 re-adopt');
assert.equal(rec.reclaimed, 0, '不应回收存活 worker');
assert.equal(store2.getTask(taskId)!.status, 'executing', 're-adopt 后任务仍在 executing(未被打断/重跑)');
// 4) 投放 finish 哨兵 → worker emit result + done + 退出
writeFileSync(join(runDir(run!.id), 'finish'), '');
const got = await waitFor(() => readOutboxAll(run!.id).some((r) => r.type === 'result'));
assert.ok(got, 'worker 应在 finish 后 emit result');
// 5) daemon ingest → 任务进 exec_review,结果四字段就位
ingestAll(store2, log);
const done = store2.getTask(taskId)!;
assert.equal(done.status, 'exec_review');
assert.equal(done.result?.verdict, 'approve');
assert.equal(done.result?.securityVerdict, 'approve');
assert.match(done.result?.diffSummary ?? '', /demo\.txt/);
// reviewer + security run 各一条 succeededdaemon 据 result 建)
const runs = store2.listRuns(taskId);
assert.ok(runs.some((r) => r.kind === 'reviewer' && r.status === 'succeeded'));
assert.ok(runs.some((r) => r.kind === 'security' && r.status === 'succeeded'));
assert.ok(runs.some((r) => r.kind === 'executor' && r.status === 'succeeded'));
});
test('集成:worker 进程死亡(心跳变陈旧)→ reconcile 回收 → 任务退避重入队', async (t) => {
const { store, dbFile, taskId } = setupReal(t);
createOrchestrator(store, log).claimTick();
const run = store.listRuns(taskId).find((r) => r.kind === 'executor')!;
await waitFor(() => readOutboxAll(run.id).some((r) => r.type === 'started'));
// 杀掉整个 worker 进程组(detached spawn 使子进程为组长;测试经 `npx tsx` 是进程树,
// 须杀组 -pid 才能连同孙进程一起灭,否则孙进程继续刷心跳。生产是 `node worker.js`pid 即真 worker)。
if (run.workerPid) { try { process.kill(-run.workerPid, 'SIGKILL'); } catch { /* 退回单 pid */ try { process.kill(run.workerPid, 'SIGKILL'); } catch { /* 已退出 */ } } }
// 等进程真正退出、停止刷心跳后,再把心跳 mtime 调到 2 分钟前(越过 60s grace,模拟「死了且心跳陈旧」)
await new Promise((r) => setTimeout(r, 500));
const old = new Date(Date.now() - 120_000);
if (existsSync(heartbeatPath(run.id))) utimesSync(heartbeatPath(run.id), old, old);
store.close();
const store2 = new Store(dbFile);
t.after(() => { try { store2.close(); } catch { /* noop */ } });
const rec = store2.reconcileInterrupted(realIsAlive);
assert.equal(rec.reclaimed, 1, '死 worker 应被回收');
assert.equal(rec.readopted, 0);
// 默认 maxRetries=2:第一次失败 → 退避重入队(queued + nextEligibleAt 有值)
const tk = store2.getTask(taskId)!;
assert.ok(['queued', 'blocked'].includes(tk.status), `回收后应重入队,实际 ${tk.status}`);
assert.ok(tk.nextEligibleAt, '应写了持久化退避 nextEligibleAt');
});