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:
Michael Grundberg
2026-09-16 15:34:47 +02:00
co-authored by Claude Opus 5
parent fa52753e8b
commit 71ed7b127c
9 changed files with 322 additions and 56 deletions
+45 -13
View File
@@ -2923,8 +2923,9 @@ export class WebServer extends EventEmitter {
* Split in two phases because the two halves have opposite timing needs:
*
* - `before-spawn` shapes the pane itself, so it has to land before the CLI
* process starts. The custom-model selection is an environment injection and
* the nice priority is applied to the spawn.
* process starts, and before `setupSessionListeners()`, which reads the
* image-watcher flag. The custom-model selection is an environment injection
* and the nice priority is applied to the spawn.
* - `after-spawn` is the session's own accumulated history. It must NOT land
* on a session whose pane failed to start: the totals would then belong to a
* session that never ran, and any later cleanup would add them to the
@@ -2952,6 +2953,10 @@ export class WebServer extends EventEmitter {
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
}
// `setupSessionListeners()` READS this flag to decide whether to start the
// watcher, so setting it later would leave the session reporting the feature
// as on with nothing watching.
if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled;
return;
}
@@ -2974,7 +2979,6 @@ export class WebServer extends EventEmitter {
});
}
if (saved.color) session.setColor(saved.color);
if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled;
if (saved.flickerFilterEnabled !== undefined) session.flickerFilterEnabled = saved.flickerFilterEnabled;
}
@@ -2989,33 +2993,61 @@ export class WebServer extends EventEmitter {
* WORKING DIRECTORY, which belongs to the workspace rather than to this session
* and may hold another live session's pasted images.
*
* This undoes only what the failed construction did: the map entry, the tab
* layout slot `registerSessionWithLayout()` took, and any pane the CLI launch
* managed to create before it threw. The persisted record is left exactly as it
* was, so the session stays restorable on the next attempt.
* Everything else `_doCleanupSession()` does, this has to do as well. It is the
* inverse of `registerSessionWithLayout()` plus `setupSessionListeners()`, and
* every registration those two make has to come back out — above all
* `sessionListenerRefs`, whose presence makes `setupSessionListeners()` return
* early. Leaving that entry behind is worse than the leak this function exists
* to prevent: the retry reuses the same session id, wires no listeners at all,
* and the user gets a tab that never shows output.
*
* The persisted record, the lifetime totals, the stored Ralph state and the
* workspace's own files are left exactly as they were, so the session stays
* restorable on the next attempt.
*/
async discardPartiallyBuiltSession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.sessions.delete(sessionId);
// --- the inverse of setupSessionListeners(), in its order ---
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
summaryTracker.stop();
this.runSummaryTrackers.delete(sessionId);
}
// An fs.watch on the workspace (or on @fix_plan.md) that nothing else closes.
session.ralphTracker.stopWatchingFixPlan();
// An FSWatcher on the workspace, likewise.
imageWatcher.unwatchSession(sessionId);
const listeners = this.sessionListenerRefs.get(sessionId);
if (listeners) {
detachSessionListeners(session, listeners);
this.sessionListenerRefs.delete(sessionId);
}
// --- the inverse of the construction itself ---
this.sse.cleanupSessionBatches(sessionId);
this.persistDeb.cancelKey(sessionId);
fileStreamManager.closeSessionStreams(sessionId);
// The per-session custom-model config dir carries the endpoint's API key, and
// `before-spawn` may already have written it. Nothing else would ever remove
// it: the stale sweep only touches state.json. A retry rewrites it.
removeConfigDir(customModelConfigDir(sessionId));
try {
session.removeAllListeners();
await session.stop?.();
await session.stop(true);
} catch (err) {
console.warn(`[Server] stopping a partially built session failed: ${getErrorMessage(err)}`);
}
try {
await this.mux.killSession(sessionId);
} catch {
// The pane may never have been created; nothing to kill is the normal case.
}
try {
await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]);
} catch (err) {
console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
}
// Any `session:updated` the half-built session emitted before it failed left a
// tab on every other open board, and the client's handler is an upsert.
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
private async restoreMuxSessions(): Promise<boolean> {