test/jobs-restart.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { loadConfig, saveActiveSession, recoverCrashedSessions, sessionAgent, sessionPermissionFlags, sessionOrigin } from '../server/config.js';
import { config, orphans } from '../server/state.js';
import { CONFIG_PATH, CONFIG_DIR } from '../server/state.js';
// loadConfig reads CONFIG_PATH, which test/setup.js has already redirected to a
// throwaway directory via AGENT007_CONFIG_DIR.
function writeConfig(jobs) {
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify({
version: 1, repos: [], orphans: [], activeSessions: [], jobs,
}));
}
afterEach(() => {
try { rmSync(CONFIG_PATH); } catch {}
});
describe('restart recovery for in-flight jobs', () => {
it('keeps a job that already has a branch in in-progress', () => {
// The agent may have pushed and opened the PR before the server died.
// Sending it back to To do would dispatch a second agent onto a `-2`
// branch to redo work that is already up for review, and open a duplicate
// PR. checkPullRequests can still resolve it from the branch alone.
writeConfig([{
id: 'j1', title: 'shipped it', repoPath: '/r', state: 'in-progress',
agentSessionId: 'session-9', agentName: 'Viper',
branchName: 'bill/add-thing', startedAt: '2026-08-27T00:00:00Z',
}]);
loadConfig();
const job = config.jobs[0];
expect(job.state).toBe('in-progress');
expect(job.branchName).toBe('bill/add-thing');
// The dead session link must go, or the cap would count a PTY that is gone.
expect(job.agentSessionId).toBeNull();
// The NAME is history — "Viper did this work" stays true across a restart,
// and it is the credit the card exists to show. Only the session link,
// which cannot survive the process, is dropped.
expect(job.agentName).toBe('Viper');
expect(job.startedAt).toBe('2026-08-27T00:00:00Z');
expect(job.lastError).toMatch(/bill\/add-thing/);
});
// Nothing watches a no-PR card, so its note must not say the board is.
it('points a no-PR job at re-adopting its agent, not at a PR watch', () => {
writeConfig([{
id: 'j1', title: 'research', repoPath: '/r', state: 'in-progress', requiresPr: false,
agentSessionId: 'session-9', agentName: 'Viper', branchName: 'bill/research',
}]);
loadConfig();
expect(config.jobs[0].lastError).toMatch(/re-adopt/);
expect(config.jobs[0].lastError).not.toMatch(/watching for its PR/);
});
it('requeues a job that never got as far as a branch', () => {
writeConfig([{
id: 'j2', title: 'never started', repoPath: '/r', state: 'in-progress',
agentSessionId: 'session-1', agentName: 'Apex', branchName: null,
startedAt: '2026-08-27T00:00:00Z',
}]);
loadConfig();
const job = config.jobs[0];
expect(job.state).toBe('todo');
expect(job.agentSessionId).toBeNull();
expect(job.startedAt).toBeNull();
});
it('leaves todo and review jobs alone', () => {
writeConfig([
{ id: 'a', title: 'queued', repoPath: '/r', state: 'todo' },
{ id: 'b', title: 'done', repoPath: '/r', state: 'review', prNumber: 3, branchName: 'bill/x' },
]);
loadConfig();
expect(config.jobs.map(j => j.state)).toEqual(['todo', 'review']);
expect(config.jobs[1].prNumber).toBe(3);
});
it('survives a config with no jobs key at all', () => {
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify({ version: 1, repos: [] }));
loadConfig();
expect(config.jobs).toEqual([]);
});
});
describe('restart recovery for a running agent', () => {
beforeEach(() => orphans.clear());
afterEach(() => orphans.clear());
it('remembers which CLI the agent ran, so the orphan resumes with the right one', () => {
// The worktree must exist or the crashed session is skipped as gone.
const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
const wt2 = mkdtempSync(join(tmpdir(), 'a007-wt-'));
const wt3 = mkdtempSync(join(tmpdir(), 'a007-wt-'));
try {
writeConfig([]);
loadConfig();
saveActiveSession({ name: 'Onyx', command: 'codex --dangerously-bypass-approvals-and-sandbox "do it"', repoPath: '/r', repoSlug: 'r', worktreePath: wt, branchName: 'b/onyx', color: '#000', cocktail: 'onyx' });
saveActiveSession({ name: 'Viper', command: 'claude --permission-mode auto "do it"', repoPath: '/r', repoSlug: 'r', worktreePath: wt2, branchName: 'b/viper', color: '#000', cocktail: 'viper' });
expect(config.activeSessions.map(s => s.agent)).toEqual(['codex', 'claude']);
// And the permission flags each was started with, for a re-spawn that
// has no job card to ask.
expect(config.activeSessions.map(s => s.permissionFlags)).toEqual([['--dangerously-bypass-approvals-and-sandbox'], ['--permission-mode', 'auto']]);
saveActiveSession({ name: 'Dispatched', command: 'codex "job"', spawnedBy: 'board', jobId: 'job-7', approvalsToBillion: true, repoPath: '/r', repoSlug: 'r', worktreePath: wt3, branchName: 'b/dispatched', color: '#000', cocktail: 'dispatched' });
expect(config.activeSessions.map(s => s.origin)).toEqual(['user', 'user', 'board']);
// What the next start does with that record.
loadConfig();
recoverCrashedSessions();
const byName = Object.fromEntries([...orphans.values()].map(o => [o.name, o]));
expect(byName.Onyx.agent).toBe('codex');
expect(byName.Onyx.permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
expect(byName.Viper.agent).toBe('claude');
expect(byName.Viper.permissionFlags).toEqual(['--permission-mode', 'auto']);
expect(byName.Dispatched.origin).toBe('board');
// The card it worked on and where its approvals go survive the restart.
expect(byName.Dispatched.jobId).toBe('job-7');
expect(byName.Dispatched.approvalsToBillion).toBe(true);
expect(byName.Onyx.jobId).toBeNull();
expect(byName.Onyx.approvalsToBillion).toBe(false);
expect(byName.Onyx.origin).toBe('user');
expect(config.orphans.find(o => o.name === 'Onyx').permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
expect(config.orphans.find(o => o.name === 'Onyx').agent).toBe('codex'); // persisted, for the restart after this one
} finally {
rmSync(wt, { recursive: true, force: true });
rmSync(wt2, { recursive: true, force: true });
rmSync(wt3, { recursive: true, force: true });
}
});
it('prefers the session\'s own note over its command, and derives one only when there is none', () => {
expect(sessionAgent({ agent: 'codex', command: 'bash -lc codex' })).toBe('codex');
expect(sessionAgent({ agent: 'claude', command: 'codex resume' })).toBe('claude');
expect(sessionAgent({ agent: null, command: 'codex resume' })).toBeNull();
expect(sessionAgent({ command: 'claude --continue' })).toBe('claude');
expect(sessionAgent({ command: 'gemini' })).toBeNull();
expect(sessionAgent({})).toBeNull();
});
it('records flags for a session that owns them, and none for a board dispatch', async () => {
// A board dispatch runs under its card's mode, which the board resolves
// again at every re-spawn against its current setting; freezing the
// dispatch-time flags on the session would let a bypass card retired long
// ago come back as a bypass agent after the board was tightened.
const { createSessionFromConfig } = await import('../server/pty.js');
const spawn = (extra) => createSessionFromConfig({
sessionId: `s-${Math.random().toString(36).slice(2)}`, name: 'T', color: '#000',
command: process.platform === 'win32' ? 'cmd /c exit' : 'true', ...extra,
}, () => {});
// The command is a stand-in; the flags are judged from its text only when the CLI is one of ours,
// so pass them in the way the ws re-adopt does and check the rule, not the parser.
const own = spawn({ spawnedBy: 'user', permissionFlags: ['--sandbox', 'read-only'] });
const board = spawn({ spawnedBy: 'board', permissionFlags: undefined });
const passed = spawn({ spawnedBy: 'board', permissionFlags: ['--approve-for-me'] });
try {
expect(own.session.permissionFlags).toEqual(['--sandbox', 'read-only']);
expect(board.session.permissionFlags).toEqual([]);
expect(passed.session.permissionFlags).toEqual(['--approve-for-me']); // an explicit answer wins
// Lineage: a board dispatch is 'board'; a re-adopt opened as a user tab
// keeps the orphan's origin it was handed.
expect(own.session.origin).toBe('user');
expect(board.session.origin).toBe('board');
const readopted = spawn({ spawnedBy: undefined, origin: 'board', permissionFlags: [] });
try { expect(readopted.session.origin).toBe('board'); expect(readopted.session.spawnedBy).toBe('user'); }
finally { try { readopted.session.pty.kill(); } catch {} clearInterval(readopted.session.stateCheckInterval); }
// And on the records: the active-session entry and the orphan it becomes.
expect(sessionOrigin({ spawnedBy: 'board' })).toBe('board');
expect(sessionOrigin({ origin: 'board', spawnedBy: 'user' })).toBe('board');
expect(sessionOrigin({ spawnedBy: 'user' })).toBe('user');
expect(sessionOrigin({})).toBe('user');
} finally {
for (const r of [own, board, passed]) { try { r.session.pty.kill(); } catch {} clearInterval(r.session.stateCheckInterval); }
}
});
it('tolerates a session record written before the CLI was noted', () => {
const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
try {
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify({
version: 1, repos: [], orphans: [], jobs: [],
activeSessions: [{ name: 'Old', repoPath: '/r', repoSlug: 'r', worktreePath: wt, branchName: 'b/old', color: '#000' }],
}));
loadConfig();
recoverCrashedSessions();
expect([...orphans.values()][0].agent).toBeNull();
expect([...orphans.values()][0].permissionFlags).toEqual([]);
} finally {
rmSync(wt, { recursive: true, force: true });
}
});
it('passes a stored record\'s flags through the allowlist again, and only with a CLI of ours', () => {
// config.json is hand-editable: what comes back onto the resume argv is
// whatever the allowlist lets through, not whatever the file says. Each
// record needs its own live worktree, or recovery skips it as gone.
const wts = Array.from({ length: 5 }, () => mkdtempSync(join(tmpdir(), 'a007-wt-')));
try {
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify({
version: 1, repos: [], orphans: [], jobs: [],
activeSessions: [
{ name: 'Junk', agent: 'codex', permissionFlags: ['--sandbox', 'nope', '--approve-for-me', '--rm', '-rf', '/'], worktreePath: wts[0], branchName: 'b/junk' },
{ name: 'Text', agent: 'codex', permissionFlags: '--approve-for-me', worktreePath: wts[1], branchName: 'b/text' },
{ name: 'Cross', agent: 'claude', permissionFlags: ['--sandbox', 'read-only'], worktreePath: wts[2], branchName: 'b/cross' },
{ name: 'Alien', agent: 'gemini', permissionFlags: ['--permission-mode', 'plan'], worktreePath: wts[3], branchName: 'b/alien' },
{ name: 'Older', agent: 'claude', worktreePath: wts[4], branchName: 'b/older' }, // written before the flags were noted
],
}));
loadConfig();
recoverCrashedSessions();
const byName = Object.fromEntries([...orphans.values()].map(o => [o.name, [o.agent, o.permissionFlags]]));
expect(byName).toEqual({
Junk: ['codex', ['--approve-for-me']],
Text: ['codex', []],
Cross: ['claude', []],
Alien: [null, []],
Older: ['claude', []],
});
// And that is what is persisted for the restart after this one.
expect(config.orphans.find(o => o.name === 'Junk').permissionFlags).toEqual(['--approve-for-me']);
expect(config.activeSessions).toEqual([]);
} finally {
for (const wt of wts) rmSync(wt, { recursive: true, force: true });
}
});
it('takes the flags a session carries over its command, and reads the command only when it carries none', () => {
// Every PTY session carries them (createSessionFromConfig reads them off
// the command at spawn); a bare object, as a test or an older caller
// builds one, gets them read off the command the same way.
expect(sessionPermissionFlags({ permissionFlags: ['--approve-for-me'], command: 'codex -s read-only' })).toEqual(['--approve-for-me']);
expect(sessionPermissionFlags({ permissionFlags: [], command: 'codex -s read-only' })).toEqual([]);
expect(sessionPermissionFlags({ command: 'codex -s read-only' })).toEqual(['--sandbox', 'read-only']);
expect(sessionPermissionFlags({ permissionFlags: '--approve-for-me', command: 'claude --permission-mode plan' })).toEqual(['--permission-mode', 'plan']);
expect(sessionPermissionFlags({ command: 'bash -lc "codex -s read-only"' })).toEqual([]);
expect(sessionPermissionFlags({ command: 'gemini --yolo' })).toEqual([]);
expect(sessionPermissionFlags({})).toEqual([]);
// A re-adopted session's own note goes onto its record verbatim, whatever
// its command says: the note came off the command at spawn, and the
// record is re-checked against the allowlist on the way back in.
const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
try {
writeConfig([]);
loadConfig();
saveActiveSession({ name: 'Back', command: 'codex resume --sandbox read-only', permissionFlags: ['--sandbox', 'read-only'], agent: 'codex', worktreePath: wt, branchName: 'b/back' });
saveActiveSession({ name: 'Guess', command: 'codex resume --sandbox read-only', permissionFlags: ['--sandbox', 'read-only'], agent: null, worktreePath: wt, branchName: 'b/guess' });
saveActiveSession({ name: 'Bare', worktreePath: wt, branchName: 'b/bare' });
expect(config.activeSessions.map(s => [s.name, s.agent, s.permissionFlags])).toEqual([
['Back', 'codex', ['--sandbox', 'read-only']],
['Guess', null, ['--sandbox', 'read-only']],
['Bare', null, []],
]);
} finally {
rmSync(wt, { recursive: true, force: true });
}
});
it('notes a CLI only for a command that is literally one, and codex by the binary alone', () => {
// A re-adopted agent already on `codex resume` and one spawned as
// /opt/homebrew/bin/codex each record what their own re-adopt would need.
// A plain terminal tab, a gemini, and a Codex started from inside a shell
// record nothing: the note outranks every other witness at re-adopt time,
// and "claude" by default would have sent a `bash -lc codex` back up as
// Claude Code, past the job card and the transcripts that knew better. A
// session with no worktree has nothing to re-adopt and is not recorded.
const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
try {
writeConfig([]);
loadConfig();
saveActiveSession({ name: 'Bare', worktreePath: wt, branchName: 'b/bare' });
saveActiveSession({ name: 'Back', command: 'codex resume', worktreePath: wt, branchName: 'b/back' });
saveActiveSession({ name: 'Brew', command: '/opt/homebrew/bin/codex --model o3', worktreePath: wt, branchName: 'b/brew' });
saveActiveSession({ name: 'Gemini', command: 'gemini', worktreePath: wt, branchName: 'b/gemini' });
saveActiveSession({ name: 'Shell', command: 'bash -lc codex', worktreePath: wt, branchName: 'b/shell' });
// A re-adopted orphan whose CLI was only guessed carries agent: null on
// the session itself, and that provenance beats its resume command —
// otherwise a wrong guess would be recorded as fact on the next close.
saveActiveSession({ name: 'Guess', command: 'codex resume', agent: null, worktreePath: wt, branchName: 'b/guess' });
saveActiveSession({ name: 'NoTree', command: 'codex' });
expect(config.activeSessions.map(s => [s.name, s.agent])).toEqual([
['Bare', null], ['Back', 'codex'], ['Brew', 'codex'], ['Gemini', null], ['Shell', null], ['Guess', null],
]);
} finally {
rmSync(wt, { recursive: true, force: true });
}
});
});
describe('restart recovery for finished cards', () => {
it('drops the dead agent link on review and done cards, keeping the credit', () => {
// Session ids come from a counter that restarts at zero, so a stale
// `session-3` on an archived card can later name a completely unrelated
// live agent — and deleting that card would kill it and release its
// worktree. The name stays: it is the record of who did the work.
writeConfig([
{
id: 'j1', title: 'in review', repoPath: '/r', state: 'review',
agentSessionId: 'session-3', agentName: 'Viper',
branchName: 'bill/a', prNumber: 1, reviewAt: '2026-08-27T00:00:00Z',
},
{
id: 'j2', title: 'archived', repoPath: '/r', state: 'done',
agentSessionId: 'session-3', agentName: 'Apex',
branchName: 'bill/b', prNumber: 2,
prMergedAt: '2026-08-27T00:00:00Z', doneAt: '2026-08-27T00:00:01Z',
},
]);
loadConfig();
for (const job of config.jobs) expect(job.agentSessionId).toBeNull();
expect(config.jobs[0].agentName).toBe('Viper');
expect(config.jobs[1].agentName).toBe('Apex');
// Nothing else about a finished card moves.
expect(config.jobs[1].state).toBe('done');
expect(config.jobs[1].prMergedAt).toBe('2026-08-27T00:00:00Z');
});
});
describe('stale agent links after a restart', () => {
it('clears the session link on a review job too, not just in-flight ones', () => {
// Session ids are only unique within a process generation, so a link kept
// on a finished card could resolve to an unrelated agent after a restart.
writeConfig([{
id: 'r1', title: 'shipped', repoPath: '/r', state: 'review',
agentSessionId: 'session-5', agentName: 'Shadow',
branchName: 'bill/x', prNumber: 20,
}]);
loadConfig();
const job = config.jobs[0];
expect(job.agentSessionId).toBeNull();
expect(job.agentName).toBe('Shadow'); // credit kept
expect(job.state).toBe('review');
expect(job.prNumber).toBe(20);
});
it('clears it on a todo job as well', () => {
writeConfig([{ id: 't1', title: 'queued', repoPath: '/r', state: 'todo', agentSessionId: 'session-2' }]);
loadConfig();
expect(config.jobs[0].agentSessionId).toBeNull();
});
});
describe('a schedule saved mid-run by a server from before v0.4.9.0', () => {
it('goes back to To do, naming the dead run\'s branch', () => {
writeConfig([{
id: 's1', title: 'Daily digest', repoPath: '/r', type: 'scheduled', schedule: '0 9 * * *',
state: 'in-progress', agentSessionId: 'session-9', agentName: 'Viper', lastRunSessionId: 'session-8',
branchName: 'bill/digest', runCount: 4,
}]);
loadConfig();
const job = config.jobs[0];
expect(config.jobs).toHaveLength(1);
expect(job.state).toBe('todo');
expect(job.branchName).toBeNull();
expect(job.runCount).toBe(4);
expect(job.lastRunSessionId).toBeUndefined();
expect(job.lastError).toMatch(/bill\/digest/);
});
it('goes back to To do from Review too, and says nothing of a branch it never had', () => {
writeConfig([{
id: 's2', title: 'Daily digest', repoPath: '/r', type: 'scheduled', schedule: '@daily',
state: 'review', agentSessionId: 'session-3', lastError: null,
}]);
loadConfig();
expect(config.jobs[0].state).toBe('todo');
expect(config.jobs[0].lastError).toBeNull();
});
});