test/respawn-board-workers.test.js
Rama main · réplica de lectura del repositorio original.
// Billion's workers come back after a restart: the startup pass re-spawns each
// orphan the restart made on one of Billion's In-progress cards, within the
// cap, and respawn_agent lets Billion bring back one by name.
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { respawnBoardWorkers, respawnAgent } from '../server/ws.js';
import { addJob, deleteJob, allJobs, boardSettings } from '../server/jobs.js';
import { config, sessions, orphans } from '../server/state.js';
import { pendingMessages } from '../server/messages.js';
import { toolsFor } from '../server/mcp.js';
import { BILLION_NAME } from '../lib/jobs.js';
import { removeTempDir } from './temp-dir.js';
// A fake `codex` first on PATH (posix only, like server.test.js's), so the
// re-spawned session is a real PTY that resumes nothing.
const posix = process.platform !== 'win32';
let bin, savedPath, savedCodexHome, repoPath;
beforeAll(() => {
bin = mkdtempSync(join(tmpdir(), 'a007-rbw-bin-'));
writeFileSync(join(bin, 'codex'), '#!/bin/sh\nsleep 5\n', { mode: 0o755 });
savedPath = process.env.PATH;
process.env.PATH = `${bin}:${savedPath}`;
savedCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = mkdtempSync(join(tmpdir(), 'a007-rbw-codex-'));
repoPath = mkdtempSync(join(tmpdir(), 'a007-rbw-repo-'));
config.repos = [...config.repos, { path: repoPath }];
});
afterAll(() => {
process.env.PATH = savedPath;
if (savedCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = savedCodexHome;
removeTempDir(bin);
});
const made = { jobs: [], dirs: [] };
afterEach(async () => {
const killed = [...sessions.values()];
for (const [id, s] of sessions) {
clearInterval(s.stateCheckInterval);
clearTimeout(s.scanTimer);
try { s.pty.kill(); } catch {}
sessions.delete(id);
}
// The workers sit in the worktree dirs removed below, which Windows will not
// remove while a live process has one as its cwd.
for (const end = Date.now() + 5000; Date.now() < end && !killed.every(s => s.exited || !s.pty);) {
await new Promise(r => setTimeout(r, 25));
}
orphans.clear();
config.activeSessions = [];
for (const id of made.jobs.splice(0)) await deleteJob(id, () => {});
for (const d of made.dirs.splice(0)) removeTempDir(d);
boardSettings().maxPerRepo = 2;
});
let n = 0;
// An orphan on `card` (or none), as recoverCrashedSessions leaves it.
function orphan({ card = null, reason = 'server-restart', worktree = true, ownerId = null, ...fields } = {}) {
const i = ++n;
const worktreePath = mkdtempSync(join(tmpdir(), 'a007-rbw-wt-'));
made.dirs.push(worktreePath);
if (worktree) writeFileSync(join(worktreePath, '.git'), 'not a gitfile\n');
else rmSync(worktreePath, { recursive: true, force: true });
const branchName = card?.branchName || `b/rbw-${i}`;
const o = {
id: `orphan-rbw-${i}`, name: `Rbw${i}`, repoPath, repoSlug: 'rbw', worktreePath, branchName,
color: '#fff', ownerId, agent: 'codex', permissionFlags: [], origin: card ? 'board' : 'user',
jobId: card?.id || null, approvalsToBillion: !!card?.postedByBillion, reason, createdAt: new Date().toISOString(), ...fields,
};
orphans.set(o.id, o);
return o;
}
function card({ state = 'in-progress', billion = true } = {}) {
const { job } = addJob({ title: `rbw card ${++n}`, repoPath, agent: 'codex', postedByBillion: billion }, () => {});
made.jobs.push(job.id);
Object.assign(job, { state, branchName: `b/rbw-card-${n}`, agentSessionId: null });
return job;
}
const live = () => [...sessions.values()].filter(s => !s.exited).map(s => s.name);
const billion = { id: 'billion', name: BILLION_NAME, isBillion: true, ownerId: null };
describe.skipIf(!posix)('startup re-spawn', () => {
it('brings back only the restart orphans on Billion\'s In-progress cards, as their board workers', async () => {
const mine = card();
const back = orphan({ card: mine });
const inReview = orphan({ card: card({ state: 'review' }) });
const hand = orphan();
const owners = orphan({ card: card({ billion: false }) });
const closed = orphan({ card: card(), reason: 'unpushed' });
// Started by hand on a Billion card's branch: on the card's branch, but never its worker.
const onBranch = orphan({ card: card(), jobId: null, origin: 'user' });
expect(await respawnBoardWorkers({ paceMs: 0 })).toEqual([back.name]);
expect(live()).toEqual([back.name]);
const session = [...sessions.values()][0];
expect(session).toMatchObject({ spawnedBy: 'board', jobId: mine.id, worktreePath: back.worktreePath });
expect(allJobs().find(j => j.id === mine.id).agentSessionId).toBe(session.id);
expect(pendingMessages(session.id)).toBe(1); // the one "continue your card" nudge
expect([...orphans.keys()].sort()).toEqual([inReview.id, hand.id, owners.id, closed.id, onBranch.id].sort());
}, 15000);
it('respects the per-repo cap and leaves the extras for the next pass', async () => {
boardSettings().maxPerRepo = 1;
orphan({ card: card() });
orphan({ card: card() });
expect(await respawnBoardWorkers({ paceMs: 0 })).toHaveLength(1);
expect(orphans.size).toBe(1);
// The next pass still finds the repo full.
expect(await respawnBoardWorkers({ paceMs: 0 })).toEqual([]);
expect(orphans.size).toBe(1);
}, 15000);
it('does nothing with RESPAWN_BOARD_WORKERS=0', async () => {
orphan({ card: card() });
expect(await respawnBoardWorkers({ paceMs: 0, env: { RESPAWN_BOARD_WORKERS: '0' } })).toEqual([]);
expect(orphans.size).toBe(1);
expect(live()).toEqual([]);
});
});
describe.skipIf(!posix)('respawn_agent', () => {
it('is Billion\'s tool only', async () => {
expect(toolsFor(billion).map(t => t.name)).toContain('respawn_agent');
expect(toolsFor({ id: 'w', name: 'Cobra' }).map(t => t.name)).not.toContain('respawn_agent');
const o = orphan({ card: card() });
expect((await respawnAgent({ id: 'w', name: 'Cobra' }, o.name)).error).toMatch(/Only Billion/);
});
it('refuses a missing orphan, a hand-started agent, someone else\'s card and a vanished worktree', async () => {
expect((await respawnAgent(billion, 'Nobody')).error).toMatch(/No orphaned agent named "Nobody"/);
const hand = orphan();
expect((await respawnAgent(billion, hand.name)).error).toMatch(/not on a card you posted/);
const owners = orphan({ card: card({ billion: false }) });
expect((await respawnAgent(billion, owners.name)).error).toMatch(/not on a card you posted/);
const theirs = orphan({ card: card(), ownerId: 'someone-else' });
expect((await respawnAgent(billion, theirs.name)).error).toMatch(/No orphaned agent named/);
const gone = orphan({ card: card(), worktree: false });
expect((await respawnAgent(billion, gone.name)).error).toMatch(/worktree is gone/);
expect(orphans.has(gone.id)).toBe(true); // reported, not dropped or rebuilt
expect(live()).toEqual([]);
});
it('re-spawns one of Billion\'s orphans within the cap', async () => {
boardSettings().maxPerRepo = 1;
const a = orphan({ card: card() });
const b = orphan({ card: card({ state: 'review' }) });
const result = await respawnAgent(billion, a.name);
expect(result.name).toBe(a.name);
expect(live()).toEqual([a.name]);
expect((await respawnAgent(billion, b.name)).error).toMatch(/cap/);
}, 15000);
});