import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { config, sessions } from '../server/state.js'; import { addJob, updateJob, moveJob, dispatchOnce, fireSchedules, supersedeRuns, finishJobForAgent, pruneFinishedRuns, runScan, allJobs, boardSettings, jobsPayload, postJobForAgent, setJobPaused, checkMergedPullRequests, } from '../server/jobs.js'; import { jobType } from '../lib/jobs.js'; const REPO = mkdtempSync(join(tmpdir(), 'a007-sched-')); const noopBroadcast = () => {}; function resetBoard() { config.repos = [{ path: REPO }]; config.jobs = []; config.jobBoard = null; boardSettings(); sessions.clear(); } // Same stand-in as jobs-dispatch.test.js: records its arguments, hands back a // session shaped like the real one, spawns nothing. function fakeCreateSession(calls) { let n = 0; return async (command, name, repoPath, branch, ownerId, meta) => { calls.push({ command, repoPath, branch, meta }); 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 }; }; } // Records which sessions were closed, and closes them the way killSession does. function fakeKillSession(killed) { return async (id) => { killed.push(id); sessions.delete(id); }; } // The closed-PR path acts on the second of two readings a scan apart, once // the card's agent is idle, and only when nothing is open on the branch. This // runs the sweep that way. async function closedSweep(broadcast, opts) { for (const s of sessions.values()) if (!s.exited) s.state = 'WAITING'; for (let i = 0; i < 2; i++) { await checkMergedPullRequests(broadcast, { findPr: async () => ({ pr: null }), ...opts }); ageClosedReadings(); } } // Stands in for the minute a closed reading must hold before it counts. function ageClosedReadings() { for (const j of allJobs()) if (j.prClosedSeenAt) j.prClosedSeenAt = new Date(Date.now() - 120_000).toISOString(); } const past = () => new Date(Date.now() - 60_000).toISOString(); const future = () => new Date(Date.now() + 3600_000).toISOString(); beforeEach(resetBoard); // A schedule that is due right now. function dueSchedule(over = {}) { const { job } = addJob({ title: 'Hourly check', repoPath: REPO, schedule: '@hourly', ...over }, noopBroadcast); job.nextRunAt = past(); return job; } const runsOf = (schedule) => allJobs().filter(j => j.scheduleId === schedule.id); describe('firing a schedule', () => { it('waits for the schedule instead of firing the moment it is posted', () => { addJob({ title: 'Daily digest', repoPath: REPO, schedule: '0 9 * * *' }, noopBroadcast); expect(fireSchedules(noopBroadcast)).toEqual([]); expect(allJobs()).toHaveLength(1); }); it('posts a run card when due, and the run goes out while the schedule stays put', async () => { const schedule = dueSchedule(); const [run] = fireSchedules(noopBroadcast); expect(run.scheduleId).toBe(schedule.id); expect(schedule.runCount).toBe(1); expect(schedule.lastRunJobId).toBe(run.id); expect(Date.parse(schedule.nextRunAt)).toBeGreaterThan(Date.now()); const calls = []; const dispatched = await dispatchOnce(fakeCreateSession(calls), noopBroadcast); expect(dispatched.map(d => d.job.id)).toEqual([run.id]); expect(calls[0].command).not.toMatch(/\/ship/); // a default schedule's runs report a summary expect(schedule.state).toBe('todo'); expect(run.state).toBe('in-progress'); }); it('fires and dispatches the run in one scan', async () => { const schedule = dueSchedule(); const calls = []; await runScan(fakeCreateSession(calls), noopBroadcast, { killSession: fakeKillSession([]), findPr: async () => ({}), findMerged: async () => ({}) }); expect(runsOf(schedule)[0].state).toBe('in-progress'); }); }); describe('a schedule whose run cannot be posted', () => { it('records why on the schedule, moves on, and clears it once a run goes out', () => { const schedule = dueSchedule(); schedule.title = ''; // hand-edited config.json: createJob refuses a blank title expect(fireSchedules(noopBroadcast)).toEqual([]); expect(runsOf(schedule)).toHaveLength(0); expect(schedule.lastError).toMatch(/Could not post this run: Title is required/); expect(schedule.runCount).toBe(0); expect(Date.parse(schedule.nextRunAt)).toBeGreaterThan(Date.now()); schedule.title = 'Hourly check'; schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toHaveLength(1); expect(schedule.lastError).toBeNull(); expect(schedule.lastErrorAt).toBeNull(); }); }); describe('a schedule holding off', () => { it('skips a firing while the previous run is still going, and says so', async () => { const schedule = dueSchedule(); fireSchedules(noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toEqual([]); expect(runsOf(schedule)).toHaveLength(1); expect(schedule.lastSkipReason).toMatch(/still going/); // Moved on rather than retried every scan: a skipped firing is not replayed. expect(Date.parse(schedule.nextRunAt)).toBeGreaterThan(Date.now()); }); it('skips while a PR run waits in Review, and fires once it is done', async () => { const schedule = dueSchedule({ requiresPr: true }); const [run] = fireSchedules(noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); const pr = { url: 'https://github.com/o/r/pull/7', number: 7 }; await finishJobForAgent({ session: sessions.get(run.agentSessionId), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); expect(run.state).toBe('review'); schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toEqual([]); expect(schedule.lastSkipReason).toMatch(/PR #7/); await moveJob(run.id, 'done', noopBroadcast, { killSession: fakeKillSession([]) }); schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toHaveLength(1); expect(schedule.lastSkipReason).toBeNull(); }); }); describe('superseding no-PR runs', () => { // One creator for the whole test, so session ids do not repeat. let create; beforeEach(() => { create = fakeCreateSession([]); }); // Two runs of one schedule, both finished and waiting in Review. async function twoFinishedRuns() { const schedule = dueSchedule(); const runs = []; for (const summary of ['first', 'second']) { schedule.nextRunAt = past(); const [run] = fireSchedules(noopBroadcast); run.postedAt = new Date(Date.now() + runs.length).toISOString(); // strictly ordered await dispatchOnce(create, noopBroadcast); await finishJobForAgent({ session: sessions.get(run.agentSessionId), summary }, noopBroadcast); sessions.get(run.agentSessionId).state = 'WAITING'; // done, idle at its prompt runs.push(run); } return { schedule, runs }; } it('fires past a no-PR run in Review, then files the older one away with its agent', async () => { const { runs: [first, second] } = await twoFinishedRuns(); const firstAgent = first.agentSessionId; const killed = []; await supersedeRuns(noopBroadcast, { killSession: fakeKillSession(killed) }); expect(first.state).toBe('done'); expect(first.supersededBy).toBe(second.id); expect(first.resultSummary).toBe('first'); // kept in the archive expect(killed).toEqual([firstAgent]); expect(second.state).toBe('review'); expect(second.supersededRuns).toBe(1); }); it('leaves a run whose agent is busy for a later scan', async () => { const { runs: [first] } = await twoFinishedRuns(); sessions.get(first.agentSessionId).state = 'MESSAGE'; // someone is talking to it await supersedeRuns(noopBroadcast, { killSession: fakeKillSession([]) }); expect(first.state).toBe('review'); sessions.get(first.agentSessionId).state = 'WAITING'; await supersedeRuns(noopBroadcast, { killSession: fakeKillSession([]) }); expect(first.state).toBe('done'); }); it('leaves a run whose agent is paused for an account switch, though it reads as exited', async () => { // A worker the switch stopped mid-turn resumes in a moment; closing it // then would cut off its follow-up (and once only unlinked it, cardless). const { runs: [first] } = await twoFinishedRuns(); Object.assign(sessions.get(first.agentSessionId), { state: 'WORKING', exited: true, accountRotating: true }); const killed = []; await supersedeRuns(noopBroadcast, { killSession: fakeKillSession(killed) }); expect(first.state).toBe('review'); expect(killed).toEqual([]); }); it('keeps the run that reached Review last, even if it was posted first', async () => { const { runs: [first, second] } = await twoFinishedRuns(); first.reviewAt = new Date(Date.now() + 60_000).toISOString(); // sent back and returned await supersedeRuns(noopBroadcast, { killSession: fakeKillSession([]) }); expect(second.state).toBe('done'); expect(first.state).toBe('review'); }); it('carries the count forward, so the newest says how many went unread', async () => { const { schedule, runs: [, second] } = await twoFinishedRuns(); await supersedeRuns(noopBroadcast, { killSession: fakeKillSession([]) }); schedule.nextRunAt = past(); const [third] = fireSchedules(noopBroadcast); third.postedAt = new Date(Date.now() + 10).toISOString(); await dispatchOnce(create, noopBroadcast); await finishJobForAgent({ session: sessions.get(third.agentSessionId), summary: 'third' }, noopBroadcast); sessions.get(third.agentSessionId).state = 'WAITING'; await supersedeRuns(noopBroadcast, { killSession: fakeKillSession([]) }); expect(second.state).toBe('done'); expect(third.supersededRuns).toBe(2); }); it('leaves a run where it is when the move to Done is refused, and counts nothing', async () => { const { runs: [first, second] } = await twoFinishedRuns(); // A run card hand-edited into a schedule: moveJob refuses every move of one. Object.assign(first, { type: 'scheduled', schedule: '@hourly' }); expect(await supersedeRuns(noopBroadcast, { killSession: fakeKillSession([]) })).toEqual([]); expect(first.state).toBe('review'); expect(first.supersededBy).toBeUndefined(); expect(second.supersededRuns).toBeUndefined(); }); }); describe('pausing a schedule', () => { it('holds a due schedule until it is resumed, without replaying the held firing', () => { const schedule = dueSchedule(); setJobPaused(schedule.id, true, noopBroadcast); expect(fireSchedules(noopBroadcast)).toEqual([]); setJobPaused(schedule.id, false, noopBroadcast); expect(Date.parse(schedule.nextRunAt)).toBeGreaterThan(Date.now()); expect(fireSchedules(noopBroadcast)).toEqual([]); schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toHaveLength(1); }); it('leaves a run already posted alone', async () => { const schedule = dueSchedule(); const [run] = fireSchedules(noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); setJobPaused(schedule.id, true, noopBroadcast); expect(run.state).toBe('in-progress'); expect(sessions.has(run.agentSessionId)).toBe(true); }); it('rejects an unknown card and no-ops a repeat of the state it is in', () => { addJob({ title: 'Daily digest', repoPath: REPO, schedule: '0 9 * * *' }, noopBroadcast); const job = allJobs()[0]; expect(setJobPaused('nope', true, noopBroadcast).error).toBeTruthy(); setJobPaused(job.id, true, noopBroadcast); const armed = allJobs()[0].nextRunAt; setJobPaused(job.id, true, noopBroadcast); expect(allJobs()[0].nextRunAt).toBe(armed); }); }); describe('editing and moving a schedule', () => { it('recomputes the next run when the schedule changes', () => { addJob({ title: 'Digest', repoPath: REPO, schedule: '0 9 * * *' }, noopBroadcast); const job = allJobs()[0]; const before = Date.parse(job.nextRunAt); updateJob(job.id, { schedule: '*/5 * * * *' }, noopBroadcast); expect(job.schedule).toBe('*/5 * * * *'); // The old due time belonged to the old cron, so it must not survive: every // five minutes is at most five minutes away, not up to a day. const after = Date.parse(job.nextRunAt); // Or-equal: between 08:55 and 09:00 both crons resolve to the same 09:00, // and the upper bound below is what proves the recompute happened. expect(after).toBeLessThanOrEqual(before); expect(after).toBeGreaterThan(Date.now()); expect(after).toBeLessThanOrEqual(Date.now() + 5 * 60_000); }); it('refuses a broken cron without half-applying the type change', () => { addJob({ title: 'Digest', repoPath: REPO }, noopBroadcast); const job = allJobs()[0]; const before = { type: job.type, schedule: job.schedule }; expect(updateJob(job.id, { type: 'scheduled', schedule: 'every friday' }, noopBroadcast).error).toMatch(/five fields/i); expect(job.type).toBe(before.type); expect(job.schedule).toBe(before.schedule); }); it('turns a scheduled card back into a one-time one, clearing what no longer applies', () => { addJob({ title: 'Digest', repoPath: REPO, schedule: '0 9 * * *' }, noopBroadcast); const job = allJobs()[0]; updateJob(job.id, { type: 'one-time', schedule: '0 9 * * *' }, noopBroadcast); expect(job.type).toBe('one-time'); expect(job.schedule).toBeNull(); expect(job.nextRunAt).toBeNull(); }); it('lets an ordinary save resend the same type and schedule, without eating a due firing', () => { addJob({ title: 'Digest', repoPath: REPO, schedule: '0 9 * * *' }, noopBroadcast); const job = allJobs()[0]; job.nextRunAt = past(); // overdue — a firing is about to happen updateJob(job.id, { title: 'Digest v2', type: 'scheduled', schedule: '0 9 * * *' }, noopBroadcast); expect(job.title).toBe('Digest v2'); // The save resent the current schedule; the pending firing survives. expect(Date.parse(job.nextRunAt)).toBeLessThan(Date.now()); }); it('refuses to move a schedule anywhere — its runs are the cards that move', async () => { addJob({ title: 'Digest', repoPath: REPO, schedule: '0 9 * * *' }, noopBroadcast); const job = allJobs()[0]; for (const state of ['in-progress', 'review', 'done']) { expect((await moveJob(job.id, state, noopBroadcast, {})).error).toMatch(/schedule stays in To do/); } expect(job.state).toBe('todo'); }); }); describe('posting a scheduled job on an agent behalf', () => { it('takes a bare schedule and reports back the card it made', () => { const result = postJobForAgent({ title: 'Digest', repo: REPO, schedule: '@daily' }, noopBroadcast); expect(result.error).toBeUndefined(); expect(result.job.type).toBe('scheduled'); expect(result.job.schedule).toBe('@daily'); expect(Date.parse(result.job.nextRunAt)).not.toBeNaN(); }); it('rejects a non-string schedule instead of silently making a one-time card', () => { expect(postJobForAgent({ title: 'Digest', repo: REPO, schedule: 30 }, noopBroadcast).error).toMatch(/schedule must be a string/i); expect(allJobs()).toHaveLength(0); }); it('hands the cron error back to the agent instead of queueing a card that never fires', () => { expect(postJobForAgent({ title: 'Digest', repo: REPO, schedule: 'daily at 9' }, noopBroadcast).error).toMatch(/five fields/i); expect(allJobs()).toHaveLength(0); }); }); describe('the wire shape', () => { it('gives every card a type, including one written before types existed', () => { addJob({ title: 'Legacy', repoPath: REPO }, noopBroadcast); delete allJobs()[0].type; addJob({ title: 'Digest', repoPath: REPO, schedule: '@hourly' }, noopBroadcast); const { jobs } = jobsPayload(); expect(jobs.map(j => j.type)).toEqual(['one-time', 'scheduled']); }); }); describe('a run sharing its schedule\'s files', () => { it('does not delete the schedule\'s file when one is dropped from the run', async () => { const { job: schedule } = addJob({ title: 'With a file', repoPath: REPO, schedule: '@hourly', attachments: [{ name: 'shot.png', data: Buffer.from('png').toString('base64') }], }, noopBroadcast); schedule.nextRunAt = past(); const [run] = fireSchedules(noopBroadcast); const shared = schedule.attachments[0].path; updateJob(run.id, { attachments: [] }, noopBroadcast); const { existsSync } = await import('fs'); expect(existsSync(shared)).toBe(true); expect(run.attachments).toEqual([]); }); }); describe('adversarial review regressions', () => { it('abandons a spawn whose card was turned into a schedule meanwhile', async () => { const { job } = addJob({ title: 'x', repoPath: REPO }, noopBroadcast); const killed = []; const create = async (...args) => { updateJob(job.id, { type: 'scheduled', schedule: '@daily' }, noopBroadcast); return fakeCreateSession([])(...args); }; await dispatchOnce(create, noopBroadcast, { killSession: fakeKillSession(killed) }); expect(job.state).toBe('todo'); expect(killed).toHaveLength(1); }); it('unlinks a run turned into a schedule, so it cannot hold its parent off', () => { const schedule = dueSchedule(); const [run] = fireSchedules(noopBroadcast); updateJob(run.id, { type: 'scheduled', schedule: '@daily' }, noopBroadcast); expect(run.scheduleId).toBeUndefined(); schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toHaveLength(1); }); }); describe('pruning finished runs', () => { it('deletes a schedule\'s finished runs past the cap, with their files, and keeps its count', async () => { const schedule = dueSchedule(); for (let i = 0; i < 52; i++) { allJobs().push({ id: `run-${i}`, title: 't', repoPath: REPO, type: 'one-time', scheduleId: schedule.id, state: 'done', doneAt: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(), attachments: [] }); } schedule.runCount = 52; const pruned = await pruneFinishedRuns(noopBroadcast); expect(pruned.map(j => j.id)).toEqual(['run-1', 'run-0']); expect(runsOf(schedule)).toHaveLength(50); expect(schedule.runCount).toBe(52); }); it('leaves a finished run whose agent is still alive for a later scan', () => { const schedule = dueSchedule(); for (let i = 0; i < 51; i++) { allJobs().push({ id: `run-${i}`, title: 't', repoPath: REPO, type: 'one-time', scheduleId: schedule.id, state: 'done', doneAt: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(), attachments: [] }); } sessions.set('alive', { id: 'alive', exited: false }); allJobs().find(j => j.id === 'run-0').agentSessionId = 'alive'; expect(pruneFinishedRuns(noopBroadcast)).toEqual([]); expect(runsOf(schedule)).toHaveLength(51); }); }); describe('a card whose PR was closed without merging', () => { async function prRunInReview() { const schedule = dueSchedule({ requiresPr: true }); const [run] = fireSchedules(noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); const pr = { url: 'https://github.com/o/r/pull/7', number: 7 }; await finishJobForAgent({ session: sessions.get(run.agentSessionId), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); return { schedule, run }; } const notMerged = async () => ({ pr: null }); it('files the run to Done, closes its agent, and lets the schedule fire again', async () => { const { schedule, run } = await prRunInReview(); const agent = run.agentSessionId; const killed = []; await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async (repo, branch, { prNumber }) => ({ pr: prNumber === 7 ? { url: 'u', number: 7 } : null }), killSession: fakeKillSession(killed), }); expect(run.state).toBe('done'); expect(run.prClosedAt).toBeTruthy(); expect(run.prMergedAt).toBeFalsy(); expect(killed).toEqual([agent]); schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toHaveLength(1); }); it('leaves the run waiting while its PR is still open', async () => { const { schedule, run } = await prRunInReview(); await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async () => ({ pr: null }), killSession: fakeKillSession([]) }); expect(run.state).toBe('review'); schedule.nextRunAt = past(); expect(fireSchedules(noopBroadcast)).toEqual([]); }); it('files a one-time card to Done the same way, closing its agent', async () => { const { job } = addJob({ title: 'plain', repoPath: REPO }, noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); const agent = job.agentSessionId; const pr = { url: 'https://github.com/o/r/pull/9', number: 9 }; await finishJobForAgent({ session: sessions.get(agent), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); const killed = []; await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async () => ({ pr }), killSession: fakeKillSession(killed) }); expect(job.state).toBe('done'); expect(job.prClosedAt).toBeTruthy(); expect(killed).toEqual([agent]); }); it('asks nothing about a card still In progress, which has no PR of record yet', async () => { const { job } = addJob({ title: 'going', repoPath: REPO }, noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); let asked = false; await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async () => { asked = true; return { pr: null }; } }); expect(asked).toBe(false); expect(job.state).toBe('in-progress'); }); }); describe('a closed PR, at the edges', () => { async function prCardInReview(over = {}) { const { job } = addJob({ title: 'plain', repoPath: REPO, ...over }, noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); const pr = { url: 'https://github.com/o/r/pull/9', number: 9 }; await finishJobForAgent({ session: sessions.get(job.agentSessionId), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); return { job, pr }; } const notMerged = async () => ({ pr: null }); it('leaves the card alone when it moves while the closed check is out', async () => { const { job, pr } = await prCardInReview(); const killed = []; await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async () => { job.state = 'in-progress'; return { pr }; }, killSession: fakeKillSession(killed), }); expect(job.state).toBe('in-progress'); expect(job.prClosedAt).toBeFalsy(); expect(killed).toEqual([]); }); it('leaves the card alone when its PR of record changes while the closed check is out', async () => { const { job, pr } = await prCardInReview(); await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async () => { job.prNumber = 10; return { pr }; }, killSession: fakeKillSession([]), }); expect(job.state).toBe('review'); expect(job.prClosedAt).toBeFalsy(); }); it('keeps the card in Review when the closed check itself fails', async () => { const { job } = await prCardInReview(); await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async () => ({ pr: null, error: 'Could not resolve to a Repository' }), killSession: fakeKillSession([]), }); expect(job.state).toBe('review'); expect(job.prCheckError).toMatch(/closed — Could not resolve/); }); it('does not ask about a closed PR when the merge check failed, or when the card has no PR number', async () => { const { job } = await prCardInReview(); let asked = 0; const findClosed = async () => { asked++; return { pr: null }; }; await closedSweep(noopBroadcast, { findMerged: async () => ({ pr: null, error: 'nope' }), findClosed }); job.prNumber = null; await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed }); expect(asked).toBe(0); expect(job.state).toBe('review'); }); it('says the PR was closed, and only says the schedule can run again for a run', async () => { const { pr } = await prCardInReview(); const notes = []; await closedSweep(m => notes.push(m), { findMerged: notMerged, findClosed: async () => ({ pr }), killSession: fakeKillSession([]) }); const note = notes.find(m => m.type === 'notification'); expect(note.message).toMatch(/PR #9 was closed without merging/); expect(note.message).not.toMatch(/schedule/); resetBoard(); dueSchedule({ requiresPr: true }); const [run] = fireSchedules(noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); await finishJobForAgent({ session: sessions.get(run.agentSessionId), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); const runNotes = []; await closedSweep(m => runNotes.push(m), { findMerged: notMerged, findClosed: async () => ({ pr }), killSession: fakeKillSession([]) }); expect(runNotes.find(m => m.type === 'notification').message).toMatch(/Its schedule can run again/); }); it('closes only the linked agent, never a hand-opened session on the same branch', async () => { const { job, pr } = await prCardInReview(); sessions.delete(job.agentSessionId); // the linked agent is gone, so only the branch could match const byHand = { id: 'by-hand', name: 'Hand', repoPath: REPO, branchName: job.branchName, exited: false }; sessions.set(byHand.id, byHand); const killed = []; await closedSweep(noopBroadcast, { findMerged: notMerged, findClosed: async () => ({ pr }), killSession: fakeKillSession(killed) }); expect(job.state).toBe('done'); expect(killed).toEqual([]); expect(sessions.has('by-hand')).toBe(true); }); it('is swept by a scan, which forwards findClosed', async () => { const { job, pr } = await prCardInReview(); sessions.get(job.agentSessionId).state = 'WAITING'; for (let i = 0; i < 2; i++) { await runScan(fakeCreateSession([]), noopBroadcast, { killSession: fakeKillSession([]), findPr: async () => ({}), findMerged: notMerged, findClosed: async () => ({ pr }), }); ageClosedReadings(); } expect(job.state).toBe('done'); expect(job.prClosedAt).toBeTruthy(); }); }); describe('a closed PR and the card\'s files', () => { it('clears the attachments of a card filed away for a closed PR', async () => { const { job } = addJob({ title: 'with a file', repoPath: REPO, attachments: [{ name: 'a.png', data: Buffer.from('x').toString('base64') }] }, noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); const pr = { url: 'https://github.com/o/r/pull/3', number: 3 }; await finishJobForAgent({ session: sessions.get(job.agentSessionId), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); const file = job.attachments[0].path; await closedSweep(noopBroadcast, { findMerged: async () => ({ pr: null }), findClosed: async () => ({ pr }), killSession: fakeKillSession([]) }); const { existsSync } = await import('fs'); expect(job.state).toBe('done'); expect(job.attachments).toEqual([]); expect(existsSync(file)).toBe(false); }); }); describe('the closed-PR guards', () => { async function prCardInReview() { const { job } = addJob({ title: 'guarded', repoPath: REPO }, noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); const pr = { url: 'https://github.com/o/r/pull/41', number: 41 }; await finishJobForAgent({ session: sessions.get(job.agentSessionId), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); sessions.get(job.agentSessionId).state = 'WAITING'; return { job, pr }; } const opts = (over = {}) => ({ findMerged: async () => ({ pr: null }), findClosed: async () => ({ pr: { url: 'u', number: 41 } }), findPr: async () => ({ pr: null }), killSession: fakeKillSession([]), ...over }); it('waits for a second closed reading a minute later before filing the card', async () => { const { job } = await prCardInReview(); await checkMergedPullRequests(noopBroadcast, opts()); expect(job.state).toBe('review'); expect(job.prClosedSeenAt).toBeTruthy(); await checkMergedPullRequests(noopBroadcast, opts()); expect(job.state).toBe('review'); // seconds later, e.g. Run now: not yet ageClosedReadings(); await checkMergedPullRequests(noopBroadcast, opts()); expect(job.state).toBe('done'); }); it('treats the same PR open again as not closed, and a failed open-PR lookup as undecided', async () => { const { job, pr } = await prCardInReview(); await checkMergedPullRequests(noopBroadcast, opts()); ageClosedReadings(); await checkMergedPullRequests(noopBroadcast, opts({ findPr: async () => ({ pr }) })); // reopened in between expect(job.state).toBe('review'); expect(job.prClosedSeenAt).toBeNull(); await checkMergedPullRequests(noopBroadcast, opts()); ageClosedReadings(); await checkMergedPullRequests(noopBroadcast, opts({ findPr: async () => ({ pr: null, error: 'rate limited' }) })); expect(job.state).toBe('review'); expect(job.prCheckError).toMatch(/replacement pull request — rate limited/); }); it('forgets a closed reading once the PR is reopened', async () => { const { job } = await prCardInReview(); await checkMergedPullRequests(noopBroadcast, opts()); await checkMergedPullRequests(noopBroadcast, opts({ findClosed: async () => ({ pr: null }) })); expect(job.prClosedSeenAt).toBeNull(); await checkMergedPullRequests(noopBroadcast, opts()); expect(job.state).toBe('review'); }); it('adopts the replacement PR opened on the same branch instead of filing the card', async () => { const { job } = await prCardInReview(); const killed = []; const next = { url: 'https://github.com/o/r/pull/42', number: 42 }; for (let i = 0; i < 2; i++) { await checkMergedPullRequests(noopBroadcast, opts({ findPr: async () => ({ pr: next }), killSession: fakeKillSession(killed) })); } expect(job.state).toBe('review'); expect(job.prNumber).toBe(42); expect(killed).toEqual([]); }); it('waits while the agent is busy, and files the card once it is quiet', async () => { const { job } = await prCardInReview(); sessions.get(job.agentSessionId).state = 'MESSAGE'; await checkMergedPullRequests(noopBroadcast, opts()); ageClosedReadings(); await checkMergedPullRequests(noopBroadcast, opts()); expect(job.state).toBe('review'); sessions.get(job.agentSessionId).state = 'WAITING'; await checkMergedPullRequests(noopBroadcast, opts()); expect(job.state).toBe('done'); }); it('lets finish_job in Review adopt a replacement PR on the card\'s branch', async () => { const { job } = await prCardInReview(); const next = { url: 'https://github.com/o/r/pull/42', number: 42 }; await finishJobForAgent({ session: sessions.get(job.agentSessionId), prUrl: next.url }, noopBroadcast, { findPr: async () => ({ pr: next }) }); expect(job.prNumber).toBe(42); await finishJobForAgent({ session: sessions.get(job.agentSessionId), prUrl: 'https://github.com/o/r/pull/99' }, noopBroadcast, { findPr: async () => ({ pr: next }) }); expect(job.prNumber).toBe(42); // not the branch's open PR, so ignored }); }); describe('a closed reading and leaving Review', () => { it('is forgotten when the card leaves Review, so a later closed PR needs two fresh readings', async () => { const { job } = addJob({ title: 'x', repoPath: REPO }, noopBroadcast); await dispatchOnce(fakeCreateSession([]), noopBroadcast); const pr = { url: 'https://github.com/o/r/pull/5', number: 5 }; await finishJobForAgent({ session: sessions.get(job.agentSessionId), prUrl: pr.url }, noopBroadcast, { findPr: async () => ({ pr }) }); job.prClosedSeenAt = new Date(Date.now() - 120_000).toISOString(); await moveJob(job.id, 'in-progress', noopBroadcast); expect(job.prClosedSeenAt).toBeNull(); }); });