test/jobs-dispatch.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { join } from 'path';
import { config, sessions, orphans, adoptingOrphans } from '../server/state.js';
import { dispatchOnce, addJob, updateJob, moveJob, deleteJob, updateSettings, boardSettings, allJobs, jobsPayload, checkPullRequests, checkMergedPullRequests, runScan, relinkSessionToJob, attachmentPath, finishJobForAgent, postJobForAgent, editJobForAgent, releasePushedOrphans, releaseJobHold, noteGithubRemote } from '../server/jobs.js';
import { parseCommand } from '../lib/helpers.js';
import { buildJobCommand as buildCommand, buildJobPrompt, jobRequiresPr, DEFAULT_PERMISSION_MODE } from '../lib/jobs.js';
// A real directory, because dispatchOnce filters to repos that exist on disk.
const REPO = mkdtempSync(join(tmpdir(), 'a007-jobrepo-'));
const REPO2 = mkdtempSync(join(tmpdir(), 'a007-jobrepo2-'));
// Swallow config writes: saveConfig targets the developer's real ~/.agent-007.
const noopBroadcast = () => {};
// A worktree path guaranteed not to exist, for orphans whose directory is gone.
const GONE_WORKTREE = join(mkdtempSync(join(tmpdir(), 'a007-gone-')), 'worktree');
function resetBoard() {
config.repos = [{ path: REPO }, { path: REPO2 }];
config.jobs = [];
config.jobBoard = null;
boardSettings();
sessions.clear();
orphans.clear();
}
// Stand-in for server.js's createSession: records its arguments and hands back
// a session shaped like the real one, without spawning a PTY.
function fakeCreateSession(calls, { fail = false } = {}) {
let n = 0;
return async (command, name, repoPath, branch, ownerId, meta) => {
calls.push({ command, repoPath, branch, ownerId, meta });
if (fail) return { error: 'Failed to create worktree: disk on fire' };
n++;
const session = {
id: `session-${n}`, name: `Agent${n}`, command, repoPath,
branchName: `bill/cocktail${n}`, worktreePath: `/wt/${n}`,
state: 'WORKING', exited: false, lastOutputAt: Date.now(),
spawnedBy: meta?.spawnedBy, jobId: meta?.jobId,
};
sessions.set(session.id, session);
return { session };
};
}
describe('attachments', () => {
beforeEach(resetBoard);
const png = Buffer.from('not really a png').toString('base64');
it('writes posted files under the config dir, keeps or drops them on edit, and removes them with the card', async () => {
const { job } = addJob({
title: 'Fix the header', repoPath: REPO,
attachments: [{ name: 'shot.png', data: png }, { name: '../evil/../../x.txt', data: png }],
}, noopBroadcast);
expect(job.attachments.map(a => a.name)).toEqual(['shot.png', '.._evil_.._.._x.txt']);
const shot = job.attachments[0].path;
expect(shot).toBe(join(process.env.AGENT007_CONFIG_DIR, 'attachments', job.id, 'shot.png'));
expect(readFileSync(shot, 'utf8')).toBe('not really a png');
expect(attachmentPath(job.id, 'shot.png')).toBe(shot);
expect(attachmentPath(job.id, 'nope.png')).toBeNull();
expect(parseCommand(job && buildCommand(job)).args[2]).toContain(shot);
// An edit that names only one file keeps that one and deletes the other;
// an edit that says nothing about attachments leaves them alone.
updateJob(job.id, { title: 'Fix the header (v2)' }, noopBroadcast);
expect(allJobs()[0].attachments).toHaveLength(2);
updateJob(job.id, { attachments: [{ name: 'shot.png' }] }, noopBroadcast);
expect(allJobs()[0].attachments.map(a => a.name)).toEqual(['shot.png']);
expect(existsSync(join(process.env.AGENT007_CONFIG_DIR, 'attachments', job.id, '.._evil_.._.._x.txt'))).toBe(false);
expect(existsSync(shot)).toBe(true);
expect(addJob({ title: 'Big', repoPath: REPO, attachments: [{ name: 'big.bin', data: Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64') }] }, noopBroadcast).error).toMatch(/too large/);
await deleteJob(job.id, noopBroadcast);
expect(existsSync(shot)).toBe(false);
});
it('refuses rather than silently drops, leaves a half-edit unapplied, and never deletes outside its dir', async () => {
const many = Array.from({ length: 21 }, (_, i) => ({ name: `f${i}.txt`, data: png }));
expect(addJob({ title: 'Many', repoPath: REPO, attachments: many }, noopBroadcast).error).toMatch(/at most 20/i);
expect(addJob({ title: 'Dots', repoPath: REPO, attachments: [{ name: '...', data: png }] }, noopBroadcast).error).toMatch(/unusable/i);
expect(addJob({ title: 'Case', repoPath: REPO, attachments: [{ name: 'A.png', data: png }, { name: 'a.png', data: png }] }, noopBroadcast).error).toMatch(/stored as/i);
expect(addJob({ title: 'Dev', repoPath: REPO, attachments: [{ name: 'CON.png', data: png }] }, noopBroadcast).job.attachments[0].name).toBe('_CON.png');
expect(allJobs()).toHaveLength(1);
// A kept entry with no file behind it is dropped from a new card.
const { job } = addJob({ title: 'Ghost', repoPath: REPO, attachments: [{ name: 'ghost.png' }, { name: 'real.png', data: png }] }, noopBroadcast);
expect(job.attachments.map(a => a.name)).toEqual(['real.png']);
// A refused save changes nothing, not even the title.
const big = Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64');
expect(updateJob(job.id, { title: 'Renamed', attachments: [{ name: 'real.png' }, { name: 'big.bin', data: big }] }, noopBroadcast).error).toMatch(/too large/);
const stored = () => allJobs().find(j => j.id === job.id);
expect(stored().title).toBe('Ghost');
expect(existsSync(join(process.env.AGENT007_CONFIG_DIR, 'attachments', job.id, 'big.bin'))).toBe(false);
// A hand-edited path outside the job's dir is left alone when dropped,
// and is never served.
const outside = join(REPO, 'keep-me.txt');
writeFileSync(outside, 'x');
job.attachments.push({ name: 'keep-me.txt', path: outside });
expect(attachmentPath(job.id, 'keep-me.txt')).toBeNull();
updateJob(job.id, { attachments: [{ name: 'real.png' }] }, noopBroadcast);
expect(existsSync(outside)).toBe(true);
expect(stored().attachments.map(a => a.name)).toEqual(['real.png']);
// Resending the same list is not an attachment change, so an ordinary
// retitle goes through untouched.
expect(updateJob(job.id, { title: 'Still fine', attachments: [{ name: 'real.png' }] }, noopBroadcast).error).toBeUndefined();
expect(stored().attachments).toHaveLength(1);
// A file that vanished from disk stays on the record rather than being
// dropped from the card by an edit that never mentioned it.
rmSync(stored().attachments[0].path);
expect(updateJob(job.id, { title: 'Still fine 2', attachments: [{ name: 'real.png' }] }, noopBroadcast).error).toBeUndefined();
expect(stored().attachments.map(a => a.name)).toEqual(['real.png']);
expect(stored().title).toBe('Still fine 2');
// Once dispatched the card is closed to edits of every kind: the agent
// holds these paths, and the title and detail it was handed. The board's
// own form is the caller here (ws.js job-update), not an agent.
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
expect(stored().state).toBe('in-progress');
expect(updateJob(job.id, { attachments: [] }, noopBroadcast).error).toMatch(/To do/);
expect(updateJob(job.id, { title: 'Too late' }, noopBroadcast).error).toMatch(/To do/);
// Before this gate, a repoPath sent post-dispatch was silently dropped
// rather than refused (only applied `state === 'todo'`); it now refuses
// like every other field.
expect(updateJob(job.id, { repoPath: REPO2 }, noopBroadcast).error).toMatch(/To do/);
expect(stored().repoPath).toBe(REPO);
expect(stored().title).toBe('Still fine 2');
});
it('refuses a board-form save in every column past To do, not just in progress', () => {
// The gate is one function for the form and for an agent, but only the
// form can reach a card sitting in Review or in the finished archive.
for (const state of ['in-progress', 'review', 'done']) {
const { job } = addJob({ title: `Sitting in ${state}`, repoPath: REPO }, noopBroadcast);
job.state = state;
expect(updateJob(job.id, { title: 'Rewritten' }, noopBroadcast).error, state).toMatch(/To do/);
expect(job.title, state).toBe(`Sitting in ${state}`);
}
});
it('replaces a file in place and leaves no staging file behind', () => {
const { job } = addJob({ title: 'Swap', repoPath: REPO, attachments: [{ name: 'a.txt', data: Buffer.from('one').toString('base64') }] }, noopBroadcast);
updateJob(job.id, { attachments: [{ name: 'a.txt', data: Buffer.from('two').toString('base64') }] }, noopBroadcast);
expect(readFileSync(job.attachments[0].path, 'utf8')).toBe('two');
expect(existsSync(job.attachments[0].path + '~part')).toBe(false);
});
it('frees the files, records and all, the moment the card reaches done', async () => {
const { job } = addJob({ title: 'Ship it', repoPath: REPO, attachments: [{ name: 'shot.png', data: png }] }, noopBroadcast);
const dir = join(process.env.AGENT007_CONFIG_DIR, 'attachments', job.id);
await moveJob(job.id, 'done', noopBroadcast);
expect(allJobs()[0].attachments).toEqual([]);
expect(existsSync(dir)).toBe(false);
});
});
describe('dispatchOnce', () => {
beforeEach(resetBoard);
it('moves a todo job to in-progress and records who is working on it and since when', async () => {
addJob({ title: 'Add caching', detail: 'LRU, 100 entries', repoPath: REPO, postedByName: 'Bill' }, noopBroadcast);
const calls = [];
const dispatched = await dispatchOnce(fakeCreateSession(calls), noopBroadcast);
expect(dispatched).toHaveLength(1);
const job = allJobs()[0];
expect(job.state).toBe('in-progress');
expect(job.agentSessionId).toBe('session-1');
expect(job.agentName).toBe('Agent1');
expect(job.branchName).toBe('bill/cocktail1');
expect(Date.parse(job.startedAt)).not.toBeNaN();
});
it('sends the job as a single argv and tags the session as board-spawned', async () => {
addJob({ title: 'Fix "the" bug', detail: 'Detail here', repoPath: REPO }, noopBroadcast);
const calls = [];
await dispatchOnce(fakeCreateSession(calls), noopBroadcast);
expect(calls).toHaveLength(1);
// The board must never steal the user's focus, so the client needs to know
// this session came from the dispatcher.
expect(calls[0].meta.spawnedBy).toBe('board');
expect(calls[0].meta.jobId).toBe(allJobs()[0].id);
const parsed = parseCommand(calls[0].command);
expect(parsed.file).toBe('claude');
expect(parsed.args).toHaveLength(3);
expect(parsed.args[2]).toContain('Fix "the" bug');
expect(parsed.args[2]).toContain('Detail here');
});
it('publishes the new session so it gets a terminal tab', async () => {
// Without this the agent runs invisibly and the user cannot answer it.
addJob({ title: 'J', repoPath: REPO }, noopBroadcast);
const published = [];
await dispatchOnce(fakeCreateSession([]), noopBroadcast, { onSessionCreated: (s) => published.push(s) });
expect(published.map(s => s.id)).toEqual(['session-1']);
});
it('stops at the per-repo cap and leaves the rest queued', async () => {
for (let i = 0; i < 5; i++) addJob({ title: `J${i}`, repoPath: REPO }, noopBroadcast);
updateSettings({ maxPerRepo: 2 }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const states = allJobs().map(j => j.state);
expect(states.filter(s => s === 'in-progress')).toHaveLength(2);
expect(states.filter(s => s === 'todo')).toHaveLength(3);
});
it('frees the slot again once a job reaches review', async () => {
for (let i = 0; i < 3; i++) addJob({ title: `J${i}`, repoPath: REPO }, noopBroadcast);
updateSettings({ maxPerRepo: 1 }, noopBroadcast);
const create = fakeCreateSession([]);
await dispatchOnce(create, noopBroadcast);
expect(allJobs().filter(j => j.state === 'in-progress')).toHaveLength(1);
// At the cap: nothing new goes out.
await dispatchOnce(create, noopBroadcast);
expect(allJobs()[1].state).toBe('todo');
// In normal operation the agent is retired at the same moment the card
// moves, which is what keeps in-progress count == live agent count. A
// manual move is the user's own call.
await moveJob(allJobs()[0].id, 'review', noopBroadcast);
await dispatchOnce(create, noopBroadcast);
expect(allJobs()[1].state).toBe('in-progress');
});
it('does not let a dead agent block its repo forever', async () => {
addJob({ title: 'first', repoPath: REPO }, noopBroadcast);
addJob({ title: 'second', repoPath: REPO }, noopBroadcast);
updateSettings({ maxPerRepo: 1 }, noopBroadcast);
const create = fakeCreateSession([]);
await dispatchOnce(create, noopBroadcast);
// The agent dies (crash, or the user closed the tab). The card stays put —
// we never auto-revert — but its slot has to come back.
sessions.delete('session-1');
await dispatchOnce(create, noopBroadcast);
expect(allJobs()[1].state).toBe('in-progress');
});
it('caps each repo separately', async () => {
addJob({ title: 'a', repoPath: REPO }, noopBroadcast);
addJob({ title: 'b', repoPath: REPO2 }, noopBroadcast);
updateSettings({ maxPerRepo: 1 }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
expect(allJobs().every(j => j.state === 'in-progress')).toBe(true);
});
it('leaves the job in todo and records the reason when the spawn fails', async () => {
addJob({ title: 'doomed', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([], { fail: true }), noopBroadcast);
const job = allJobs()[0];
expect(job.state).toBe('todo'); // retried on the next tick
expect(job.lastError).toMatch(/disk on fire/);
});
it('reuses a failed reservation for the next card in the same pass, at the live cap', async () => {
boardSettings().maxPerRepo = 2;
const create = fakeCreateSession([]);
const live = addJob({ title: 'live', repoPath: REPO }, noopBroadcast).job;
await dispatchOnce(create, noopBroadcast);
const a = addJob({ title: 'A', repoPath: REPO, postedBy: 'owner-a' }, noopBroadcast).job;
const b = addJob({ title: 'B', repoPath: REPO, postedBy: 'owner-b' }, noopBroadcast).job;
const c = addJob({ title: 'C', repoPath: REPO }, noopBroadcast).job;
const attempted = [];
const result = await dispatchOnce(async (...args) => {
attempted.push(args[5].jobId);
if (args[5].jobId === a.id) return { error: 'branch collision' };
expect(args[4]).toBe('owner-b');
return create(...args);
}, noopBroadcast);
expect(attempted).toEqual([a.id, b.id]);
expect(result.map(r => r.job.id)).toEqual([b.id]);
expect(a.state).toBe('todo');
expect(a.lastError).toBe('branch collision');
expect(Date.parse(a.lastErrorAt)).not.toBeNaN();
expect(c.state).toBe('todo');
expect([live, b].every(j => j.state === 'in-progress')).toBe(true);
expect(sessions.size).toBe(2);
});
it('tries every failing candidate once and leaves arrivals for the next pass', async () => {
boardSettings().maxPerRepo = 1;
for (let i = 0; i < 5; i++) addJob({ title: `fail${i}`, repoPath: REPO }, noopBroadcast);
const expected = allJobs().map(j => j.id);
const attempted = [];
expect(await dispatchOnce(async (...args) => {
attempted.push(args[5].jobId);
addJob({ title: 'arrival', repoPath: REPO }, noopBroadcast);
return { error: 'unavailable' };
}, noopBroadcast)).toEqual([]);
expect(attempted).toEqual(expected);
expect(allJobs().slice(0, 5).every(j => j.state === 'todo' && j.lastError === 'unavailable')).toBe(true);
});
it('rechecks queued candidates and a live worker adopted during a failed spawn', async () => {
boardSettings().maxPerRepo = 1;
const a = addJob({ title: 'A', repoPath: REPO }, noopBroadcast).job;
const b = addJob({ title: 'B', repoPath: REPO }, noopBroadcast).job;
const other = addJob({ title: 'other repo', repoPath: REPO2 }, noopBroadcast).job;
const calls = [];
await dispatchOnce(async (...args) => {
calls.push(args[5].jobId);
b.state = 'in-progress';
b.agentSessionId = 'adopted';
sessions.set('adopted', { id: 'adopted', exited: false });
other.paused = true;
return { error: 'failed' };
}, noopBroadcast);
expect(calls).toEqual([a.id]);
});
it.each(['deleted', 'paused', 'held', 'cap'])('abandons a spawn when %s, then advances only if eligible', async (change) => {
boardSettings().maxPerRepo = 1;
const a = addJob({ title: 'A', repoPath: REPO }, noopBroadcast).job;
const b = addJob({ title: 'B', repoPath: REPO }, noopBroadcast).job;
const create = fakeCreateSession([]);
const calls = [], killed = [];
await dispatchOnce(async (...args) => {
calls.push(args[5].jobId);
const result = await create(...args);
if (args[5].jobId === a.id) {
if (change === 'deleted') config.jobs = allJobs().filter(j => j !== a);
if (change === 'paused') a.paused = true;
if (change === 'held') a.holdUntil = new Date(Date.now() + 60000).toISOString();
if (change === 'cap') {
b.state = 'in-progress'; b.agentSessionId = 'adopted';
sessions.set('adopted', { id: 'adopted', exited: false });
}
}
return result;
}, noopBroadcast, { killSession: async id => { killed.push(id); sessions.delete(id); } });
expect(killed).toEqual(['session-1']);
expect(calls).toEqual(change === 'cap' ? [a.id] : [a.id, b.id]);
expect(sessions.size).toBe(1);
expect(a.agentSessionId).toBeNull();
});
it.each([false, true])('keeps the reservation when aborted cleanup fails or is absent (%s)', async (throws) => {
boardSettings().maxPerRepo = 1;
const a = addJob({ title: 'A', repoPath: REPO }, noopBroadcast).job;
addJob({ title: 'B', repoPath: REPO }, noopBroadcast);
const calls = [];
const create = fakeCreateSession(calls);
await dispatchOnce(async (...args) => {
const result = await create(...args);
a.title = 'edited';
return result;
}, noopBroadcast, throws ? { killSession: async () => { throw new Error('cleanup failed'); } } : {});
expect(calls).toHaveLength(1);
expect(sessions.size).toBe(1);
expect(allJobs().every(j => j.state === 'todo')).toBe(true);
});
it('abandons a spawn into a removed repo, skips its remaining cards, and advances another repo', async () => {
boardSettings().maxPerRepo = 1;
const a = addJob({ title: 'A', repoPath: REPO }, noopBroadcast).job;
const b = addJob({ title: 'B', repoPath: REPO }, noopBroadcast).job;
const c = addJob({ title: 'C', repoPath: REPO2 }, noopBroadcast).job;
const create = fakeCreateSession([]);
const attempted = [], killed = [];
const result = await dispatchOnce(async (...args) => {
attempted.push(args[5].jobId);
const spawned = await create(...args);
if (args[5].jobId === a.id) config.repos = [{ path: REPO2 }];
return spawned;
}, noopBroadcast, { killSession: async id => { killed.push(id); sessions.delete(id); } });
expect(attempted).toEqual([a.id, c.id]);
expect(killed).toEqual(['session-1']);
expect(result.map(r => r.job.id)).toEqual([c.id]);
expect([a, b].every(j => j.state === 'todo' && j.agentSessionId === null)).toBe(true);
expect(sessions.size).toBe(1);
});
it('does not persist or broadcast when every snapshot candidate is blocked by the live cap', async () => {
boardSettings().maxPerRepo = 1;
addJob({ title: 'live', repoPath: REPO }, noopBroadcast);
const create = fakeCreateSession([]);
await dispatchOnce(create, noopBroadcast);
const queued = addJob({ title: 'queued', repoPath: REPO }, noopBroadcast).job;
const before = readFileSync(join(process.env.AGENT007_CONFIG_DIR, 'config.json'), 'utf8');
const broadcasts = [], attempted = [];
expect(await dispatchOnce(fakeCreateSession(attempted), event => broadcasts.push(event))).toEqual([]);
expect(attempted).toEqual([]);
expect(broadcasts).toEqual([]);
expect(readFileSync(join(process.env.AGENT007_CONFIG_DIR, 'config.json'), 'utf8')).toBe(before);
expect(queued.state).toBe('todo');
});
it('clears a stale error once the job starts', async () => {
addJob({ title: 'flaky', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([], { fail: true }), noopBroadcast);
expect(allJobs()[0].lastError).toBeTruthy();
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
expect(allJobs()[0].state).toBe('in-progress');
expect(allJobs()[0].lastError).toBeNull();
});
it('skips jobs whose repo is no longer configured', async () => {
addJob({ title: 'orphaned repo', repoPath: '/does/not/exist' }, noopBroadcast);
const calls = [];
await dispatchOnce(fakeCreateSession(calls), noopBroadcast);
expect(calls).toHaveLength(0);
expect(allJobs()[0].state).toBe('todo');
});
});
describe('board mutations', () => {
beforeEach(resetBoard);
it('returning a job to todo unlinks the agent so it can be dispatched again', async () => {
addJob({ title: 'redo', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const id = allJobs()[0].id;
await moveJob(id, 'todo', noopBroadcast);
const job = allJobs()[0];
expect(job.state).toBe('todo');
expect(job.agentSessionId).toBeNull();
expect(job.agentName).toBeNull();
expect(job.startedAt).toBeNull();
});
it('holds a card sent back to todo for a minute, through edits, until it lapses or is dispatched now', async () => {
const { job } = addJob({ title: 'redo', repoPath: REPO }, noopBroadcast);
expect(job.holdUntil).toBeUndefined(); // a fresh card goes straight out
await moveJob(job.id, 'todo', noopBroadcast); // nor does a To do -> To do no-op hold it
expect(job.holdUntil).toBeUndefined();
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
expect(job.state).toBe('in-progress');
const before = Date.now();
await moveJob(job.id, 'todo', noopBroadcast);
const until = Date.parse(job.holdUntil);
expect(until - before).toBeGreaterThanOrEqual(59_000);
expect(until - Date.now()).toBeLessThanOrEqual(60_000);
expect(await dispatchOnce(fakeCreateSession([]), noopBroadcast)).toHaveLength(0);
// Editing is the point of the hold, so it does not lift it.
updateJob(job.id, { title: 'redo, better' }, noopBroadcast);
expect(job.holdUntil).not.toBeNull();
expect(await dispatchOnce(fakeCreateSession([]), noopBroadcast)).toHaveLength(0);
// Lapsed: dispatched like any card.
job.holdUntil = new Date(Date.now() - 1).toISOString();
expect(await dispatchOnce(fakeCreateSession([]), noopBroadcast)).toHaveLength(1);
expect(job.holdUntil).toBeNull();
// Dispatch now clears it.
await moveJob(job.id, 'todo', noopBroadcast);
expect(await dispatchOnce(fakeCreateSession([]), noopBroadcast)).toHaveLength(0);
releaseJobHold(job.id, noopBroadcast);
expect(job.holdUntil).toBeNull();
expect(await dispatchOnce(fakeCreateSession([]), noopBroadcast)).toHaveLength(1);
expect(releaseJobHold('nope', noopBroadcast).error).toMatch(/not found/i);
});
it('rejects an unknown state', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
expect((await moveJob(allJobs()[0].id, 'archived', noopBroadcast)).error).toMatch(/Unknown state/);
});
it('reports a missing job rather than throwing', async () => {
expect((await moveJob('nope', 'review', noopBroadcast)).error).toMatch(/not found/i);
expect((await deleteJob('nope', noopBroadcast)).error).toMatch(/not found/i);
});
it('clamps the cap and interval to sane bounds', () => {
expect(updateSettings({ maxPerRepo: 999 }, noopBroadcast).settings.maxPerRepo).toBe(10);
expect(updateSettings({ maxPerRepo: 0 }, noopBroadcast).settings.maxPerRepo).toBe(1);
expect(updateSettings({ intervalMs: 5 }, noopBroadcast).settings.intervalMs).toBe(30_000);
});
it('starts with the dispatcher running on a new install', () => {
delete config.jobBoard;
expect(boardSettings().running).toBe(true);
});
it('keeps a saved stopped dispatcher', () => {
config.jobBoard = { running: false };
expect(boardSettings().running).toBe(false);
});
it('derives live status onto the wire payload without persisting it', async () => {
addJob({ title: 'live', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
sessions.get('session-1').state = 'MESSAGE';
const payload = jobsPayload();
expect(payload.jobs[0].status).toBe('needs-input');
expect(payload.jobs[0].agentAlive).toBe(true);
// The stored record stays clean — status is a view of a live PTY.
expect(allJobs()[0].status).toBeUndefined();
});
});
// --- Closing the agent once its PR is open ---
describe('checkPullRequests closing the agent', () => {
beforeEach(resetBoard);
// Stub the gh lookup: these tests are about what happens once a PR is found,
// not about talking to GitHub.
const withPr = (pr) => async () => ({ pr });
async function dispatched() {
addJob({ title: 'shipped', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
return allJobs()[0];
}
it('moves the job to review and records the PR', async () => {
const job = await dispatched();
const findPr = withPr({ url: 'https://gh/o/r/pull/5', number: 5, isDraft: false });
const killed = [];
await checkPullRequests(noopBroadcast, { findPr, killSession: async (id) => killed.push(id) });
expect(job.state).toBe('review');
expect(job.prNumber).toBe(5);
expect(job.prUrl).toBe('https://gh/o/r/pull/5');
expect(Date.parse(job.reviewAt)).not.toBeNaN();
});
// Review is finished work with its agent kept on hand; Done retires it.
it('keeps the agent when the card moves to review', async () => {
const job = await dispatched();
const sid = job.agentSessionId;
const findPr = withPr({ url: 'u', number: 1 });
const killed = [];
await checkPullRequests(noopBroadcast, { findPr });
expect(job.state).toBe('review');
expect(job.agentSessionId).toBe(sid);
expect(sessions.has(sid)).toBe(true);
});
it('leaves a card that needs no PR to its agent, even when a PR exists', async () => {
addJob({ title: 'research', repoPath: REPO, requiresPr: false }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await checkPullRequests(noopBroadcast, { findPr: withPr({ url: 'u', number: 1 }) });
expect(job.state).toBe('in-progress');
});
it('keeps the credit on the card after the agent is gone', async () => {
// Requirement 3: who worked on it, and since when — a review card must
// still answer that once its agent has been retired.
const job = await dispatched();
const agentName = job.agentName, branch = job.branchName, started = job.startedAt;
const findPr = withPr({ url: 'u', number: 3 });
await checkPullRequests(noopBroadcast, { findPr });
expect(job.agentName).toBe(agentName);
expect(job.branchName).toBe(branch);
expect(job.startedAt).toBe(started);
});
it('frees the slot for the next queued job', async () => {
addJob({ title: 'first', repoPath: REPO }, noopBroadcast);
addJob({ title: 'second', repoPath: REPO }, noopBroadcast);
updateSettings({ maxPerRepo: 1 }, noopBroadcast);
const create = fakeCreateSession([]);
await dispatchOnce(create, noopBroadcast);
expect(allJobs()[1].state).toBe('todo'); // capped out
const findPr = withPr({ url: 'u', number: 4 });
await checkPullRequests(noopBroadcast, { findPr, killSession: async (id) => sessions.delete(id) });
await dispatchOnce(create, noopBroadcast);
expect(allJobs()[0].state).toBe('review');
expect(allJobs()[1].state).toBe('in-progress');
});
});
// --- Review -> Done, once the PR merges ---
describe('checkMergedPullRequests', () => {
beforeEach(resetBoard);
const merged = (pr) => async () => ({ pr });
// A job sitting in Review with a branch and an open PR, agent already gone —
// exactly the state the automatic path leaves behind.
async function inReview() {
addJob({ title: 'landed', repoPath: REPO, attachments: [{ name: 'shot.png', data: Buffer.from('png').toString('base64') }] }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: { url: 'https://gh/o/r/pull/5', number: 5 } }),
killSession: async (id) => sessions.delete(id),
});
return allJobs()[0];
}
it('takes a merged job off the board and records when it merged', async () => {
const job = await inReview();
const sid = job.agentSessionId;
const killed = [];
const finished = await checkMergedPullRequests(noopBroadcast, {
findMerged: merged({ url: 'https://gh/o/r/pull/5', number: 5, mergedAt: '2026-08-28T10:00:00Z' }),
killSession: async (id) => { killed.push(id); sessions.delete(id); },
});
// Merged means finished: the agent kept through Review goes now.
expect(killed).toEqual([sid]);
expect(finished).toHaveLength(1);
expect(job.state).toBe('done');
expect(job.prMergedAt).toBe('2026-08-28T10:00:00Z');
expect(Date.parse(job.doneAt)).not.toBeNaN();
// The record survives: the card is the only place the PR link lives.
expect(job.prUrl).toBe('https://gh/o/r/pull/5');
expect(job.prNumber).toBe(5);
// The files do not: the run they were input to is over.
expect(job.attachments).toEqual([]);
expect(existsSync(join(process.env.AGENT007_CONFIG_DIR, 'attachments', job.id))).toBe(false);
});
it('keeps the files while a re-adopted agent is live, frees them on a later sweep once it exits', async () => {
const job = await inReview();
// A live agent re-adopted onto the Review card: the sweep deliberately
// does not retire it, so its prompt's file paths must stay readable.
sessions.set('s-live', { id: 's-live', exited: false, branchName: job.branchName, repoPath: REPO });
job.agentSessionId = 's-live';
await checkMergedPullRequests(noopBroadcast, {
findMerged: merged({ url: 'https://gh/o/r/pull/5', number: 5, mergedAt: '2026-08-28T10:00:00Z' }),
});
expect(job.state).toBe('done');
expect(job.attachments).toHaveLength(1);
const dir = join(process.env.AGENT007_CONFIG_DIR, 'attachments', job.id);
expect(existsSync(dir)).toBe(true);
// Once the session ends, the next sweep's retry pass reclaims the disk —
// even though the done card is no longer a merge candidate.
sessions.get('s-live').exited = true;
await checkMergedPullRequests(noopBroadcast, { findMerged: merged(null) });
expect(job.attachments).toEqual([]);
expect(existsSync(dir)).toBe(false);
});
it('leaves a job in review while its PR is still open', async () => {
const job = await inReview();
await checkMergedPullRequests(noopBroadcast, { findMerged: merged(null) });
expect(job.state).toBe('review');
expect(job.prMergedAt).toBeNull();
});
it('never asks about a job that has not been dispatched', async () => {
addJob({ title: 'queued', repoPath: REPO }, noopBroadcast);
const asked = [];
await checkMergedPullRequests(noopBroadcast, {
findMerged: async (_r, b) => { asked.push(b); return { pr: null }; },
});
expect(asked).toHaveLength(0);
expect(allJobs()[0].state).toBe('todo');
});
it('finishes an in-progress job whose PR opened and merged between two scans', async () => {
// `gh pr list --state open` cannot see a merged PR, so checkPullRequests
// finds nothing and leaves the card in progress. A Review-only sweep would
// never look at it again: the card would sit there reading "agent gone"
// forever for work that had actually shipped.
addJob({ title: 'shipped and landed', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: null }) });
expect(job.state).toBe('in-progress');
const killed = [];
await checkMergedPullRequests(noopBroadcast, {
findMerged: merged({ url: 'https://gh/o/r/pull/4', number: 4, mergedAt: new Date().toISOString() }),
killSession: async (id) => killed.push(id),
});
expect(job.state).toBe('done');
expect(job.prNumber).toBe(4);
// Leaving in-progress is what the cap counts, so this agent has to go.
expect(killed).toEqual([sid]);
expect(Date.parse(job.reviewAt)).not.toBeNaN();
});
it('asks about this card\'s PR by number once it has one', async () => {
const job = await inReview();
let asked = null;
await checkMergedPullRequests(noopBroadcast, {
findMerged: async (_r, _b, opts) => { asked = opts; return { pr: null }; },
});
expect(asked.prNumber).toBe(5);
expect(asked.mergedAfter).toBe(job.reviewAt);
});
it('falls back to "merged after this attempt started" when there is no PR of record', async () => {
// A card moved to Review by hand has no prNumber, so the only thing that
// separates its merge from a previous job's on the same branch name is when
// the merge happened.
addJob({ title: 'by hand', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast);
let asked = null;
await checkMergedPullRequests(noopBroadcast, {
findMerged: async (_r, _b, opts) => { asked = opts; return { pr: null }; },
});
expect(asked.prNumber).toBeNull();
expect(asked.mergedAfter).toBe(job.reviewAt);
});
it('leaves a finished job finished — the sweep never sees it again', async () => {
// Done is terminal, so a swept card cannot come back and be re-swept. The
// state filter is what enforces it; prMergedAt is a second belt.
const job = await inReview();
const findMerged = merged({ url: 'u', number: 5, mergedAt: '2026-08-28T10:00:00Z' });
await checkMergedPullRequests(noopBroadcast, { findMerged });
expect(job.state).toBe('done');
const doneAt = job.doneAt;
const finished = await checkMergedPullRequests(noopBroadcast, { findMerged });
expect(finished).toEqual([]);
expect(job.state).toBe('done');
expect(job.doneAt).toBe(doneAt);
});
it('leaves the agent alone — a re-adopted agent may be working the review', async () => {
// This runs every scan, unlike the one-shot retirement at the PR. Killing
// here would make Review permanently hostile to working on your own PR.
const job = await inReview();
job.agentSessionId = 'session-99';
sessions.set('session-99', { id: 'session-99', exited: false, branchName: job.branchName, repoPath: REPO });
await checkMergedPullRequests(noopBroadcast, { findMerged: merged({ url: 'u', number: 5 }) });
expect(job.state).toBe('done');
expect(sessions.get('session-99').exited).toBe(false);
});
it('says so on the card when it cannot check, and clears the note once it can', async () => {
const job = await inReview();
await checkMergedPullRequests(noopBroadcast, {
findMerged: async () => ({ pr: null, error: 'Could not resolve to a Repository' }),
});
expect(job.state).toBe('review');
expect(job.prCheckError).toMatch(/Could not resolve to a Repository/);
await checkMergedPullRequests(noopBroadcast, { findMerged: merged(null) });
expect(job.prCheckError).toBeNull();
});
it('does not apply a merge to a job that moved while the lookup was in flight', async () => {
const job = await inReview();
const findMerged = async () => {
await moveJob(job.id, 'in-progress', noopBroadcast);
return { pr: { url: 'u', number: 5, mergedAt: '2026-08-28T10:00:00Z' } };
};
await checkMergedPullRequests(noopBroadcast, { findMerged });
expect(job.state).toBe('in-progress');
expect(job.prMergedAt).toBeNull();
});
it('does not re-stamp the note while the same failure repeats', async () => {
// A permanent failure recurs every scan; it must not churn config.json.
const job = await inReview();
const failing = async () => ({ pr: null, error: 'gh: not authenticated' });
await checkMergedPullRequests(noopBroadcast, { findMerged: failing });
const firstStamp = job.prCheckErrorAt;
await checkMergedPullRequests(noopBroadcast, { findMerged: failing });
expect(job.prCheckErrorAt).toBe(firstStamp);
});
it('stamps prMergedAt even when the merged PR reports no mergedAt', async () => {
// parseMergedPr accepts a PR carrying only state:MERGED, so this shape is
// reachable, and the archive reads prMergedAt to say when the work landed.
// A null there would leave a merged card claiming only "finished".
const job = await inReview();
await checkMergedPullRequests(noopBroadcast, { findMerged: merged({ url: 'u', number: 5, mergedAt: null }) });
expect(Date.parse(job.prMergedAt)).not.toBeNaN();
});
it('never asks about a review job that has no branch', async () => {
// A job moved to Review by hand from To do has no branch to query.
addJob({ title: 'branchless', repoPath: REPO }, noopBroadcast);
const job = allJobs()[0];
job.state = 'review';
const asked = [];
await checkMergedPullRequests(noopBroadcast, {
findMerged: async (_r, b) => { asked.push(b); return { pr: null }; },
});
expect(asked).toHaveLength(0);
expect(job.state).toBe('review');
});
it('does not resurrect a job deleted while the lookup was in flight', async () => {
const job = await inReview();
const findMerged = async () => {
await deleteJob(job.id, noopBroadcast);
return { pr: { url: 'u', number: 5, mergedAt: '2026-08-28T10:00:00Z' } };
};
const finished = await checkMergedPullRequests(noopBroadcast, { findMerged });
expect(allJobs()).toHaveLength(0);
expect(finished).toHaveLength(0);
});
it('does not apply a merge found for a branch the job no longer has', async () => {
const job = await inReview();
const findMerged = async () => {
job.branchName = 'bill/something-else';
return { pr: { url: 'u', number: 5, mergedAt: '2026-08-28T10:00:00Z' } };
};
await checkMergedPullRequests(noopBroadcast, { findMerged });
expect(job.state).toBe('review');
expect(job.prMergedAt).toBeNull();
});
it('finishes a job that opens and merges within one scan', async () => {
// This is why the sweeps run in this order: PRs found, then merges swept.
// Reversed, such a job would sit in Review for a whole extra interval.
addJob({ title: 'fast', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await runScan(fakeCreateSession([]), noopBroadcast, {
findPr: async () => ({ pr: { url: 'https://gh/o/r/pull/9', number: 9 } }),
findMerged: merged({ url: 'https://gh/o/r/pull/9', number: 9, mergedAt: '2026-08-28T10:00:00Z' }),
killSession: async (id) => sessions.delete(id),
});
expect(job.state).toBe('done');
expect(job.prNumber).toBe(9);
});
it('re-spawns restart orphans before it dispatches, so they get their slot first', async () => {
addJob({ title: 'queued', repoPath: REPO }, noopBroadcast);
const order = [];
const calls = [];
const create = fakeCreateSession(calls);
await runScan(async (...args) => { order.push('dispatch'); return create(...args); }, noopBroadcast, {
findPr: async () => ({ pr: null }),
findMerged: merged(null),
respawnWorkers: async () => { order.push('respawn'); },
});
expect(order).toEqual(['respawn', 'dispatch']);
});
it('runs as part of a scan, so a merged PR finishes its job unattended', async () => {
const job = await inReview();
await runScan(fakeCreateSession([]), noopBroadcast, {
findPr: async () => ({ pr: null }),
findMerged: merged({ url: 'u', number: 5, mergedAt: '2026-08-28T10:00:00Z' }),
});
expect(job.state).toBe('done');
});
});
// --- Finishing a job by hand ---
describe('moving a job to done', () => {
beforeEach(resetBoard);
it('stamps doneAt and retires any agent still attached', async () => {
addJob({ title: 'finish me', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
const killed = [];
await moveJob(job.id, 'done', noopBroadcast, { killSession: async (id) => killed.push(id) });
expect(job.state).toBe('done');
expect(Date.parse(job.doneAt)).not.toBeNaN();
expect(killed).toEqual([sid]);
});
it('refuses every move out of done, and touches nothing when it does', async () => {
// Done is terminal. Letting a card back onto the board carried its spent PR
// of record into the new attempt, so the sweep re-matched that same old
// merge on the next scan and filed the card away again — killing any agent
// re-adopted on the branch, because finishing from in-progress retires one.
addJob({ title: 'still working', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: { url: 'u', number: 1 } }),
killSession: async (id) => sessions.delete(id),
});
// A fresh agent is re-adopted onto the shipped branch.
job.agentSessionId = 'session-readopted';
sessions.set('session-readopted', { id: 'session-readopted', exited: false, branchName: job.branchName, repoPath: REPO });
await checkMergedPullRequests(noopBroadcast, {
findMerged: async () => ({ pr: { url: 'u', number: 1, mergedAt: '2026-08-28T10:00:00Z' } }),
});
expect(job.state).toBe('done');
const doneAt = job.doneAt;
const killed = [];
for (const state of ['review', 'in-progress', 'todo']) {
const res = await moveJob(job.id, state, noopBroadcast, { killSession: async (id) => killed.push(id) });
expect(res.error).toMatch(/finished/);
expect(job.state).toBe('done');
}
// A refused move is a no-op: no agent closed, no stamp disturbed.
expect(killed).toEqual([]);
expect(job.doneAt).toBe(doneAt);
expect(job.prNumber).toBe(1);
expect(sessions.get('session-readopted').exited).toBe(false);
});
it('still retires the agent when an in-progress job is requeued', async () => {
// Requeueing means start over, so the agent and its worktree do go.
addJob({ title: 'start over', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
const killed = [];
await moveJob(job.id, 'todo', noopBroadcast, { killSession: async (id) => killed.push(id) });
expect(killed).toEqual([sid]);
});
it('leaves prMergedAt null, so the card says "finished" and not "merged"', async () => {
// Finishing by hand is not a claim about GitHub, and the archive reads the
// difference off this field.
addJob({ title: 'by hand', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'done', noopBroadcast);
expect(job.prMergedAt).toBeNull();
expect(Date.parse(job.doneAt)).not.toBeNaN();
});
it('clears the "cannot check" note when the user moves the card by hand', async () => {
// The note ends with "move it by hand" — following it must not leave the
// card in the archive still carrying the instruction.
addJob({ title: 'unreachable', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: null, error: 'Could not resolve to a Repository' }),
});
expect(job.prCheckError).toBeTruthy();
await moveJob(job.id, 'done', noopBroadcast);
expect(job.prCheckError).toBeNull();
expect(job.prCheckErrorAt).toBeNull();
});
it('cannot be requeued — follow-up work is a new job', async () => {
// The archive is the record of what shipped. Reusing the card would mean
// either carrying a spent PR into a new attempt or erasing that record.
addJob({ title: 'redo', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'done', noopBroadcast);
const doneAt = job.doneAt;
const { error } = await moveJob(job.id, 'todo', noopBroadcast);
expect(error).toMatch(/finished/);
expect(job.state).toBe('done');
expect(job.doneAt).toBe(doneAt);
});
});
// --- Kept Review agents do not hold cap slots ---
describe('agents do not pile up across many jobs', () => {
beforeEach(resetBoard);
it('never runs more agents than the cap, and drains the whole queue', async () => {
// The cap counts in-progress jobs. Agents kept with their cards in Review
// are idle and do not count, so this measures the agents on In progress
// cards: if the cap ever counted wrong, more than two would be working.
updateSettings({ maxPerRepo: 2 }, noopBroadcast);
for (let i = 0; i < 8; i++) addJob({ title: `J${i}`, repoPath: REPO }, noopBroadcast);
const create = fakeCreateSession([]);
const findPr = async () => ({ pr: { url: 'u', number: 1 } });
const kill = async (id) => { sessions.delete(id); };
let peak = 0;
for (let cycle = 0; cycle < 6; cycle++) {
await dispatchOnce(create, noopBroadcast);
peak = Math.max(peak, allJobs().filter(j => j.state === 'in-progress' && sessions.has(j.agentSessionId)).length);
await checkPullRequests(noopBroadcast, { findPr });
}
expect(peak).toBeLessThanOrEqual(2);
expect(allJobs().filter(j => j.state === 'review')).toHaveLength(8);
// Done is what retires them.
for (const job of allJobs()) await moveJob(job.id, 'done', noopBroadcast, { killSession: kill });
expect([...sessions.values()].filter(s => !s.exited)).toHaveLength(0);
});
});
// --- Branch naming through a real dispatch ---
describe('branch naming on dispatch', () => {
beforeEach(resetBoard);
it('asks for a branch derived from the job title, with collision fallback', async () => {
addJob({ title: 'Add rate limiting!', repoPath: REPO }, noopBroadcast);
const calls = [];
await dispatchOnce(fakeCreateSession(calls), noopBroadcast);
// createSession(command, name, repoPath, customBranch, ownerId, meta)
expect(calls[0].branch).toBe('add-rate-limiting');
// Two jobs may share a title, so a taken branch must take a suffix rather
// than failing the dispatch outright.
expect(calls[0].meta.branchSuffixOnCollision).toBe(true);
});
it('runs the agent in the board default permission mode', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
const calls = [];
await dispatchOnce(fakeCreateSession(calls), noopBroadcast);
expect(calls[0].command).toContain(`--permission-mode ${DEFAULT_PERMISSION_MODE}`);
});
});
// --- Regressions found by /review ---
describe('concurrent scans', () => {
beforeEach(resetBoard);
it('never dispatches the same job twice when two scans overlap', async () => {
// The "Run now" button and the interval timer both scan. dispatchOnce reads
// job.state to pick candidates, awaits createSession, and only then sets
// state='in-progress' — so without a guard both scans see the same job as
// todo and spawn an agent for it: two worktrees, two branches, and one
// orphaned agent the board never cleans up.
addJob({ title: 'only once', repoPath: REPO }, noopBroadcast);
const calls = [];
const slowCreate = (() => {
const inner = fakeCreateSession(calls);
return async (...args) => {
await new Promise(r => setTimeout(r, 20)); // widen the window
return inner(...args);
};
})();
const [a, b] = await Promise.all([
runScan(slowCreate, noopBroadcast),
runScan(slowCreate, noopBroadcast),
]);
expect(calls).toHaveLength(1);
expect([a.skipped, b.skipped].filter(Boolean)).toHaveLength(1); // one bounced
expect(allJobs().filter(j => j.state === 'in-progress')).toHaveLength(1);
});
it('releases the guard so a later scan still runs', async () => {
addJob({ title: 'first', repoPath: REPO }, noopBroadcast);
const create = fakeCreateSession([]);
await runScan(create, noopBroadcast);
addJob({ title: 'second', repoPath: REPO }, noopBroadcast);
const r = await runScan(create, noopBroadcast);
expect(r.skipped).toBe(false);
expect(allJobs().filter(j => j.state === 'in-progress')).toHaveLength(2);
});
it('releases the guard even when a scan throws', async () => {
addJob({ title: 'boom', repoPath: REPO }, noopBroadcast);
const exploding = async () => { throw new Error('spawn exploded'); };
await expect(runScan(exploding, noopBroadcast)).rejects.toThrow('spawn exploded');
const r = await runScan(fakeCreateSession([]), noopBroadcast);
expect(r.skipped).toBe(false);
});
});
describe('requeueing a job retires its agent', () => {
beforeEach(resetBoard);
it('closes the running agent so it cannot escape the cap', async () => {
// Unlinking without killing left a live agent no job pointed at: it stopped
// counting toward the cap, so the board dispatched a replacement alongside
// it and repeating the move walked straight past the cap.
addJob({ title: 'redo me', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
const killed = [];
await moveJob(job.id, 'todo', noopBroadcast, { killSession: async (id) => { killed.push(id); sessions.delete(id); } });
expect(killed).toEqual([sid]);
expect(job.state).toBe('todo');
expect(job.agentSessionId).toBeNull();
});
it('keeps the cap honest across repeated requeues', async () => {
updateSettings({ maxPerRepo: 1 }, noopBroadcast);
addJob({ title: 'flip flop', repoPath: REPO }, noopBroadcast);
const create = fakeCreateSession([]);
const kill = async (id) => { sessions.delete(id); };
for (let i = 0; i < 4; i++) {
await dispatchOnce(create, noopBroadcast);
await moveJob(allJobs()[0].id, 'todo', noopBroadcast, { killSession: kill });
}
expect([...sessions.values()].filter(s => !s.exited)).toHaveLength(0);
});
it('keeps the agent on a manual move to review, and retires it on done', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
const killed = [];
const killSession = async (id) => { killed.push(id); sessions.delete(id); };
await moveJob(job.id, 'review', noopBroadcast, { killSession, findPr: async () => ({ pr: null }) });
expect(killed).toEqual([]);
expect(job.agentSessionId).toBe(sid);
await moveJob(job.id, 'done', noopBroadcast, { killSession });
expect(killed).toEqual([sid]);
});
});
// --- Regressions found by external review (gemini) ---
describe('the job can change while a dispatch is in flight', () => {
beforeEach(resetBoard);
// createSession takes seconds; WebSocket handlers run during it. The scan
// guard serialises scans against each other, not against the user.
function createDuring(mutate, calls = []) {
const inner = fakeCreateSession(calls);
return async (...args) => {
const result = await inner(...args);
mutate(); // the user acts while the spawn completes
return result;
};
}
it('does not claim a job that was deleted mid-dispatch, and kills the agent', async () => {
addJob({ title: 'gone', repoPath: REPO }, noopBroadcast);
const jobId = allJobs()[0].id;
const killed = [];
const create = createDuring(() => { allJobs().length = 0; });
await dispatchOnce(create, noopBroadcast, {
killSession: async (id) => { killed.push(id); sessions.delete(id); },
});
// Without the re-check the agent runs on with no card pointing at it:
// invisible to the board, uncounted by the cap, never cleaned up.
expect(killed).toHaveLength(1);
expect([...sessions.values()].filter(s => !s.exited)).toHaveLength(0);
expect(allJobs().find(j => j.id === jobId)).toBeUndefined();
});
it('does not overwrite a state the user changed mid-dispatch', async () => {
addJob({ title: 'requeued', repoPath: REPO }, noopBroadcast);
const job = allJobs()[0];
const create = createDuring(() => { job.state = 'review'; });
await dispatchOnce(create, noopBroadcast, { killSession: async (id) => sessions.delete(id) });
expect(job.state).toBe('review'); // not clobbered back to in-progress
expect(job.agentSessionId).toBeNull();
});
it('does not apply a PR result to a job that moved on during the lookup', async () => {
addJob({ title: 'racy', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const killed = [];
const findPr = async () => {
// The user requeues while the gh lookup is in flight.
job.state = 'todo';
job.branchName = null;
job.agentSessionId = null;
return { pr: { url: 'u', number: 1 } };
};
await checkPullRequests(noopBroadcast, { findPr, killSession: async (id) => killed.push(id) });
expect(job.state).toBe('todo'); // the user's action stands
expect(killed).toEqual([]); // and no unrelated agent was killed
});
});
describe('deleting a job', () => {
beforeEach(resetBoard);
it('retires its agent instead of leaking it', async () => {
addJob({ title: 'delete me', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const sid = allJobs()[0].agentSessionId;
const killed = [];
await deleteJob(allJobs()[0].id, noopBroadcast, { killSession: async (id) => { killed.push(id); sessions.delete(id); } });
expect(killed).toEqual([sid]);
expect(allJobs()).toHaveLength(0);
});
});
describe('moveJob guards', () => {
beforeEach(resetBoard);
it('refuses to strand a never-dispatched job in in-progress', async () => {
// Nothing could ever move it out again: the dispatcher only looks at todo,
// the PR watcher only at jobs with a branch.
addJob({ title: 'never started', repoPath: REPO }, noopBroadcast);
const r = await moveJob(allJobs()[0].id, 'in-progress', noopBroadcast);
expect(r.error).toMatch(/never been dispatched/i);
expect(allJobs()[0].state).toBe('todo');
});
it('still allows review -> in-progress for a dispatched job', async () => {
addJob({ title: 'real', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
await moveJob(allJobs()[0].id, 'review', noopBroadcast);
const r = await moveJob(allJobs()[0].id, 'in-progress', noopBroadcast);
expect(r.error).toBeUndefined();
expect(allJobs()[0].state).toBe('in-progress');
});
});
// --- A PR check that cannot run must say so ---
describe('when the PR check itself fails', () => {
beforeEach(resetBoard);
const failing = (msg) => async () => ({ pr: null, error: msg });
it('records the reason on the card instead of looking like a quiet agent', async () => {
// Observed live: `gh pr list` returned "Could not resolve to a Repository"
// because the signed-in account could not see that org. Every job in that
// repo stalled forever, and the only symptom was a card reading
// "quiet — may need you", which points at the agent, not the real cause.
addJob({ title: 'invisible repo', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
await checkPullRequests(noopBroadcast, {
findPr: failing("Could not resolve to a Repository with the name 'org/repo'"),
});
const job = allJobs()[0];
expect(job.state).toBe('in-progress'); // it cannot advance, correctly
expect(job.prCheckError).toMatch(/Cannot check for a pull request/i);
expect(job.prCheckError).toMatch(/Could not resolve to a Repository/);
});
it('does not rewrite the job while the same failure repeats', async () => {
// A permanent failure recurs every five minutes; it must not churn config.
addJob({ title: 'repeat', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const findPr = failing('gh: not authenticated');
await checkPullRequests(noopBroadcast, { findPr });
const firstStamp = allJobs()[0].prCheckErrorAt;
await checkPullRequests(noopBroadcast, { findPr });
expect(allJobs()[0].prCheckErrorAt).toBe(firstStamp);
});
it('clears the note as soon as the check works again, PR or not', async () => {
// The failure the previous version had: it only cleared on a PR being
// found, so a repo that regained access kept claiming it was unreachable
// until a PR happened to appear.
addJob({ title: 'access restored', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
await checkPullRequests(noopBroadcast, { findPr: failing('gh: not authenticated') });
expect(allJobs()[0].prCheckError).toBeTruthy();
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: null }) });
const job = allJobs()[0];
expect(job.state).toBe('in-progress'); // still no PR, correctly
expect(job.prCheckError).toBeNull(); // but no longer claiming it cannot look
});
it('leaves a restart note alone when the check works', async () => {
// A restart note names where the work is. It stays true whether or not the
// PR check succeeds, so only the PR-check note may be cleared.
addJob({ title: 'restarted', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
job.lastError = 'Server restarted — agent lost. Work is on bill/x.';
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: null }) });
expect(allJobs()[0].lastError).toMatch(/Server restarted/);
});
it('does not write an error onto a job that moved on during the lookup', async () => {
// findPr is a network call; WebSocket handlers run during it.
addJob({ title: 'requeued mid-check', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await checkPullRequests(noopBroadcast, {
findPr: async () => {
job.state = 'todo';
job.branchName = null;
job.agentSessionId = null;
return { pr: null, error: 'gh: not authenticated' };
},
});
expect(job.state).toBe('todo');
expect(job.prCheckError).toBeNull();
});
it('clears the note once the check succeeds', async () => {
addJob({ title: 'recovers', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
await checkPullRequests(noopBroadcast, { findPr: failing('gh: not authenticated') });
expect(allJobs()[0].prCheckError).toBeTruthy();
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: { url: 'u', number: 4 } }),
killSession: async (id) => sessions.delete(id),
});
const job = allJobs()[0];
expect(job.state).toBe('review');
expect(job.prCheckError).toBeNull();
});
it('an empty result is still just "no PR yet", not an error', async () => {
addJob({ title: 'not ready', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: null }) });
const job = allJobs()[0];
expect(job.state).toBe('in-progress');
expect(job.lastError).toBeNull();
});
});
// --- Reconnecting a re-adopted agent to its job ---
describe('relinkSessionToJob', () => {
beforeEach(resetBoard);
// What a restart leaves behind: the card keeps its branch, the session link is
// gone, and re-adopting the orphan produces a brand new session.
async function afterRestart() {
addJob({ title: 'interrupted', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const branch = job.branchName;
sessions.clear();
job.agentSessionId = null;
job.agentName = null;
job.lastError = 'Server restarted — agent lost.';
return { job, branch };
}
it('reconnects the agent to its card, matched on the branch', async () => {
const { job, branch } = await afterRestart();
const readopted = { id: 'session-99', name: 'Mirage', repoPath: REPO, branchName: branch, exited: false };
sessions.set(readopted.id, readopted);
const linked = relinkSessionToJob(readopted, noopBroadcast);
expect(linked).toBe(job);
expect(job.agentSessionId).toBe('session-99');
expect(job.agentName).toBe('Mirage');
expect(job.lastError).toBeNull();
// Tagged so the PR path can retire it like any other board agent.
expect(readopted.jobId).toBe(job.id);
});
it('makes the job count toward the cap again', async () => {
// Until it is relinked the job occupies no slot, so the board would happily
// dispatch a second agent for the same repo alongside the one that is back.
const { branch } = await afterRestart();
updateSettings({ maxPerRepo: 1 }, noopBroadcast);
addJob({ title: 'next up', repoPath: REPO }, noopBroadcast);
const readopted = { id: 'session-99', name: 'Mirage', repoPath: REPO, branchName: branch, exited: false };
sessions.set(readopted.id, readopted);
relinkSessionToJob(readopted, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
expect(allJobs()[1].state).toBe('todo'); // capped by the reconnected agent
});
it('never steals a job that already has a live agent', async () => {
addJob({ title: 'busy', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const original = job.agentSessionId;
const impostor = { id: 'session-99', name: 'Other', repoPath: REPO, branchName: job.branchName, exited: false };
expect(relinkSessionToJob(impostor, noopBroadcast)).toBeNull();
expect(job.agentSessionId).toBe(original);
});
it('ignores a session whose branch matches no job, or a different repo', async () => {
const { branch } = await afterRestart();
expect(relinkSessionToJob({ id: 's', name: 'X', repoPath: REPO, branchName: 'unrelated' }, noopBroadcast)).toBeNull();
expect(relinkSessionToJob({ id: 's', name: 'X', repoPath: REPO2, branchName: branch }, noopBroadcast)).toBeNull();
expect(relinkSessionToJob({ id: 's', name: 'X', repoPath: REPO }, noopBroadcast)).toBeNull();
expect(relinkSessionToJob(null, noopBroadcast)).toBeNull();
});
});
describe('a PR-check failure and another note coexist', () => {
beforeEach(resetBoard);
it('keeps the restart note AND records why the check cannot run', async () => {
// Both are true at once: the agent is gone, and the board cannot see this
// repo's pull requests. Sharing one field meant clobbering one or hiding
// the other, so they get their own fields.
addJob({ title: 'restarted', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
job.lastError = 'Server restarted — agent lost. Work is on bill/x.';
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: null, error: 'gh: not authenticated' }),
});
expect(allJobs()[0].lastError).toMatch(/Server restarted/);
expect(allJobs()[0].prCheckError).toMatch(/Cannot check for a pull request/);
});
it('clearing the PR-check note leaves the other one alone', async () => {
addJob({ title: 'both', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
job.lastError = 'Server restarted — agent lost. Work is on bill/x.';
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: null, error: 'gh: boom' }) });
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: null }) });
expect(allJobs()[0].prCheckError).toBeNull();
expect(allJobs()[0].lastError).toMatch(/Server restarted/);
});
});
// --- Agents left running after their job already shipped ---
describe('retiring the agent when the link is missing', () => {
beforeEach(resetBoard);
// The live shape this fixes: a restart nulls agentSessionId, so the PR is
// found with nothing recorded to retire. Before, the agent kept running and
// holding a worktree for work that had already shipped.
async function unlinkedButRunning() {
addJob({ title: 'restarted then shipped', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const branch = job.branchName;
sessions.clear();
job.agentSessionId = null;
job.agentName = null;
const running = { id: 'session-99', name: 'Ghost', repoPath: REPO, branchName: branch, exited: false };
sessions.set(running.id, running);
return { job, running };
}
it('finds the agent by branch and retires it as the merged job moves to done', async () => {
const { job, running } = await unlinkedButRunning();
const killed = [];
await checkMergedPullRequests(noopBroadcast, {
findMerged: async () => ({ pr: { url: 'u', number: 42, mergedAt: '2026-08-28T10:00:00Z' } }),
killSession: async (id) => { killed.push(id); sessions.delete(id); },
});
expect(killed).toEqual([running.id]);
expect(job.state).toBe('done');
expect(job.agentName).toBe('Ghost'); // credit recorded before it went
expect(job.agentSessionId).toBeNull();
});
it('does not touch a session on the same branch in a different repo', async () => {
const { running } = await unlinkedButRunning();
running.repoPath = REPO2;
const killed = [];
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: { url: 'u', number: 42 } }),
killSession: async (id) => killed.push(id),
});
expect(killed).toEqual([]);
});
it('keeps the link when the kill fails, so the agent stays reachable', async () => {
addJob({ title: 'stubborn', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: { url: 'u', number: 42 } }),
killSession: async () => { throw new Error('worktree busy'); },
});
expect(job.state).toBe('review'); // the PR is open either way
expect(job.agentSessionId).toBe(sid); // not nulled into a zombie
});
it('never sweeps agents on jobs that are ALREADY in review', async () => {
// An agent re-adopted on a shipped branch to address review comments is the
// user's. A recurring sweep would kill it every five minutes.
const { job, running } = await unlinkedButRunning();
job.state = 'review';
job.prNumber = 42;
const killed = [];
await runScan(fakeCreateSession([]), noopBroadcast, {
findPr: async () => ({ pr: { url: 'u', number: 42 } }),
killSession: async (id) => killed.push(id),
});
expect(killed).toEqual([]);
expect(sessions.has(running.id)).toBe(true);
});
});
describe('relink covers a job that already reached review', () => {
beforeEach(resetBoard);
it('prefers a job still in progress over an older one already in review', async () => {
addJob({ title: 'old, shipped', repoPath: REPO }, noopBroadcast);
addJob({ title: 'new, running', repoPath: REPO }, noopBroadcast);
const [older, newer] = allJobs();
older.state = 'review';
older.branchName = 'bill/shared';
newer.state = 'in-progress';
newer.branchName = 'bill/shared';
const readopted = { id: 'session-99', name: 'Ghost', repoPath: REPO, branchName: 'bill/shared', exited: false };
expect(relinkSessionToJob(readopted, noopBroadcast)).toBe(newer);
expect(older.agentSessionId).toBeNull();
});
it('reconnects an agent whose job moved to review while it was gone', async () => {
addJob({ title: 'shipped during restart', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const branch = job.branchName;
sessions.clear();
job.agentSessionId = null;
job.agentName = null;
job.state = 'review';
const readopted = { id: 'session-99', name: 'Ghost', repoPath: REPO, branchName: branch, exited: false };
expect(relinkSessionToJob(readopted, noopBroadcast)).toBe(job);
expect(job.agentSessionId).toBe('session-99');
});
});
// --- A finished card must say what it produced ---
describe('review cards keep their record', () => {
beforeEach(resetBoard);
it('clears the restart note once the PR is found', async () => {
// The note says the board is still watching for this PR. Finding it makes
// the note false on its own card.
addJob({ title: 'restarted then shipped', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
job.lastError = 'Server restarted — agent lost. The board is still watching for its PR.';
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: { url: 'u', number: 102 } }),
killSession: async (id) => sessions.delete(id),
});
expect(job.state).toBe('review');
expect(job.prNumber).toBe(102);
expect(job.lastError).toBeNull();
});
it('a manual move to review picks up the PR that already exists', async () => {
// Nothing else backfills it: the watcher only examines in-progress jobs, so
// the card would sit in Review with no link to what it produced.
addJob({ title: 'shipped elsewhere', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast, {
killSession: async (id) => sessions.delete(id),
findPr: async () => ({ pr: { url: 'https://gh/o/r/pull/18', number: 18 } }),
});
expect(job.state).toBe('review');
expect(job.prNumber).toBe(18);
expect(job.prUrl).toBe('https://gh/o/r/pull/18');
});
it('a manual move still works when there is no PR to find', async () => {
addJob({ title: 'no pr', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast, {
killSession: async (id) => sessions.delete(id),
findPr: async () => ({ pr: null, error: 'gh: not authenticated' }),
});
expect(job.state).toBe('review');
expect(job.prNumber).toBeNull();
});
it('does not re-look-up a PR the card already has', async () => {
addJob({ title: 'already linked', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
job.prNumber = 7;
let looked = false;
await moveJob(job.id, 'review', noopBroadcast, {
killSession: async (id) => sessions.delete(id),
findPr: async () => { looked = true; return { pr: null }; },
});
expect(looked).toBe(false);
expect(job.prNumber).toBe(7);
});
});
describe('a manual move survives a bad PR lookup', () => {
beforeEach(resetBoard);
it('still moves, keeping the agent, when the lookup throws', async () => {
// The lookup runs before persist; a throw there would leave the job
// changed in memory and never saved.
addJob({ title: 'lookup explodes', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
const killed = [];
const result = await moveJob(job.id, 'review', noopBroadcast, {
killSession: async (id) => { killed.push(id); sessions.delete(id); },
findPr: async () => { throw new Error('network down'); },
});
expect(result.error).toBeUndefined();
expect(job.state).toBe('review');
expect(killed).toEqual([]); // Review keeps its agent
expect(job.agentSessionId).toBe(sid);
expect(job.prNumber).toBeNull(); // no link, as before
});
it('clears the agent link once the agent is actually gone', async () => {
addJob({ title: 'linked then retired', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'done', noopBroadcast, {
killSession: async (id) => sessions.delete(id),
});
expect(job.agentSessionId).toBeNull();
});
it('keeps the link when the kill fails, so the agent stays reachable', async () => {
addJob({ title: 'stubborn agent', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
await moveJob(job.id, 'done', noopBroadcast, {
killSession: async () => { throw new Error('worktree busy'); },
});
expect(job.agentSessionId).toBe(sid);
});
});
describe('moving to done when the agent is already gone', () => {
beforeEach(resetBoard);
it('drops the dead link rather than leaving a stale id on the card', async () => {
// A stale id is how a finished card outlived a restart and then resolved to
// an unrelated agent in the next process generation.
addJob({ title: 'agent already gone', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
sessions.delete(job.agentSessionId);
const killed = [];
await moveJob(job.id, 'done', noopBroadcast, {
killSession: async (id) => killed.push(id),
});
expect(killed).toEqual([]); // nothing to kill
expect(job.agentSessionId).toBeNull(); // but the link still goes
});
it('closes an agent whose CLI exited, so its tab and saved session do not outlive the card', async () => {
// Only unlinked, it stayed in activeSessions, and the next restart made it
// an orphan of a finished card (test/worker-lifecycle.test.js).
addJob({ title: 'agent already exited', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const sid = job.agentSessionId;
sessions.get(sid).exited = true;
const killed = [];
await moveJob(job.id, 'done', noopBroadcast, {
killSession: async (id) => { killed.push(id); sessions.delete(id); },
});
expect(killed).toEqual([sid]);
expect(job.agentSessionId).toBeNull();
});
});
describe('requeueing clears every per-attempt field', () => {
beforeEach(resetBoard);
it('drops the PR-check note along with the branch and PR', async () => {
// The note describes an attempt that no longer exists; carrying it onto a
// fresh To do card reports a failure against work not yet tried.
addJob({ title: 'unreachable repo', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
await checkPullRequests(noopBroadcast, {
findPr: async () => ({ pr: null, error: 'Could not resolve to a Repository' }),
});
const job = allJobs()[0];
expect(job.prCheckError).toBeTruthy();
await moveJob(job.id, 'todo', noopBroadcast, { killSession: async (id) => sessions.delete(id) });
expect(job.state).toBe('todo');
expect(job.prCheckError).toBeNull();
expect(job.prCheckErrorAt).toBeNull();
expect(job.branchName).toBeNull();
expect(job.prNumber).toBeNull();
});
});
describe('a manual move that races a requeue', () => {
beforeEach(resetBoard);
it('does not leave PR links on a card that went back to To do', async () => {
// findPr is a network call; a requeue during it would otherwise resume and
// write prUrl/prNumber onto a job that is now todo.
addJob({ title: 'raced', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast, {
killSession: async (id) => sessions.delete(id),
findPr: async () => {
job.state = 'todo'; // the user requeues mid-lookup
return { pr: { url: 'u', number: 55 } };
},
});
expect(job.state).toBe('todo');
expect(job.prNumber).toBeNull();
expect(job.prUrl).toBeNull();
});
it('clears a stale PR-check note when the manual move finds the PR', async () => {
addJob({ title: 'had a failure', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
job.prCheckError = 'Cannot check for a pull request here — gh: not authenticated.';
await moveJob(job.id, 'review', noopBroadcast, {
killSession: async (id) => sessions.delete(id),
findPr: async () => ({ pr: { url: 'u', number: 18 } }),
});
expect(job.prNumber).toBe(18);
expect(job.prCheckError).toBeNull();
});
});
// --- finish_job: the agent reports its own finish ---
describe('finishJobForAgent', () => {
beforeEach(resetBoard);
async function running(fields = {}) {
addJob({ title: 'work', repoPath: REPO, ...fields }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
return { job, session: sessions.get(job.agentSessionId) };
}
const openPr = (pr) => async () => ({ pr });
it('moves a PR job to review with the PR it names, keeping the agent', async () => {
const { job, session } = await running();
const result = await finishJobForAgent({ session, prUrl: 'https://github.com/o/r/pull/5/' }, noopBroadcast,
{ findPr: openPr({ url: 'https://github.com/o/r/pull/5', number: 5 }) });
expect(result.error).toBeUndefined();
expect(job.state).toBe('review');
expect(job.prNumber).toBe(5);
expect(job.agentSessionId).toBe(session.id);
});
it('refuses a PR job with no PR url, or one that is not the branch PR', async () => {
const { job, session } = await running();
const findPr = openPr({ url: 'https://github.com/o/r/pull/5', number: 5 });
expect((await finishJobForAgent({ session }, noopBroadcast, { findPr })).error).toMatch(/\/ship/);
expect((await finishJobForAgent({ session, prUrl: 'https://github.com/o/r/pull/6' }, noopBroadcast, { findPr })).error)
.toMatch(/pull\/5/);
expect((await finishJobForAgent({ session, prUrl: 'https://github.com/o/r/pull/5' }, noopBroadcast,
{ findPr: openPr(null) })).error).toMatch(/no open pull request/);
expect(job.state).toBe('in-progress');
});
it('moves a no-PR job to review with its summary, and needs one', async () => {
const { job, session } = await running({ requiresPr: false });
expect((await finishJobForAgent({ session }, noopBroadcast)).error).toMatch(/summary/);
const result = await finishJobForAgent({ session, summary: 'Found the leak in pty.js.' }, noopBroadcast);
expect(result.error).toBeUndefined();
expect(job.state).toBe('review');
expect(job.resultSummary).toBe('Found the leak in pty.js.');
expect(job.agentSessionId).toBe(session.id);
});
it('only lets the linked agent finish its own card', async () => {
const { job } = await running({ requiresPr: false });
const stranger = { id: 'session-other', name: 'Stranger' };
expect((await finishJobForAgent({ session: stranger, summary: 'x' }, noopBroadcast)).error).toMatch(/not working a job/);
expect(job.state).toBe('in-progress');
});
});
describe('the one-time prompt', () => {
it('asks for /ship then finish_job with the PR on a PR job, and no /ship otherwise', () => {
const pr = buildJobPrompt({ title: 't', requiresPr: true });
expect(pr).toContain('/ship');
expect(pr).toMatch(/finish_job[\s\S]*pr_url/);
const noPr = buildJobPrompt({ title: 't', requiresPr: false });
expect(noPr).not.toContain('ship');
expect(noPr).toMatch(/finish_job[\s\S]*summary/);
});
});
describe('done after a restart', () => {
beforeEach(resetBoard);
// A restart leaves a kept Review agent's worktree as an orphan record, not a
// session. Done must still release it.
// Done's release is covered below; To do clears the branch off the card
// first, so the release has to work from what the card held before.
it('releases the orphaned worktree of a review card moved back to To do', async () => {
addJob({ title: 'kept then restarted', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast, { findPr: async () => ({ pr: null }) });
sessions.clear();
job.agentSessionId = null;
job.worktreePath = GONE_WORKTREE;
orphans.set('orphan-1', { id: 'orphan-1', name: 'Viper', repoPath: REPO, branchName: job.branchName,
worktreePath: GONE_WORKTREE }); // already gone from disk
await moveJob(job.id, 'todo', noopBroadcast);
expect(orphans.has('orphan-1')).toBe(false);
expect(job.branchName).toBeNull();
});
});
describe('releasePushedOrphans at startup', () => {
beforeEach(resetBoard);
// removeWorktree's own tests cover the git rules; this covers which orphans
// get asked at all. A worktree already gone from disk releases on any ask.
it('re-checks only unpushed orphans with no live card', async () => {
addJob({ title: 'in review', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast, { findPr: async () => ({ pr: null }) });
sessions.clear();
const orphan = (id, branchName, reason) => orphans.set(id, { id, name: id, repoPath: REPO, branchName,
worktreePath: join(GONE_WORKTREE, id), reason });
orphan('done-card', 'bill10/merged', 'unpushed');
orphan('dirty', 'bill10/dirty', 'uncommitted');
orphan('review-card', job.branchName, 'unpushed');
// Asked, but removeWorktree still says orphaned: stays, and is not counted.
orphans.set('kept', { id: 'kept', name: 'kept', repoPath: REPO, branchName: 'bill10/kept',
worktreePath: mkdtempSync(join(tmpdir(), 'a007-kept-')), reason: 'unpushed' });
expect(await releasePushedOrphans(noopBroadcast)).toBe(1);
expect([...orphans.keys()].sort()).toEqual(['dirty', 'kept', 'review-card']);
});
});
// --- finish_job refusals, and the no-PR / orphan edges of Review and Done ---
describe('finishJobForAgent refusals', () => {
beforeEach(resetBoard);
async function running(fields = {}) {
addJob({ title: 'work', repoPath: REPO, ...fields }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
return { job, session: sessions.get(job.agentSessionId) };
}
it('refuses with no session at all', async () => {
expect((await finishJobForAgent({}, noopBroadcast)).error).toMatch(/not working a job/);
});
// The poller can find the PR /ship opened before the agent calls in.
it('succeeds when the card is already in Review, keeping a new summary', async () => {
const { job, session } = await running();
const pr = { url: 'https://github.com/o/r/pull/5', number: 5 };
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr }) });
const result = await finishJobForAgent({ session, prUrl: pr.url, summary: 'shipped' }, noopBroadcast,
{ findPr: async () => ({ pr }) });
expect(result.error).toBeUndefined();
expect(job.state).toBe('review');
expect(job.resultSummary).toBe('shipped');
});
it('refuses a card that has already been filed away as done', async () => {
const { job, session } = await running({ requiresPr: false });
job.state = 'done';
expect((await finishJobForAgent({ session, summary: 'x' }, noopBroadcast)).error).toMatch(/Finished already/);
});
it('refuses when the card is deleted or re-branched during the PR lookup', async () => {
const { job, session } = await running();
const pr = { url: 'https://github.com/o/r/pull/5', number: 5 };
const rebranch = async () => { job.branchName = 'bill/other'; return { pr }; };
expect((await finishJobForAgent({ session, prUrl: pr.url }, noopBroadcast, { findPr: rebranch })).error).toMatch(/moved/);
expect(job.prNumber).toBeNull();
const vanish = async () => { config.jobs = []; return { pr }; };
expect((await finishJobForAgent({ session, prUrl: pr.url }, noopBroadcast, { findPr: vanish })).error).toMatch(/moved/);
expect(job.prNumber).toBeNull();
});
it('sets requiresPr through updateJob, ignoring a non-boolean', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
const job = allJobs()[0];
updateJob(job.id, { requiresPr: 'no' }, noopBroadcast);
expect(job.requiresPr).toBe(true);
updateJob(job.id, { requiresPr: false }, noopBroadcast);
expect(job.requiresPr).toBe(false);
});
it('refuses a non-string summary or pr_url off the wire', async () => {
const { job, session } = await running({ requiresPr: false });
expect((await finishJobForAgent({ session, summary: 42 }, noopBroadcast)).error).toMatch(/summary must be a string/);
expect((await finishJobForAgent({ session, summary: 'x', prUrl: {} }, noopBroadcast)).error).toMatch(/pr_url must be a string/);
expect(job.state).toBe('in-progress');
});
it('refuses a blank pr_url, and a blank summary on a no-PR card', async () => {
const { session } = await running();
expect((await finishJobForAgent({ session, prUrl: ' ' }, noopBroadcast, { findPr: async () => { throw new Error('not called'); } })).error)
.toMatch(/requires a pull request/);
resetBoard();
const noPr = await running({ requiresPr: false });
expect((await finishJobForAgent({ session: noPr.session, summary: ' \n ' }, noopBroadcast)).error).toMatch(/summary/);
});
it('passes a PR lookup failure back, leaving the card alone', async () => {
const { job, session } = await running();
const result = await finishJobForAgent({ session, prUrl: 'https://github.com/o/r/pull/5' }, noopBroadcast,
{ findPr: async () => ({ error: 'gh is not installed' }) });
expect(result.error).toMatch(/Could not check the pull request — gh is not installed/);
expect(job.state).toBe('in-progress');
});
it('leaves a card that moved while its PR was being looked up', async () => {
const { job, session } = await running();
const result = await finishJobForAgent({ session, prUrl: 'https://github.com/o/r/pull/5' }, noopBroadcast, {
findPr: async () => { job.state = 'todo'; return { pr: { url: 'https://github.com/o/r/pull/5', number: 5 } }; },
});
expect(result.error).toMatch(/moved while its pull request was being checked/);
expect(job.state).toBe('todo');
expect(job.prNumber).toBeNull();
});
it('keeps an optional summary on a PR job, and clears its old errors', async () => {
const { job, session } = await running();
job.lastError = 'stale';
const notes = [];
await finishJobForAgent({ session, prUrl: 'https://github.com/O/R/pull/5', summary: 'Assumed UTC.' }, (m) => notes.push(m),
{ findPr: async () => ({ pr: { url: 'https://github.com/o/r/pull/5', number: 5 } }) });
expect(job.state).toBe('review');
expect(job.resultSummary).toBe('Assumed UTC.');
expect(job.lastError).toBeNull();
expect(notes.some(m => m.type === 'notification' && /PR #5/.test(m.message))).toBe(true);
});
});
describe('cards that need no pull request', () => {
beforeEach(resetBoard);
it('defaults to requiring one on a one-time card and not on a schedule', () => {
expect(addJob({ title: 'a', repoPath: REPO }, noopBroadcast).job.requiresPr).toBe(true);
expect(addJob({ title: 'b', repoPath: REPO, requiresPr: false }, noopBroadcast).job.requiresPr).toBe(false);
expect(jobRequiresPr({ title: 'old' })).toBe(true);
expect(jobRequiresPr({ requiresPr: false })).toBe(false);
expect(jobRequiresPr({ type: 'scheduled', schedule: '@daily' })).toBe(false);
expect(jobRequiresPr({ type: 'scheduled', schedule: '@daily', requiresPr: true })).toBe(true);
});
it('skips the PR lookup on a manual move to review', async () => {
addJob({ title: 'research', repoPath: REPO, requiresPr: false }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
let looked = false;
await moveJob(job.id, 'review', noopBroadcast, { findPr: async () => { looked = true; return { pr: { url: 'u', number: 1 } }; } });
expect(looked).toBe(false);
expect(job.state).toBe('review');
expect(job.prNumber).toBeNull();
});
it('is left alone by the merge sweep, even when its branch merged', async () => {
addJob({ title: 'research', repoPath: REPO, requiresPr: false }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast);
const finished = await checkMergedPullRequests(noopBroadcast, {
findMerged: async () => ({ pr: { url: 'u', number: 1, mergedAt: '2026-08-28T10:00:00Z' } }),
});
expect(finished).toEqual([]);
expect(job.state).toBe('review');
});
it('is validated and toggled through the agent door', () => {
expect(postJobForAgent({ title: 'x', repo: REPO, requiresPr: 'no' }, noopBroadcast).error).toMatch(/requires_pr must be true or false/);
expect(allJobs()).toHaveLength(0);
const { job } = postJobForAgent({ title: 'x', repo: REPO, requiresPr: false }, noopBroadcast);
expect(job.requiresPr).toBe(false);
expect(editJobForAgent({ id: job.id, requiresPr: 'yes' }, noopBroadcast).error).toMatch(/requires_pr must be true or false/);
expect(editJobForAgent({ id: job.id, requiresPr: false }, noopBroadcast).error).toMatch(/Nothing to change/);
expect(editJobForAgent({ id: job.id, requiresPr: true }, noopBroadcast).changed).toEqual(['requires_pr']);
expect(allJobs()[0].requiresPr).toBe(true);
});
});
describe('releasing an orphaned worktree on done', () => {
beforeEach(() => { resetBoard(); orphans.clear(); adoptingOrphans.clear(); });
async function restartedReviewCard() {
addJob({ title: 'kept then restarted', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast, { findPr: async () => ({ pr: null }) });
sessions.clear();
job.agentSessionId = null;
return job;
}
const orphanFor = (job, worktreePath) => ({ id: 'orphan-1', name: 'Viper', repoPath: REPO, branchName: job.branchName, worktreePath });
it('leaves an orphan that is being re-adopted', async () => {
const job = await restartedReviewCard();
job.worktreePath = GONE_WORKTREE; // the card recorded the worktree the orphan holds
orphans.set('orphan-1', orphanFor(job, GONE_WORKTREE));
adoptingOrphans.add('orphan-1');
await moveJob(job.id, 'done', noopBroadcast);
expect(job.state).toBe('done');
expect(orphans.has('orphan-1')).toBe(true);
});
it('keeps the orphan when its worktree still holds something', async () => {
const job = await restartedReviewCard();
// A directory with no .git is a broken worktree: removeWorktree keeps it.
const kept = mkdtempSync(join(tmpdir(), 'a007-broken-wt-'));
orphans.set('orphan-1', orphanFor(job, kept));
await moveJob(job.id, 'done', noopBroadcast);
expect(orphans.has('orphan-1')).toBe(true);
expect(existsSync(kept)).toBe(true);
rmSync(kept, { recursive: true, force: true });
});
it('leaves an orphan on another repo with the same branch name alone', async () => {
const job = await restartedReviewCard();
job.worktreePath = GONE_WORKTREE; // the card recorded the worktree the orphan holds
orphans.set('orphan-1', { ...orphanFor(job, GONE_WORKTREE), repoPath: REPO2 });
await moveJob(job.id, 'done', noopBroadcast);
expect(orphans.has('orphan-1')).toBe(true);
});
it('is what the merge sweep falls back to when no agent is left to retire', async () => {
const job = await restartedReviewCard();
job.worktreePath = GONE_WORKTREE; // the card recorded the worktree the orphan holds
orphans.set('orphan-1', orphanFor(job, GONE_WORKTREE));
const finished = await checkMergedPullRequests(noopBroadcast, {
findMerged: async () => ({ pr: { url: 'u', number: 42, mergedAt: '2026-08-28T10:00:00Z' } }),
killSession: async () => { throw new Error('nothing to kill'); },
});
expect(finished).toHaveLength(1);
expect(job.state).toBe('done');
expect(orphans.has('orphan-1')).toBe(false);
});
it('is what the closed-PR sweep falls back to when no agent is left to retire', async () => {
const job = await restartedReviewCard();
job.prNumber = 42;
job.worktreePath = GONE_WORKTREE; // the card recorded the worktree the orphan holds
orphans.set('orphan-1', orphanFor(job, GONE_WORKTREE));
job.prClosedSeenAt = new Date(Date.now() - 120_000).toISOString(); // first reading, a while ago
const finished = await checkMergedPullRequests(noopBroadcast, { findPr: async () => ({ pr: null }),
findMerged: async () => ({ pr: null }),
findClosed: async () => ({ pr: { url: 'u', number: 42 } }),
killSession: async () => { throw new Error('nothing to kill'); },
});
expect(finished).toHaveLength(1);
expect(job.prClosedAt).toBeTruthy();
expect(orphans.has('orphan-1')).toBe(false);
});
});
describe('adversarial review regressions', () => {
beforeEach(resetBoard);
it('clears the last attempt\'s summary when a card goes back to To do', async () => {
addJob({ title: 'x', repoPath: REPO, requiresPr: false }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await finishJobForAgent({ session: sessions.get(job.agentSessionId), summary: 'old result' }, noopBroadcast);
await moveJob(job.id, 'todo', noopBroadcast, { killSession: async (id) => sessions.delete(id) });
expect(job.resultSummary).toBeNull();
});
it('relinks a re-adopted agent over a link to a session that has exited', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
sessions.get(job.agentSessionId).exited = true; // closed by hand, no restart
const adopted = { id: 'session-77', name: 'Viper', repoPath: REPO, branchName: job.branchName, exited: false };
sessions.set(adopted.id, adopted);
expect(relinkSessionToJob(adopted, noopBroadcast)).toBe(job);
expect(job.agentSessionId).toBe('session-77');
});
it('defaults a schedule to no-PR runs, and keeps an explicit choice', () => {
expect(addJob({ title: 's', repoPath: REPO, schedule: '@daily' }, noopBroadcast).job.requiresPr).toBe(false);
expect(addJob({ title: 't', repoPath: REPO, schedule: '@daily', requiresPr: true }, noopBroadcast).job.requiresPr).toBe(true);
});
it('holds the orphan against re-adoption only while its worktree is removed', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
sessions.clear();
job.agentSessionId = null;
job.worktreePath = GONE_WORKTREE; // the card recorded the worktree the orphan holds
orphans.set('orphan-9', { id: 'orphan-9', name: 'Viper', repoPath: REPO, branchName: job.branchName, worktreePath: GONE_WORKTREE });
await moveJob(job.id, 'done', noopBroadcast);
expect(orphans.has('orphan-9')).toBe(false);
expect(adoptingOrphans.has('orphan-9')).toBe(false);
});
});
describe('red team regressions', () => {
beforeEach(resetBoard);
it('treats the PR poll moving the card during finish_job\'s lookup as a success', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
const pr = { url: 'https://github.com/o/r/pull/5', number: 5 };
const pollerWins = async () => { job.state = 'review'; return { pr }; };
const result = await finishJobForAgent({ session: sessions.get(job.agentSessionId), prUrl: pr.url, summary: 'done' },
noopBroadcast, { findPr: pollerWins });
expect(result.error).toBeUndefined();
expect(job.resultSummary).toBe('done');
});
it('leaves an unlinked session on a Review card\'s branch alone when the PR merges', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: { url: 'u', number: 5 } }) });
sessions.clear();
job.agentSessionId = null;
sessions.set('mine', { id: 'mine', repoPath: REPO, branchName: job.branchName, exited: false });
const killed = [];
await checkMergedPullRequests(noopBroadcast, {
findMerged: async () => ({ pr: { url: 'u', number: 5, mergedAt: '2026-09-24T00:00:00Z' } }),
killSession: async (id) => killed.push(id),
});
expect(job.state).toBe('done');
expect(killed).toEqual([]);
});
});
describe('pass 3 regressions', () => {
beforeEach(resetBoard);
it('resets requiresPr to the new type\'s default on a type change with no choice made', () => {
addJob({ title: 'x', repoPath: REPO, schedule: '@daily' }, noopBroadcast);
const job = allJobs()[0];
expect(job.requiresPr).toBe(false);
updateJob(job.id, { type: 'one-time', schedule: '' }, noopBroadcast);
expect(job.requiresPr).toBe(true);
updateJob(job.id, { type: 'scheduled', schedule: '@daily', requiresPr: true }, noopBroadcast);
expect(job.requiresPr).toBe(true);
});
it('keeps requiresPr when a save resends the same type, and defaults a card turned schedule to no PR', () => {
addJob({ title: 'y', repoPath: REPO, schedule: '@daily', requiresPr: true }, noopBroadcast);
const job = allJobs()[0];
updateJob(job.id, { title: 'y2', type: 'scheduled', schedule: '@daily' }, noopBroadcast);
expect(job.requiresPr).toBe(true);
addJob({ title: 'z', repoPath: REPO }, noopBroadcast);
const plain = allJobs()[1];
updateJob(plain.id, { type: 'scheduled', schedule: '@daily' }, noopBroadcast);
expect(plain.requiresPr).toBe(false);
});
});
describe('outside review regressions', () => {
beforeEach(resetBoard);
it('retires an agent re-adopted during the merge lookup, not the stale one', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await checkPullRequests(noopBroadcast, { findPr: async () => ({ pr: { url: 'u', number: 5 } }) });
const killed = [];
await checkMergedPullRequests(noopBroadcast, {
findMerged: async () => {
sessions.set('readopted', { id: 'readopted', repoPath: REPO, branchName: job.branchName, exited: false });
job.agentSessionId = 'readopted';
return { pr: { url: 'u', number: 5, mergedAt: '2026-09-24T00:00:00Z' } };
},
killSession: async (id) => { killed.push(id); sessions.delete(id); },
});
expect(killed).toEqual(['readopted']);
});
});
describe('red team regressions', () => {
beforeEach(resetBoard);
it('leaves another run\'s orphan alone when it merely shares the branch name', async () => {
addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
sessions.clear();
job.agentSessionId = null;
orphans.set('theirs', { id: 'theirs', name: 'Other', repoPath: REPO, branchName: job.branchName, worktreePath: GONE_WORKTREE + '-other' });
await moveJob(job.id, 'done', noopBroadcast);
expect(orphans.has('theirs')).toBe(true);
});
it('restamps reviewAt on a no-PR card returned from In progress, not on a PR card', async () => {
for (const requiresPr of [false, true]) {
resetBoard();
addJob({ title: 'x', repoPath: REPO, requiresPr }, noopBroadcast);
await dispatchOnce(fakeCreateSession([]), noopBroadcast);
const job = allJobs()[0];
await moveJob(job.id, 'review', noopBroadcast, { findPr: async () => ({ pr: null }) });
job.reviewAt = '2026-01-01T00:00:00.000Z';
await moveJob(job.id, 'in-progress', noopBroadcast);
await moveJob(job.id, 'review', noopBroadcast, { findPr: async () => ({ pr: null }) });
expect(job.reviewAt === '2026-01-01T00:00:00.000Z').toBe(requiresPr);
}
});
});
describe('a PR job in a repo with no GitHub remote', () => {
beforeEach(resetBoard);
it('says so on the card, and stops once a GitHub remote is added', async () => {
const repo = mkdtempSync(join(tmpdir(), 'a007-noremote-'));
execFileSync('git', ['init', '-q', repo]);
config.repos.push({ path: repo });
const { job } = addJob({ title: 'x', repoPath: repo }, noopBroadcast);
await noteGithubRemote(job, noopBroadcast);
expect(jobsPayload().jobs[0].noGithubRemote).toBe(true);
// A card that needs no pull request is not told.
updateJob(job.id, { requiresPr: false }, noopBroadcast);
expect(jobsPayload().jobs[0].noGithubRemote).toBe(false);
updateJob(job.id, { requiresPr: true }, noopBroadcast);
execFileSync('git', ['-C', repo, 'remote', 'add', 'origin', 'git@github.com:o/r.git']);
await noteGithubRemote(job, noopBroadcast);
expect(jobsPayload().jobs[0].noGithubRemote).toBe(false);
rmSync(repo, { recursive: true, force: true });
});
it('says when the card\'s CLI has no ship skill, and stops once one is installed', async () => {
const home = mkdtempSync(join(tmpdir(), 'a007-noship-'));
const homes = { claudeDir: join(home, '.claude'), codexDir: join(home, '.codex'), agentsDir: join(home, '.agents') };
const { job } = addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await noteGithubRemote(job, noopBroadcast, { homes });
expect(jobsPayload().jobs[0].noShipSkill).toBe('claude');
updateJob(job.id, { requiresPr: false }, noopBroadcast);
expect(jobsPayload().jobs[0].noShipSkill).toBeNull();
updateJob(job.id, { requiresPr: true }, noopBroadcast);
mkdirSync(join(homes.claudeDir, 'skills', 'ship'), { recursive: true });
writeFileSync(join(homes.claudeDir, 'skills', 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
await noteGithubRemote(job, noopBroadcast, { homes });
expect(jobsPayload().jobs[0].noShipSkill).toBeNull();
rmSync(home, { recursive: true, force: true });
});
it('writes nothing when git cannot answer', async () => {
const { job } = addJob({ title: 'x', repoPath: REPO }, noopBroadcast);
await noteGithubRemote(job, noopBroadcast);
expect(job.noGithubRemote).toBeUndefined();
});
});