mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
fix(sessions): make the discard a real inverse of the construction
Third review of the reboot-restore branch. The narrow discard the previous commit introduced avoided everything cleanupSession() did wrongly, and in dropping so much of it also dropped four things it had to keep. The worst broke the retry the whole design rests on. setupSessionListeners() returns early while sessionListenerRefs still holds the session id, and the discard never cleared that entry. So the advertised flow — a rebuild fails because the agent binary is missing, the user fixes their PATH and clicks again — reused the same id, wired no listeners at all, and produced a tab that never showed output, never updated its status and never persisted. That is worse than the leak the discard was added to prevent. Three more registrations leaked with it: a RunSummaryTracker and its interval, an image watcher on the workspace, and the Ralph fix-plan watcher. The discard now undoes each registration setupSessionListeners() makes, in its order, and the per-session custom-model config directory, which holds the endpoint's API key literally and which nothing else would ever remove. The image-watcher flag was restored after the code that reads it, so a session came back reporting the feature as on with nothing watching. It moves to the before-spawn phase, and that phase now runs before the listeners rather than after them. The generation counter that lets a mid-restore dismiss win was global while clear() is ownership-scoped, so one user's dismiss discarded another user's unspent entries, permanently, because nothing rebuilds an in-memory plan. It is now per owner. Bumping only the owners of entries the dismiss removed was not enough either: take() has already emptied the plan by then, so a dismiss landing mid-restore saw nothing of that owner's to remove and invalidated nothing. The owners that matter are those with a restore in flight, filtered by what the dismissing user may access, and that is what clear() now bumps. Plan expiry bumps too, so a restore straddling the 24-hour boundary cannot hand entries back and give an expired plan another full day. Tests. discardPartiallyBuiltSession had no test at all: the only implementation any test ran was the mock's one-line stub, which is why every defect above was invisible. test/discard-partially-built-session.ts drives the real WebServer, and the retry assertion fails if the listener refs are left behind — verified by reverting the fix. The dismiss-race test drove the registry by hand, so deleting the route's generation argument left it green; it now goes through the route, and two further tests cover the multi-user cases. The mock context has now gone stale twice, because route tests pass it as `ctx as never` and tsconfig.json includes only src, so nothing ever compares it to the ports. A type-level guard is therefore inert — I wrote one and confirmed it never fires. test/mocks/mock-route-context-completeness.ts compares the mock's keys against WebServer.createRouteContext() at runtime instead, and names what is missing. Also: the API reference now says workspace-forbidden is judged against the owner's grant, the banner's module header no longer claims Restore always dismisses it, and the detail span gets the same min-width: 0 the phone rule already needed. 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
fa52753e8b
commit
71ed7b127c
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* `WebServer.discardPartiallyBuiltSession()` against the real server object.
|
||||
*
|
||||
* The reboot-restore route calls this when a rebuild registers a session and
|
||||
* then fails to start its pane. It has to be the exact inverse of
|
||||
* `registerSessionWithLayout()` plus `setupSessionListeners()`, and it must NOT
|
||||
* be the user-initiated delete: banking the session's token totals, demoting a
|
||||
* pinned record or deleting the workspace's files would all be wrong for a
|
||||
* session that never ran.
|
||||
*
|
||||
* These tests drive the real method rather than the route, because the route
|
||||
* tests run against a mock context whose `discardPartiallyBuiltSession` is a
|
||||
* one-line stub — an earlier version of this function left four registrations
|
||||
* behind and every route test still passed.
|
||||
*
|
||||
* The retry assertion is the important one. `setupSessionListeners()` returns
|
||||
* early when `sessionListenerRefs` still holds the session id, so a discard that
|
||||
* leaves that entry makes the next attempt wire nothing at all, and the user
|
||||
* gets a tab that never shows output.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
|
||||
/** Reach the private collections the discard is responsible for emptying. */
|
||||
interface ServerInternals {
|
||||
sessions: Map<string, Session>;
|
||||
sessionListenerRefs: Map<string, unknown>;
|
||||
runSummaryTrackers: Map<string, unknown>;
|
||||
registerSessionWithLayout(session: Session): Promise<void>;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const WORKSPACE = join(homedir(), '.codeman-test-discard');
|
||||
const SESSION_ID = 'a1b2c3d4e5f60718';
|
||||
|
||||
let server: WebServer;
|
||||
let internals: ServerInternals;
|
||||
let mux: TmuxManager;
|
||||
|
||||
function buildSession(): Session {
|
||||
return new Session({
|
||||
id: SESSION_ID,
|
||||
workingDir: WORKSPACE,
|
||||
mode: 'claude',
|
||||
name: 'rebuilt session',
|
||||
mux,
|
||||
useMux: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(WORKSPACE, { recursive: true });
|
||||
// Test mode: no port is opened and no CLI is launched.
|
||||
server = new WebServer(0, false, true);
|
||||
internals = server as unknown as ServerInternals;
|
||||
mux = new TmuxManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
|
||||
rmSync(WORKSPACE, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('discarding a session whose pane never started', () => {
|
||||
it('takes the session back out of the server', async () => {
|
||||
const session = buildSession();
|
||||
await internals.registerSessionWithLayout(session);
|
||||
await internals.setupSessionListeners(session);
|
||||
expect(internals.sessions.has(SESSION_ID)).toBe(true);
|
||||
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID);
|
||||
expect(internals.sessions.has(SESSION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('releases the listener registration, so a retry can wire itself again', async () => {
|
||||
const first = buildSession();
|
||||
await internals.registerSessionWithLayout(first);
|
||||
await internals.setupSessionListeners(first);
|
||||
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true);
|
||||
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID);
|
||||
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(false);
|
||||
|
||||
// The retry reuses the id by design. `setupSessionListeners()` returns early
|
||||
// while the refs are still there, so a session built now would run blind:
|
||||
// no terminal output, no status updates, no exit broadcast.
|
||||
const retry = buildSession();
|
||||
await internals.registerSessionWithLayout(retry);
|
||||
await internals.setupSessionListeners(retry);
|
||||
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it('stops the run-summary tracker, whose interval would otherwise keep firing', async () => {
|
||||
const session = buildSession();
|
||||
await internals.registerSessionWithLayout(session);
|
||||
await internals.setupSessionListeners(session);
|
||||
expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(true);
|
||||
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID);
|
||||
expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing at all for a session it never registered', async () => {
|
||||
await expect(internals.discardPartiallyBuiltSession('never-existed')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The mock route context must offer everything the real one does.
|
||||
*
|
||||
* Route tests pass their context as `ctx as never`, and `tsconfig.json` includes
|
||||
* only `src/**`, so no type check ever compares the mock against the ports. A
|
||||
* port that gained a method left this mock missing it twice; both times the
|
||||
* route under test threw a TypeError inside its own catch, and the suite
|
||||
* reported a plausible-looking failure for an unrelated reason.
|
||||
*
|
||||
* So the comparison is made at runtime, against `WebServer.createRouteContext()`
|
||||
* rather than against the port types, which is what keeps it from drifting: the
|
||||
* server's own context object is the thing route modules are really given.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WebServer } from '../../src/web/server.js';
|
||||
import { createMockRouteContext } from './mock-route-context.js';
|
||||
|
||||
describe('the mock route context', () => {
|
||||
it('offers every member the real route context does', () => {
|
||||
const server = new WebServer(0, false, true);
|
||||
const real = (server as unknown as { createRouteContext(): Record<string, unknown> }).createRouteContext();
|
||||
const mock = createMockRouteContext() as unknown as Record<string, unknown>;
|
||||
|
||||
const missing = Object.keys(real).filter((key) => !(key in mock));
|
||||
expect(missing, `mock-route-context.ts is missing: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -282,18 +282,62 @@ describe('a failure before any entry is considered', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
it('wins, rather than being undone when the route hands its entries back', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
// The user clicks Dismiss while the restore is between its take and its
|
||||
// return. Driven through the ROUTE, so removing the generation argument from
|
||||
// the route would make this fail.
|
||||
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
rebootRestoreRegistry.clear(() => true);
|
||||
throw new Error('settings unreadable');
|
||||
});
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
// 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);
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('reaches an in-flight restore the dismisser can see, even once its entries are taken', async () => {
|
||||
const mine = offerEntry('mine', 'alice');
|
||||
rebootRestoreRegistry.set([mine]);
|
||||
expect(rebootRestoreRegistry.beginSpending('alice')).toBe(true);
|
||||
const generations = rebootRestoreRegistry.snapshotGenerations([mine]);
|
||||
const taken = rebootRestoreRegistry.take((owner) => owner === 'alice');
|
||||
|
||||
// The plan is empty now, so a dismiss has nothing of Alice's to remove; the
|
||||
// invalidation has to come from her claimed flight.
|
||||
rebootRestoreRegistry.clear((owner) => owner === 'alice');
|
||||
rebootRestoreRegistry.restore(taken, generations);
|
||||
rebootRestoreRegistry.endSpending('alice');
|
||||
|
||||
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not reach another owner, whose unspent entries still come back', async () => {
|
||||
const mine = offerEntry('mine', 'alice');
|
||||
const theirs = offerEntry('theirs', 'bob');
|
||||
rebootRestoreRegistry.set([mine, theirs]);
|
||||
|
||||
// Bob is mid-restore, holding his own entry. The claimed flight is what makes
|
||||
// this the interesting case: a dismiss can no longer see Bob's entries in the
|
||||
// plan, so the invalidation has to come from the in-flight set, filtered by
|
||||
// what the dismissing user may access.
|
||||
expect(rebootRestoreRegistry.beginSpending('bob')).toBe(true);
|
||||
const bobsGenerations = rebootRestoreRegistry.snapshotGenerations([theirs]);
|
||||
const bobsTaken = rebootRestoreRegistry.take((owner) => owner === 'bob');
|
||||
expect(bobsTaken.map((e) => e.sessionId)).toEqual(['theirs']);
|
||||
|
||||
// Alice dismisses her own banner meanwhile.
|
||||
rebootRestoreRegistry.clear((owner) => owner === 'alice');
|
||||
|
||||
// Bob's restore finishes and hands his entry back. Alice's dismiss covered
|
||||
// her entries, not his, so his offer survives.
|
||||
rebootRestoreRegistry.restore(bobsTaken, bobsGenerations);
|
||||
rebootRestoreRegistry.endSpending('bob');
|
||||
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user