mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
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>
114 lines
4.5 KiB
TypeScript
114 lines
4.5 KiB
TypeScript
/**
|
|
* `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();
|
|
});
|
|
});
|