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