// A finished card's worker stays finished (Oct 9: Shadow-2, Shadow-6, Shadow-12 // and Echo came back as live agents with no card). // // - Filing a card closes its worker even when the worker's CLI has already // exited: left as an exited tab, it stayed in activeSessions and the next // restart turned it into an orphan of a card that had moved on. // - A worker re-adopted after a restart is retired like any other: the // restart clears the card's link and the re-adopt sets it to the new id. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { config, sessions, orphans, adoptingOrphans } from '../server/state.js'; import { addJob, allJobs, boardSettings, dispatchOnce, moveJob, checkPullRequests, checkMergedPullRequests, relinkSessionToJob, closeJobForAgent, } from '../server/jobs.js'; import { removeTempDir } from './temp-dir.js'; const REPO = mkdtempSync(join(tmpdir(), 'a007-life-repo-')); const noop = () => {}; const dirs = []; function resetBoard() { config.repos = [{ path: REPO }]; config.jobs = []; config.jobBoard = null; config.activeSessions = []; boardSettings(); sessions.clear(); orphans.clear(); adoptingOrphans.clear(); } // server.js's createSession without the PTY. function fakeCreateSession() { let n = 0; return async (command, name, repoPath, branch, ownerId, meta) => { n++; const session = { id: `session-${n}`, name: `Worker${n}`, command, repoPath, branchName: `mac-mini/card-${n}`, worktreePath: `/wt/${n}`, state: 'WORKING', exited: false, lastOutputAt: Date.now(), spawnedBy: meta?.spawnedBy, jobId: meta?.jobId, }; sessions.set(session.id, session); return { session }; }; } // A dispatched card, its worker live and linked. let createSession; async function dispatched(fields = {}) { const { job } = addJob({ title: 'lifecycle card', repoPath: REPO, postedByBillion: true, ...fields }, noop); await dispatchOnce(createSession, noop); return allJobs().find(j => j.id === job.id); } const killer = (killed) => async (id) => { killed.push(id); sessions.delete(id); }; let log; beforeEach(() => { resetBoard(); createSession = fakeCreateSession(); log = vi.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { log.mockRestore(); for (const d of dirs.splice(0)) removeTempDir(d); }); const logged = () => log.mock.calls.map(c => c.join(' ')).join('\n'); describe('filing a card closes its worker', () => { it('closes a worker whose CLI already exited when the card is accepted, and logs it', async () => { const job = await dispatched({ requiresPr: false }); const sid = job.agentSessionId; job.state = 'review'; sessions.get(sid).exited = true; // crashed, updated, /exit const killed = []; const result = await closeJobForAgent({ session: { isBillion: true }, id: job.id, accept: true }, noop, { killSession: killer(killed) }); expect(result.accepted).toBe(true); // Before: the exited tab was only unlinked, and lived on in activeSessions. expect(killed).toEqual([sid]); expect(job.agentSessionId).toBeNull(); expect(logged()).toMatch(/Worker1: closed, its card "lifecycle card" moved to Finished \(its CLI had already exited\)/); }); it('closes a linked worker whose CLI already exited when the PR merges, and drops the link', async () => { const job = await dispatched(); const sid = job.agentSessionId; await checkPullRequests(noop, { findPr: async () => ({ pr: { url: 'https://gh/o/r/pull/7', number: 7 } }) }); expect(job.state).toBe('review'); sessions.get(sid).exited = true; const killed = []; await checkMergedPullRequests(noop, { findMerged: async () => ({ pr: { url: 'https://gh/o/r/pull/7', number: 7, mergedAt: new Date().toISOString() } }), killSession: killer(killed), }); expect(job.state).toBe('done'); expect(killed).toEqual([sid]); expect(job.agentSessionId).toBeNull(); expect(logged()).toMatch(/Worker1: closed, its card "lifecycle card" moved to Finished/); }); it('never discards the files of a worker whose CLI exited, even for a scratch run', async () => { // supersedeRuns files an old run with discardChanges; a run that crashed // may hold work its summary does not, so its dirty worktree stays an orphan. const job = await dispatched(); const sid = job.agentSessionId; sessions.get(sid).exited = true; const calls = []; await moveJob(job.id, 'done', noop, { discardChanges: true, killSession: async (id, opts) => { calls.push([id, opts]); sessions.delete(id); } }); expect(calls).toEqual([[sid, { discardChanges: false }]]); }); it('leaves a filed card no link to a session that is gone', async () => { const job = await dispatched(); await checkPullRequests(noop, { findPr: async () => ({ pr: { url: 'https://gh/o/r/pull/8', number: 8 } }) }); sessions.delete(job.agentSessionId); await checkMergedPullRequests(noop, { findMerged: async () => ({ pr: { url: 'https://gh/o/r/pull/8', number: 8, mergedAt: new Date().toISOString() } }), killSession: killer([]), }); expect(job.state).toBe('done'); expect(job.agentSessionId).toBeNull(); }); // The card's first hypothesis: a re-adopted worker gets a new session id // while the card keeps the old one, so the retire misses it. It does not: // the restart clears every link (loadConfig) and the re-adopt sets the new id. it('retires a worker re-adopted after a restart, on merge and on accept', async () => { const merged = await dispatched(); await checkPullRequests(noop, { findPr: async () => ({ pr: { url: 'https://gh/o/r/pull/9', number: 9 } }) }); const accepted = await dispatched({ title: 'no-PR card', requiresPr: false }); accepted.state = 'review'; const before = new Map([merged, accepted].map(job => [job, sessions.get(job.agentSessionId)])); sessions.clear(); // the restart: no session survives it for (const [job, old] of before) { job.agentSessionId = null; // loadConfig clears every link const readopted = { ...old, id: `readopted-${job.id}`, exited: false }; sessions.set(readopted.id, readopted); expect(relinkSessionToJob(readopted, noop)).toBe(job); expect(job.agentSessionId).toBe(readopted.id); } const killed = []; await checkMergedPullRequests(noop, { findMerged: async (_r, branch) => ({ pr: branch === merged.branchName ? { url: 'https://gh/o/r/pull/9', number: 9, mergedAt: new Date().toISOString() } : null }), killSession: killer(killed), }); await closeJobForAgent({ session: { isBillion: true }, id: accepted.id, accept: true }, noop, { killSession: killer(killed) }); expect(killed).toEqual([`readopted-${merged.id}`, `readopted-${accepted.id}`]); expect([merged.state, accepted.state]).toEqual(['done', 'done']); }); }); describe('closing an exited worker: the edges', () => { const failing = async () => { throw new Error('worktree busy'); }; it('closes an exited worker when its card goes back to To do', async () => { const job = await dispatched(); const sid = job.agentSessionId; sessions.get(sid).exited = true; const killed = []; await moveJob(job.id, 'todo', noop, { killSession: killer(killed) }); expect(job.state).toBe('todo'); expect(killed).toEqual([sid]); expect(job.agentSessionId).toBeNull(); expect(logged()).toMatch(/Worker1: closed, its card "lifecycle card" moved to .*\(its CLI had already exited\)/); }); it('keeps the link when closing an exited worker fails on a manual move', async () => { const job = await dispatched(); const sid = job.agentSessionId; sessions.get(sid).exited = true; const err = vi.spyOn(console, 'error').mockImplementation(() => {}); await moveJob(job.id, 'done', noop, { killSession: failing }); err.mockRestore(); expect(job.state).toBe('done'); expect(job.agentSessionId).toBe(sid); }); it('keeps the link when closing an exited linked worker fails on merge', async () => { const job = await dispatched(); const sid = job.agentSessionId; await checkPullRequests(noop, { findPr: async () => ({ pr: { url: 'https://gh/o/r/pull/11', number: 11 } }) }); sessions.get(sid).exited = true; const err = vi.spyOn(console, 'error').mockImplementation(() => {}); await checkMergedPullRequests(noop, { findMerged: async () => ({ pr: { url: 'https://gh/o/r/pull/11', number: 11, mergedAt: new Date().toISOString() } }), killSession: failing, }); err.mockRestore(); expect(job.state).toBe('done'); expect(job.agentSessionId).toBe(sid); }); it('does not close an unlinked exited session found by branch, but drops a dead link', async () => { // In progress when it merges, so the sweep also looks by branch; an exited // session there is not the card's (only a live one is matched by branch). const job = await dispatched(); const old = sessions.get(job.agentSessionId); sessions.delete(old.id); // the linked id is gone const stray = { ...old, id: 'stray-exited', exited: true }; sessions.set(stray.id, stray); const killed = []; await checkMergedPullRequests(noop, { findMerged: async () => ({ pr: { url: 'https://gh/o/r/pull/12', number: 12, mergedAt: new Date().toISOString() } }), killSession: killer(killed), }); expect(job.state).toBe('done'); expect(killed).toEqual([]); expect(job.agentSessionId).toBeNull(); expect(sessions.has(stray.id)).toBe(true); }); });