fix(sessions): undo a failed rebuild without deleting the user's data

A second review of the previous commit found that its own repair for the
session leak introduced three defects, all from reaching for
cleanupSession() to undo a half-built session. That function is the
user-initiated delete, not an undo.

It banked the session's historical token and cost totals into the lifetime
figures, and a reboot never runs cleanup, so those totals had never been
counted before; every failed rebuild added them again. It saw the pin that
had just been restored and demoted the record to `stopped`, which this pass
reads as the durable marker of a deliberate kill, so a pinned session whose
rebuild failed became permanently unrestorable. And it recursively removed
`.claude-images` from the working directory, which belongs to the workspace
rather than to the session, so a failed rebuild destroyed the pasted images
of any other live session in that repo.

discardPartiallyBuiltSession() now undoes only what the construction did:
the map entry, the tab-layout slot, the listeners and any pane the launch
created before throwing. The persisted record, the lifetime totals, the
Ralph state and the workspace's files are left alone.

Re-applying the persisted state also splits in two, which removes the first
two defects at the root rather than only at the call site. The half that
shapes the pane, the custom-model environment and the nice priority, still
runs before the spawn. The half that is the session's own history now runs
after it, so a session whose pane never started carries no totals and no pin
for anything downstream to misread.

The rest of that review. The multi-user workspace confinement re-check read
the requesting user's grant, and returns true for an admin, so the case its
own comment described was the one it missed; it now resolves the entry
owner's grant through isWorkingDirAllowedForUsername, the way cron does. A
forbidden workspace goes back on offer, matching both the registry's stated
contract and the API reference. The client re-reads the plan after a restore
instead of blanking the banner, so entries the server put back stay
reachable, and a 409 now says a restore is already running rather than
reporting a failure. A dismiss arriving mid-restore wins, through a
generation counter the route carries across its take. The re-application
also restores the tab colour, the image-watcher flag and the original
pinnedAt, via a new Session.restorePin that does not re-stamp the pin time.
The phone breakpoint gains min-width: 0, without which a nowrap flex item
never shrinks and the buttons still overflow, and it folds into the existing
phone block.

Ralph's loop configuration still does not survive a restore, because
toState() reads it off a live tracker and there is no way to keep it without
arming the loop. The method now says so rather than leaving it implied.

Tests. The capacity test could not fail on the property it existed for: it
filled the board past the cap before the loop, so a single pre-loop check
would have passed it. It now leaves one seat, so only a per-iteration check
restores exactly one entry. New tests cover the ordering around the spawn,
a throw before the loop returning the whole plan and releasing the flight,
the dismiss-during-restore race, and that the failure path calls the narrow
discard rather than the delete. The shared mock context gains the port
method it was missing, which is what made the first run of these tests fail
for the wrong reason.

Refs #411

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-16 15:04:35 +02:00
co-authored by Claude Opus 5
parent fbede5cd2a
commit fa52753e8b
9 changed files with 273 additions and 47 deletions
+3
View File
@@ -62,6 +62,9 @@ export function createMockRouteContext(options?: {
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
reapplyPersistedSessionState: vi.fn(async () => {}),
discardPartiallyBuiltSession: vi.fn(async (id: string) => {
sessions.delete(id);
}),
getSessionStateWithRespawn: vi.fn((s: MockSession) => s.toState()),
// -- EventPort --
@@ -18,6 +18,8 @@ import fastifyCookie from '@fastify/cookie';
/** Set per test: whether the mocked `startInteractive()` rejects. */
let startShouldThrow = false;
/** Ordering log, so a test can assert what ran before the pane spawned. */
const callOrder: string[] = [];
vi.mock('../../src/session.js', () => ({
Session: class {
@@ -35,6 +37,7 @@ vi.mock('../../src/session.js', () => ({
this.owner = config.owner;
}
async startInteractive() {
callOrder.push('startInteractive');
if (startShouldThrow) throw new Error('spawn claude ENOENT');
}
/** The mock route context projects a session through this on broadcast. */
@@ -101,6 +104,7 @@ async function createHarness(ctx: ReturnType<typeof createMockRouteContext>): Pr
beforeEach(() => {
startShouldThrow = false;
callOrder.length = 0;
});
afterEach(() => {
@@ -131,7 +135,12 @@ describe('a rebuild that fails after the session is registered', () => {
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
// The session reached ctx.sessions via addSession; the route has to take it
// back out, or the board shows a tab whose pane never existed.
expect(ctx.cleanupSession).toHaveBeenCalledWith('a', true, expect.any(String));
expect(ctx.discardPartiallyBuiltSession).toHaveBeenCalledWith('a');
expect(ctx.sessions.has('a')).toBe(false);
// NOT the user-initiated delete: that would bank this session's historical
// tokens into the lifetime totals, demote a pinned record to `stopped`, and
// delete the workspace's .claude-images.
expect(ctx.cleanupSession).not.toHaveBeenCalled();
await app.close();
});
@@ -166,6 +175,23 @@ describe('a rebuild that succeeds', () => {
await app.close();
});
it('shapes the pane before it spawns, and restores the history after', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
(ctx.reapplyPersistedSessionState as ReturnType<typeof vi.fn>).mockImplementation(
async (_s: unknown, _saved: unknown, phase: string) => {
callOrder.push(`reapply:${phase}`);
}
);
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
// The custom-model environment has to reach the process; the token totals
// must not land on a session whose pane never started.
expect(callOrder).toEqual(['reapply:before-spawn', 'startInteractive', 'reapply:after-spawn']);
await app.close();
});
it('tells every other board about the rebuilt session', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
@@ -189,10 +215,30 @@ describe('a rebuild that succeeds', () => {
});
describe('the session caps', () => {
it('stops restoring at the global cap and leaves the rest on offer', async () => {
it('counts the sessions it is itself creating, not just the ones it started with', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
// One seat short of the documented maximum of 50, counting the session the
// mock context seeds. A check that ran once before the loop would restore
// BOTH entries; only a per-iteration check refuses the second.
for (let i = 0; i < 48; i += 1) {
ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
}
const app = await createHarness(ctx);
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(res.restored.map((s: { id: string }) => s.id)).toEqual(['a']);
expect(res.skipped).toEqual([{ sessionId: 'b', reason: 'capacity-reached' }]);
// Refused rather than lost: closing a session and clicking again works.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['b']);
await app.close();
});
it('refuses every entry when the board is already at the cap', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
// Fill the board to the documented maximum of 50 concurrent sessions.
for (let i = 0; i < 50; i += 1) {
ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
}
@@ -201,10 +247,53 @@ describe('the session caps', () => {
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(res.restored).toEqual([]);
expect(res.skipped.map((s: { reason: string }) => s.reason)).toEqual(['capacity-reached', 'capacity-reached']);
await app.close();
});
});
// Refused rather than lost: closing a session and clicking again works.
describe('a failure before any entry is considered', () => {
it('returns the whole plan rather than spending it', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('settings unreadable'));
const app = await createHarness(ctx);
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
expect(res.statusCode).toBeGreaterThanOrEqual(500);
// The plan cannot be rebuilt once boot has pruned the records, so a throw
// anywhere in the route has to hand the entries back.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
await app.close();
});
it('releases the single flight, so the next click is not refused', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('settings unreadable'));
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
expect(rebootRestoreRegistry.beginSpending(undefined)).toBe(true);
rebootRestoreRegistry.endSpending(undefined);
await app.close();
});
});
describe('a dismiss that lands while a restore is running', () => {
it('wins, rather than being undone when the restore hands its entries back', async () => {
const entries = [offerEntry('a')];
rebootRestoreRegistry.set(entries);
const generation = rebootRestoreRegistry.currentGeneration();
const taken = rebootRestoreRegistry.take(() => true);
expect(taken).toHaveLength(1);
// The user clears the banner while the restore is still working.
rebootRestoreRegistry.clear(() => true);
// The restore finishes and tries to put its unspent entry back.
rebootRestoreRegistry.restore(taken, generation);
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
});
});