Files
Codeman/test/reboot-restore.test.ts
T
Michael GrundbergandClaude Opus 5 5108a24bf0 fix(sessions): never restore a session whose agent was already exited
Found by a real reboot, which is the first thing to catch it. Typing `/exit`
ends the CLI process and leaves the session record behind, and the
process-exit handler persists `pid: null` with `status: 'idle'` before
anything else runs. By status alone that is indistinguishable from a session
sitting idle when the power went, so the boot pass offered those sessions
back and a click spawned the agents the user had deliberately closed — the
exact case the eligibility rule exists to exclude.

The absent pid is what tells the two apart, and the plan step now refuses a
record without one, under its own `not-running` reason so the boot log says
why. On a healthy board every running session carries a pid; a record with
none describes an agent that is already gone.

Deliberately the conservative direction. A session that somehow persisted no
pid while genuinely running is not offered, and its conversation stays
reachable from the Resume list, which is where every session would be
without this feature. The opposite error spawns processes nobody asked for.

Ark0N/Codeman#446 covers the dead panes those exits leave behind, but this
does not wait on it: the rule belongs here whether or not the record's shape
changes later.

Refs #411

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:46:15 +02:00

395 lines
16 KiB
TypeScript

/**
* @fileoverview The decision half of reboot restore, and proof that the existing
* recovery construction path can CREATE a resumed pane.
*
* Three things are under test. `src/reboot-restore.ts` decides whether the
* machine rebooted and which dead sessions may be offered back. The plan
* registry in `src/web/reboot-restore-registry.ts` holds that offer between the
* boot that builds it and the click that spends it. The third is the claim the
* whole feature rests on: a `Session` built the way `restoreMuxSessions()`
* already builds one, but given no `muxSession` and a `resumeSessionId`, creates
* a fresh pane that resumes the old conversation. If that holds, the restore
* needs no new session-creation service.
*
* `reconcileSessions()` reports every session ALIVE under vitest, so the
* server's own boot pass cannot be reached from here. The decision logic is
* therefore driven directly, and the construction claim is driven through a real
* `Session` against the in-memory tmux layer vitest substitutes.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionState } from '../src/types.js';
import {
looksLikeHostReboot,
newestPersistedActivity,
planRebootRestore,
rejectAlreadyLive,
resolveResumeConversationId,
type RebootRestoreEntry,
} from '../src/reboot-restore.js';
import { RebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
const HOUR = 60 * 60 * 1000;
const NOW = 1_760_000_000_000;
function persistedSession(overrides: Partial<SessionState> & { id: string }): SessionState {
return {
// A live agent's record carries its process id; `/exit` persists null instead.
pid: 99999,
status: 'idle',
workingDir: '/tmp/spike',
currentTaskId: null,
createdAt: NOW - 4 * HOUR,
lastActivityAt: NOW - 2 * HOUR,
mode: 'claude',
...overrides,
} as SessionState;
}
describe('reboot detection', () => {
const base = {
livePaneCount: 0,
deadSessionCount: 2,
// The host came up 10 minutes ago, well after the sessions were last active.
uptimeSeconds: 600,
newestPersistedActivityAt: NOW - 2 * HOUR,
now: NOW,
};
it('calls it a reboot when the socket is empty and the host booted after the last activity', () => {
expect(looksLikeHostReboot(base)).toBe(true);
});
it('refuses when some panes survived, which is an ordinary server restart', () => {
expect(looksLikeHostReboot({ ...base, livePaneCount: 3 })).toBe(false);
});
it('refuses on a long-uptime host, where someone wiped the tmux socket by hand', () => {
// Up for 30 days: the sessions were active long AFTER this boot, so the panes
// went away for some reason other than the machine restarting.
expect(looksLikeHostReboot({ ...base, uptimeSeconds: 30 * 24 * 60 * 60 })).toBe(false);
});
it('refuses when nothing died', () => {
expect(looksLikeHostReboot({ ...base, deadSessionCount: 0 })).toBe(false);
});
it('reads the newest activity stamp across the persisted records', () => {
const persisted = {
a: persistedSession({ id: 'a', lastActivityAt: NOW - 5 * HOUR }),
b: persistedSession({ id: 'b', lastActivityAt: NOW - 1 * HOUR }),
};
expect(newestPersistedActivity(persisted)).toBe(NOW - 1 * HOUR);
});
});
describe('which dead sessions may be rebuilt', () => {
it('rebuilds a session that was simply running when the power went out', () => {
const persisted = { live: persistedSession({ id: 'live', status: 'busy' }) };
const plan = planRebootRestore(['live'], persisted, () => true);
expect(plan.restore.map((s) => s.sessionId)).toEqual(['live']);
});
it('never revives a session the user killed while pinned (COD-142 demotes it to stopped)', () => {
const persisted = { killed: persistedSession({ id: 'killed', status: 'stopped', pinned: true }) };
const plan = planRebootRestore(['killed'], persisted, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'killed', reason: 'intentionally-ended' }]);
});
it('never revives a session whose record an unpinned kill already deleted', () => {
const plan = planRebootRestore(['gone'], {}, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
});
it('never revives a pane whose PTY-exit breaker had tripped', () => {
const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
});
it('leaves remote sessions to the COD-108 reconnect watcher', () => {
const persisted = {
r: persistedSession({
id: 'r',
remote: { hostId: 'h', host: 'example.test', username: 'u', sessionName: 'n', owned: true },
} as Partial<SessionState> & { id: string }),
};
expect(planRebootRestore(['r'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
});
it('leaves docker sessions alone, since the container may not be up', () => {
const persisted = {
d: persistedSession({ id: 'd', docker: { containerId: 'abc', caseId: 'c' } } as Partial<SessionState> & {
id: string;
}),
};
expect(planRebootRestore(['d'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
});
it('skips a CLI whose history the claude transcript reader does not understand', () => {
const persisted = { c: persistedSession({ id: 'c', mode: 'codex' }) };
expect(planRebootRestore(['c'], persisted, () => true).skipped[0].reason).toBe('unsupported-mode');
});
});
describe('a session whose agent had already exited', () => {
it('is refused, because `/exit` leaves the record reading idle with no pid', () => {
// What the process-exit handler persists: the CLI is gone, the record is not,
// and its status is indistinguishable from a session that was merely idle.
const persisted = { exited: persistedSession({ id: 'exited', status: 'idle', pid: null }) };
const plan = planRebootRestore(['exited'], persisted, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'exited', reason: 'not-running' }]);
});
it('still restores the session beside it that was running when the power went', () => {
const persisted = {
exited: persistedSession({ id: 'exited', pid: null }),
running: persistedSession({ id: 'running', pid: 4242 }),
};
const plan = planRebootRestore(['exited', 'running'], persisted, () => true);
expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['running']);
expect(plan.skipped.map((s) => s.reason)).toEqual(['not-running']);
});
it('refuses a record with no pid field at all', () => {
const persisted = { odd: persistedSession({ id: 'odd', pid: undefined as unknown as null }) };
expect(planRebootRestore(['odd'], persisted, () => true).skipped[0].reason).toBe('not-running');
});
});
describe('a workspace that is no longer on disk', () => {
it('is kept out of the offer, so a click cannot scaffold a deleted repo', () => {
const persisted = { gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }) };
const plan = planRebootRestore(['gone'], persisted, () => false);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'workspace-missing' }]);
});
it('is judged per session, not for the batch', () => {
const persisted = {
kept: persistedSession({ id: 'kept', workingDir: '/tmp/still-here' }),
gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }),
};
const plan = planRebootRestore(['kept', 'gone'], persisted, (dir) => dir === '/tmp/still-here');
expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['kept']);
expect(plan.skipped.map((s) => s.reason)).toEqual(['workspace-missing']);
});
});
describe('a conversation that came back on its own before the click', () => {
const entry: RebootRestoreEntry = {
sessionId: 'abc',
workingDir: '/tmp/spike',
mode: 'claude',
resumeConversationId: 'conv-1',
state: persistedSession({ id: 'abc' }),
};
it('is skipped when the user resumed it by hand from the Resume list', () => {
// Same conversation, different session id: the Resume list creates a NEW id.
const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-1']));
expect(result.restore).toEqual([]);
expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
});
it('is skipped when a session with that id is already on the board', () => {
const result = rejectAlreadyLive([entry], new Set(['abc']), new Set());
expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
});
it('is rebuilt when neither its id nor its conversation is live', () => {
const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-other']));
expect(result.restore.map((e) => e.sessionId)).toEqual(['abc']);
expect(result.skipped).toEqual([]);
});
});
describe('the plan the banner spends', () => {
const all = () => true;
const entryFor = (sessionId: string, owner?: string): RebootRestoreEntry => ({
sessionId,
owner,
workingDir: '/tmp/spike',
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: persistedSession({ id: sessionId, owner }),
});
it('hands an entry to the first caller and nothing to the second', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a'), entryFor('b')]);
expect(registry.take(all, undefined, undefined).map((e) => e.sessionId)).toEqual(['a', 'b']);
// The double-click: two panes on one conversation is what this prevents.
expect(registry.take(all, undefined, undefined)).toEqual([]);
});
it('spends only the ids a caller asked for', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a'), entryFor('b')]);
expect(registry.take(all, ['b'], undefined).map((e) => e.sessionId)).toEqual(['b']);
expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
});
it("shows a user their own sessions and leaves another owner's alone", () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('mine', 'alice'), entryFor('theirs', 'bob')]);
const asAlice = (owner: string | undefined) => owner === 'alice';
expect(registry.list(asAlice).map((e) => e.sessionId)).toEqual(['mine']);
expect(registry.take(asAlice, undefined, 'alice').map((e) => e.sessionId)).toEqual(['mine']);
// Bob's entry is still on offer for Bob.
expect(registry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
});
it('puts back an entry that no pane was created for', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a')]);
const taken = registry.take(all, undefined, undefined);
registry.releaseFlight(undefined, taken);
expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
});
it('runs one restore at a time', () => {
const registry = new RebootRestoreRegistry();
expect(registry.beginSpending()).toBe(true);
expect(registry.beginSpending()).toBe(false);
registry.endSpending();
expect(registry.beginSpending()).toBe(true);
});
it('drops what a dismiss cleared', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a'), entryFor('b')]);
expect(registry.clear(all)).toBe(2);
expect(registry.list(all)).toEqual([]);
});
it('forgets a plan nobody took for a day', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a')]);
const dayLater = Date.now() + 25 * HOUR;
const realNow = Date.now;
Date.now = () => dayLater;
try {
expect(registry.list(all)).toEqual([]);
} finally {
Date.now = realNow;
}
});
});
describe('which conversation a rebuilt pane resumes', () => {
it('prefers the chain tail, the conversation the CLI reported last', () => {
const state = persistedSession({
id: 'sess-1',
resumeSessionId: 'launch-id',
claudeSessionChain: ['launch-id', 'after-clear'],
});
expect(resolveResumeConversationId(state)).toBe('after-clear');
});
it('falls back to the id the session originally resumed', () => {
const state = persistedSession({ id: 'sess-1', resumeSessionId: 'resumed-id' });
expect(resolveResumeConversationId(state)).toBe('resumed-id');
});
it('falls back to the session id, which is what Claude was launched with', () => {
expect(resolveResumeConversationId(persistedSession({ id: 'sess-1' }))).toBe('sess-1');
});
});
describe('the recovery construction path can create a resumed pane', () => {
const workingDir = join(homedir(), 'codeman-cases', 'reboot-restore-spike');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(workingDir, { recursive: true, force: true });
});
/** Built exactly as the reboot pass builds one: no `muxSession`, plus a resume id. */
function rebuildFromPersistedState(state: SessionState, mux: TmuxManager): Session {
mkdirSync(workingDir, { recursive: true });
const session = new Session({
id: state.id,
workingDir,
mode: state.mode,
name: state.name,
createdAt: state.createdAt,
mux,
useMux: true,
resumeSessionId: resolveResumeConversationId(state),
owner: state.owner,
lastActivityAt: state.lastActivityAt,
claudeSessionChain: state.claudeSessionChain,
});
sessions.push(session);
return session;
}
it('creates a NEW mux session rather than needing one to attach to', async () => {
const mux = new TmuxManager();
const state = persistedSession({ id: 'aaaaaaa1-1111-4111-8111-111111111111', name: 'w1-spike' });
const session = rebuildFromPersistedState(state, mux);
expect(mux.getSessions()).toHaveLength(0);
await session.startInteractive();
const created = mux.getSessions();
expect(created).toHaveLength(1);
expect(created[0].sessionId).toBe('aaaaaaa1-1111-4111-8111-111111111111');
expect(created[0].workingDir).toBe(workingDir);
});
it('comes back pointed at the conversation the pane was holding', async () => {
const mux = new TmuxManager();
const state = persistedSession({
id: 'aaaaaaa2-2222-4222-8222-222222222222',
resumeSessionId: 'launch-id',
claudeSessionChain: ['launch-id', 'after-clear'],
});
const session = rebuildFromPersistedState(state, mux);
await session.startInteractive();
// The chain tail wins: a `/clear` before the reboot moved the CLI off the launch id.
expect(session.claudeSessionId).toBe('after-clear');
});
it('comes back idle, with no prompt sent and no autonomous loop armed', async () => {
const mux = new TmuxManager();
const state = persistedSession({
id: 'aaaaaaa3-3333-4333-8333-333333333333',
ralphEnabled: true,
respawnEnabled: true,
});
const session = rebuildFromPersistedState(state, mux);
await session.startInteractive();
// No prompt was queued: nothing is waiting on a task. The status itself is not
// assertable here, because the test PTY echoes and the activity detector reads
// that echo as work; in production the pane settles once the CLI finishes booting.
expect(session.currentTaskId).toBeNull();
// The pass never touches the tracker, so a persisted Ralph loop stays cold.
expect(session.ralphTracker.enabled).toBe(false);
});
it('keeps the owner it was persisted with, there being no request to read one from', async () => {
const mux = new TmuxManager();
const state = persistedSession({ id: 'aaaaaaa4-4444-4444-8444-444444444444', owner: 'alice' });
const session = rebuildFromPersistedState(state, mux);
await session.startInteractive();
expect(session.owner).toBe('alice');
expect(mux.getSessions()[0].owner).toBe('alice');
});
});