mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(sessions): offer to rebuild the sessions a host reboot destroyed
A host reboot takes the tmux server down with it, so every pane dies, reconciliation finds nothing to attach to, and the board comes up empty. Picking yesterday's work back up meant finding each conversation in history and resuming it by hand, one at a time. The boot pass now works out what the reboot killed and leaves it on offer. It runs inside restoreMuxSessions(), in the window where reconciliation has reported the dead sessions and cleanupStaleSessions() has not pruned their records yet, which is the only place the records can still be read. The board shows a banner, and nothing is created until the user clicks it. A click rather than an automatic restore is what makes the reboot heuristic acceptable. The heuristic cannot tell a reboot from a crash that took tmux down inside the same window, so it decides whether to ASK, never whether to act: a wrong yes costs a line of text the user dismisses instead of N CLI processes nobody asked for. Four things are re-checked when the click arrives rather than trusted from boot, because hours can pass and the board moves on. The owner's privilege grant re-resolves through the env clamp. The workspace must still be on disk. A conversation the user already resumed by hand from the Resume list is skipped, since two panes running --resume on one conversation would fight over the same transcript. Entries leave the plan synchronously before the first await, and the route is single-flighted, so a double-click or two devices cannot both reach the same entry. A restored session comes back attached, idle and disarmed. Respawn controllers and Ralph loops are deliberately not re-armed: a machine that just came up is the worst moment to turn an autonomous run loose. Its workspace hooks are installed by the restore route itself, because the boot-time sweep sits behind a gate that is false after a reboot and has finished long before the click; without them a session goes silently blind, with no stop or idle events for respawn, no Approvals Inbox item and no red tab on a blocking dialog. Stats collection starts the same way. The pane is new, so the conversation continues and the terminal scrollback does not. The banner says so rather than letting an empty pane read as a broken restore. The plan lives in memory only. A server restart drops it, which costs the convenience this adds and never the conversation: the conversation is the transcript under ~/.claude/projects, which the Welcome screen's Resume list and the Session Manager already read, so a dropped plan returns the user to resuming by hand. clampEnvOverridesForOwner moves to src/session-env-clamp.ts, since the question it answers is about session privilege rather than about HTTP and it now has a caller outside the route layer. Its test hook stays re-exported from session-routes.ts. Claude sessions only for this pass. The other CLIs name their thread in their own config object, which this does not thread through yet. Remote and docker sessions are skipped on purpose, because both need another host or a container to be up and a freshly booted machine cannot promise either. Refs #411 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bd286bf502
commit
da933d70be
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Reboot-restore route tests (src/web/routes/reboot-restore-routes.ts) via
|
||||
* app.inject(), no live port.
|
||||
*
|
||||
* Every entry these tests put on offer names a workspace that does not exist, so
|
||||
* the route's click-time workspace check rejects it before any `Session` is
|
||||
* constructed. That keeps the tests on the route's own guards — taking, scoping,
|
||||
* single-flighting and re-checking — and leaves pane creation to
|
||||
* test/reboot-restore.test.ts, which drives a real `Session` for it.
|
||||
*
|
||||
* The routes read the process-wide `rebootRestoreRegistry` singleton, so every
|
||||
* test resets it; a leaked entry would bleed into the next one.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { registerRebootRestoreRoutes } from '../../src/web/routes/reboot-restore-routes.js';
|
||||
import { rebootRestoreRegistry } from '../../src/web/reboot-restore-registry.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import type { RebootRestoreEntry } from '../../src/reboot-restore.js';
|
||||
import type { SessionState } from '../../src/types.js';
|
||||
|
||||
async function createHarness(authUser?: { username: string; role: 'admin' | 'user' }): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
if (authUser) {
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
registerRebootRestoreRoutes(app, createMockRouteContext() as never);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
/** An entry whose workspace is deliberately absent, so no pane is ever created. */
|
||||
function offerEntry(sessionId: string, owner?: string): RebootRestoreEntry {
|
||||
return {
|
||||
sessionId,
|
||||
name: `session ${sessionId}`,
|
||||
workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
|
||||
owner,
|
||||
mode: 'claude',
|
||||
resumeConversationId: `conv-${sessionId}`,
|
||||
state: {
|
||||
id: sessionId,
|
||||
pid: null,
|
||||
status: 'idle',
|
||||
workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
|
||||
currentTaskId: null,
|
||||
createdAt: 1_760_000_000_000,
|
||||
mode: 'claude',
|
||||
owner,
|
||||
} as SessionState,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
rebootRestoreRegistry.reset();
|
||||
});
|
||||
|
||||
describe('GET /api/reboot-restore', () => {
|
||||
it('reports nothing when no reboot left anything behind', async () => {
|
||||
const app = await createHarness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/reboot-restore' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.sessions).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('names what is on offer, and says the scrollback is not coming back', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const app = await createHarness();
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(body.sessions.map((s: { id: string }) => s.id)).toEqual(['a', 'b']);
|
||||
expect(body.scrollbackRestored).toBe(false);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('never carries the persisted record itself to the browser', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a', 'alice')]);
|
||||
const app = await createHarness({ username: 'alice', role: 'admin' });
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(Object.keys(body.sessions[0]).sort()).toEqual(['id', 'mode', 'name', 'owner', 'workingDir']);
|
||||
expect(body.sessions[0].state).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/reboot-restore/restore', () => {
|
||||
it('spends the offer, so a second click finds nothing left to spend', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const app = await createHarness();
|
||||
|
||||
const first = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
// The workspace is gone, so nothing was rebuilt — but the entry was taken.
|
||||
expect(first.restored).toEqual([]);
|
||||
expect(first.skipped).toEqual([{ sessionId: 'a', reason: 'workspace-missing' }]);
|
||||
|
||||
const second = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(second.restored).toEqual([]);
|
||||
expect(second.skipped).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('spends only the sessions the click named', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const app = await createHarness();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/reboot-restore/restore',
|
||||
payload: { sessionIds: ['b'] },
|
||||
});
|
||||
expect(res.json().data.skipped).toEqual([{ sessionId: 'b', reason: 'workspace-missing' }]);
|
||||
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('refuses a body it does not recognise rather than guessing', async () => {
|
||||
const app = await createHarness();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/reboot-restore/restore',
|
||||
payload: { sessionIds: 'not-an-array' },
|
||||
});
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('turns a second concurrent restore away rather than interleaving it', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
// Claimed by a restore already in flight.
|
||||
expect(rebootRestoreRegistry.beginSpending()).toBe(true);
|
||||
const app = await createHarness();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
expect(res.statusCode).toBe(409);
|
||||
rebootRestoreRegistry.endSpending();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/reboot-restore/dismiss', () => {
|
||||
it('drops the offer and leaves the banner with nothing to show', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const app = await createHarness();
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/dismiss', payload: {} });
|
||||
expect(res.json().data.dismissed).toBe(2);
|
||||
|
||||
const after = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(after.sessions).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user