fix(sessions): let a dismiss reach the entries a restore is holding

Fourth review of the reboot-restore branch, and the third to find a defect
in the previous round's fix. This one is the same shape as its predecessor:
a counter keyed on one thing, compared against a set keyed on another.

The generation counter was indexed by the entry's owner, while the in-flight
set holds the caller doing the restoring. Those are the same person exactly
when a user restores their own sessions, which is every case the tests
covered. The route deliberately supports the other case: an admin may spend
another user's entries. So when an admin restored Bob's sessions and Bob
dismissed the banner, nothing matched, the entries came back, and a plan Bob
had explicitly dismissed was re-armed for another twenty-four hours.

Rather than reconcile the two key spaces, the counter is gone. `take()` now
parks the entries it hands out, remembering which caller is spending them,
and they stay parked until that restore ends. A dismiss filters the parked
entries by `canAccess(entry.owner)` — the same predicate it already applies
to the plan — so it reaches them wherever they are. `releaseFlight()` puts
back only what is still parked. Expiry and a fresh boot plan unpark
everything, for the same reason. There is one key space now, the entry's
owner, and the spender is only ever used to tell two concurrent flights
apart. That removes `generations`, `snapshotGenerations()`, `bump()`,
`bumpAll()` and the argument threaded through the route.

The discard grew the teardown it still lacked. A rebuild can fail after
startInteractive() resolved, and a restored workspace still carries
Codeman's hooks, so the CLI can post a hook event within milliseconds; the
transcript watcher that starts from it, the attachment registry, the wait
registry and the approvals inbox all outlive the listeners and would meet
the retry, which reuses the session id by design. Its steps also run in
reverse order now, so no live listener can reach a tracker that has already
stopped, and the mux kill has its own guard, because stop() kills the pane
in its last block after destroying four trackers.

Tests. The run-summary test named an interval and asserted a map entry, so
dropping stop() left it green; it now spies on stop(). Nothing pinned that
before-spawn must precede setupSessionListeners, which reads the flag that
phase restores, so swapping the two lines was silent; the ordering test now
includes the listener setup. The retry assertion was a tautology and now
asserts a different refs object. Both strengthened tests were verified by
reverting their fix. Two new tests cover the admin-restores-another-owner
cases this round was about. The server in the discard test is built once and
stopped, since its constructor registers handlers on module-level watchers,
and the workspace is removed through safeRmHomeTree.

Refs #411

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-16 16:51:00 +02:00
co-authored by Claude Opus 5
parent 71ed7b127c
commit 39976041e0
6 changed files with 159 additions and 103 deletions
+29 -10
View File
@@ -3010,26 +3010,42 @@ export class WebServer extends EventEmitter {
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);
// --- the inverse of setupSessionListeners(), in reverse order ---
// Listeners first: while they are attached, one of them can still reach a
// tracker this is about to stop.
const listeners = this.sessionListenerRefs.get(sessionId);
if (listeners) {
detachSessionListeners(session, listeners);
this.sessionListenerRefs.delete(sessionId);
}
// An FSWatcher on the workspace that nothing else closes.
imageWatcher.unwatchSession(sessionId);
// An fs.watch on the workspace (or on @fix_plan.md), likewise.
session.ralphTracker.stopWatchingFixPlan();
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
summaryTracker.stop();
this.runSummaryTrackers.delete(sessionId);
}
// --- what anything else may have attached to this id in the meantime ---
// A rebuild can fail AFTER startInteractive() resolved, and a restored
// workspace still carries Codeman's hooks, so the CLI can post a hook event
// within milliseconds. Each of these outlives the listeners and would
// otherwise meet the retry, which reuses the same session id by design.
this.stopTranscriptWatcher(sessionId);
attachmentRegistry.clearSession(sessionId);
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
// --- the inverse of the construction itself ---
this.sse.cleanupSessionBatches(sessionId);
this.persistDeb.cancelKey(sessionId);
fileStreamManager.closeSessionStreams(sessionId);
// `lastRecordedTokens` is deliberately NOT deleted: the `after-spawn` phase
// seeds it as the daily-usage baseline for these restored totals, and the
// retry reuses the id, so dropping it would count them as new usage.
// 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.
@@ -3039,6 +3055,9 @@ export class WebServer extends EventEmitter {
await session.stop(true);
} catch (err) {
console.warn(`[Server] stopping a partially built session failed: ${getErrorMessage(err)}`);
// `stop()` kills the mux session in its last block, after destroying its
// trackers, so a throw on the way there leaves the pane running.
await this.mux.killSession(sessionId).catch(() => {});
}
try {
await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]);