test/jobs-scheduled-dispatch.test.js
Rama main · réplica de lectura del repositorio original.
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();
});
});