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>
This commit is contained in:
Michael Grundberg
2026-09-17 18:46:15 +02:00
co-authored by Claude Opus 5
parent 5f55f9cb65
commit 5108a24bf0
2 changed files with 51 additions and 1 deletions
+27
View File
@@ -39,6 +39,7 @@ 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',
@@ -137,6 +138,32 @@ describe('which dead sessions may be rebuilt', () => {
});
});
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' }) };