mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix(sessions): act on the dual review of the reboot-restore route
Fifteen findings from two independent reviews of #442, three of them blocking. Every one is addressed here. The three blockers all sat in the restore route. A rebuild that threw after addSession left a registered session with no pane behind it, visible on the board, holding a layout slot and written to state.json, with its plan entry already spent; the catch now cleans the session up and puts the entry back. The loop checked neither the global nor the per-user session cap, so one click could take a board past a documented limit; capacity is now re-checked per iteration, because the loop is itself creating the sessions it counts. Worst of the three, a rebuilt session carried none of the state its constructor has no parameter for and then persisted itself over the record that held it, zeroing token and cost totals and dropping the pin. The pin matters most: pruning keeps a record only while it is pinned, so discarding it handed the record to the next stale sweep. A new reapplyPersistedSessionState() on the session port restores the pin, the token totals, auto-compact, auto-clear, auto-resume, nice priority, the flicker filter and the custom-model selection, and it runs before both startInteractive and the first persist. The rest, in the order they bite a user. Every rebuild failure was reported as workspace-missing, so the banner told users their repo was gone when the agent had simply failed to start; there are now distinct reasons, and the toast names each one. The client read restored and skipped off the outer response object rather than through the uniform envelope, so every count came back zero and neither toast ever fired. A board left open across the reboot never learned an offer existed, because the banner was seeded only on the page-load path; it now re-reads on every SSE init. The workspace check was existence-only, skipping the multi-user confinement that the create route applies, so a withdrawn grant would not be noticed. The banner had no phone breakpoint while its text was nowrap and its buttons could not shrink. Smaller: a missing workspace is now re-offered rather than dropped, while an already-open conversation is dropped rather than re-offered forever; a throw anywhere in the route returns the unspent entries instead of discarding the plan; the single flight is keyed by owner, since take() already stops two callers receiving one entry; the env clamp's header no longer claims a protection it cannot provide on this path today, and names the check that does bite; the three endpoints are documented in docs/api-reference.md; and the module header now says that os.uptime() reads the host's clock, so the feature is effectively off inside a container. The review also explained why the tests missed all of this: they proved the construction claim through their own copy of the construction rather than through the route, and the route tests used workspaces that did not exist, so no Session was ever built. test/routes/reboot-restore-rebuild-failure.ts mocks the Session module to drive the route's real path, and covers the cleanup, the reason reported, the re-application ordering, the broadcast and the caps. The mock route context gains the port method and the mux call the route needs. 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
da933d70be
commit
fbede5cd2a
@@ -23,6 +23,13 @@ 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> {
|
||||
return createHarnessWithCtx(createMockRouteContext(), authUser);
|
||||
}
|
||||
|
||||
async function createHarnessWithCtx(
|
||||
ctx: ReturnType<typeof createMockRouteContext>,
|
||||
authUser?: { username: string; role: 'admin' | 'user' }
|
||||
): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
if (authUser) {
|
||||
@@ -30,7 +37,7 @@ async function createHarness(authUser?: { username: string; role: 'admin' | 'use
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
registerRebootRestoreRoutes(app, createMockRouteContext() as never);
|
||||
registerRebootRestoreRoutes(app, ctx as never);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
@@ -106,18 +113,36 @@ describe('GET /api/reboot-restore', () => {
|
||||
});
|
||||
|
||||
describe('POST /api/reboot-restore/restore', () => {
|
||||
it('spends the offer, so a second click finds nothing left to spend', async () => {
|
||||
it('reports a workspace that is gone, and keeps offering it in case it comes back', 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([]);
|
||||
// Nothing was built, so the entry goes back: a repo can be restored from a
|
||||
// backup between two clicks, and losing the offer would be unrecoverable.
|
||||
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('never re-offers a conversation that is already open', async () => {
|
||||
const entry = offerEntry('a');
|
||||
rebootRestoreRegistry.set([entry]);
|
||||
const app = await createHarness();
|
||||
const ctx = createMockRouteContext({ sessionId: entry.sessionId });
|
||||
// A session with that id is live, which is what the Resume list would produce.
|
||||
const liveApp = await createHarnessWithCtx(ctx);
|
||||
|
||||
const res = (await liveApp.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(res.skipped).toEqual([{ sessionId: 'a', reason: 'already-live' }]);
|
||||
|
||||
// Unlike a missing workspace, this one is dropped: it cannot stop being true.
|
||||
const left = (await liveApp.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions).toEqual([]);
|
||||
await liveApp.close();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -132,8 +157,9 @@ describe('POST /api/reboot-restore/restore', () => {
|
||||
});
|
||||
expect(res.json().data.skipped).toEqual([{ sessionId: 'b', reason: 'workspace-missing' }]);
|
||||
|
||||
// 'a' was never taken, and 'b' came back because no pane was built for it.
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
|
||||
expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -150,12 +176,13 @@ describe('POST /api/reboot-restore/restore', () => {
|
||||
|
||||
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);
|
||||
// Claimed by a restore already in flight for this same owner (undefined in
|
||||
// single-user mode, which is what the harness runs as).
|
||||
expect(rebootRestoreRegistry.beginSpending(undefined)).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();
|
||||
rebootRestoreRegistry.endSpending(undefined);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user