// 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); });