mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
A host reboot takes the tmux server down with it, so every pane dies, reconciliation finds nothing to attach to, and the board comes up empty. Picking yesterday's work back up meant finding each conversation in history and resuming it by hand, one at a time. The boot pass now works out what the reboot killed and leaves it on offer. It runs inside restoreMuxSessions(), in the window where reconciliation has reported the dead sessions and cleanupStaleSessions() has not pruned their records yet, which is the only place the records can still be read. The board shows a banner, and nothing is created until the user clicks it. A click rather than an automatic restore is what makes the reboot heuristic acceptable. The heuristic cannot tell a reboot from a crash that took tmux down inside the same window, so it decides whether to ASK, never whether to act: a wrong yes costs a line of text the user dismisses instead of N CLI processes nobody asked for. Four things are re-checked when the click arrives rather than trusted from boot, because hours can pass and the board moves on. The owner's privilege grant re-resolves through the env clamp. The workspace must still be on disk. A conversation the user already resumed by hand from the Resume list is skipped, since two panes running --resume on one conversation would fight over the same transcript. Entries leave the plan synchronously before the first await, and the route is single-flighted, so a double-click or two devices cannot both reach the same entry. A restored session comes back attached, idle and disarmed. Respawn controllers and Ralph loops are deliberately not re-armed: a machine that just came up is the worst moment to turn an autonomous run loose. Its workspace hooks are installed by the restore route itself, because the boot-time sweep sits behind a gate that is false after a reboot and has finished long before the click; without them a session goes silently blind, with no stop or idle events for respawn, no Approvals Inbox item and no red tab on a blocking dialog. Stats collection starts the same way. The pane is new, so the conversation continues and the terminal scrollback does not. The banner says so rather than letting an empty pane read as a broken restore. The plan lives in memory only. A server restart drops it, which costs the convenience this adds and never the conversation: the conversation is the transcript under ~/.claude/projects, which the Welcome screen's Resume list and the Session Manager already read, so a dropped plan returns the user to resuming by hand. clampEnvOverridesForOwner moves to src/session-env-clamp.ts, since the question it answers is about session privilege rather than about HTTP and it now has a caller outside the route layer. Its test hook stays re-exported from session-routes.ts. Claude sessions only for this pass. The other CLIs name their thread in their own config object, which this does not thread through yet. Remote and docker sessions are skipped on purpose, because both need another host or a container to be up and a freshly booted machine cannot promise either. Refs #411 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
5.8 KiB
TypeScript
143 lines
5.8 KiB
TypeScript
/**
|
|
* @fileoverview The pending restore plan: what a host reboot destroyed, waiting on a click.
|
|
*
|
|
* The boot pass builds this plan inside `restoreMuxSessions()`, in the window
|
|
* where reconciliation has reported the dead sessions and `cleanupStaleSessions()`
|
|
* has not pruned their records yet. The board then offers "restore N sessions
|
|
* from before the reboot", and `web/routes/reboot-restore-routes` spends the plan
|
|
* when the user clicks.
|
|
*
|
|
* Invariants:
|
|
* - Entries are in-memory only. A server restart drops the plan, and nothing
|
|
* re-builds it, because the records it was built from are pruned by then.
|
|
* That costs the convenience this feature adds and never the conversation:
|
|
* the conversation IS the transcript under `~/.claude/projects`, which
|
|
* `services/unified-session-service.ts` reads for the Welcome screen's Resume
|
|
* list and the Session Manager, and `resumeHistorySession()` in
|
|
* `web/public/terminal-ui.js` resumes from a row there with no persisted
|
|
* session record involved. A dropped plan therefore returns the user to
|
|
* resuming by hand, one at a time, which is where they are without this
|
|
* feature. What the plan held that a transcript does not is the owner, the
|
|
* name, the env overrides, the effort and the lineage.
|
|
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
|
|
* import and no IO, which keeps it unit-testable and cycle-free.
|
|
* - Spending is take-then-build: `take()` removes entries synchronously, before
|
|
* the route's first `await`, so a double-click or two devices cannot both
|
|
* reach the same entry and put two panes on one conversation.
|
|
* - One restore runs at a time. `beginSpending()` single-flights the route, so
|
|
* two concurrent clicks cannot interleave pane creation.
|
|
*
|
|
* @dependencies reboot-restore (RebootRestoreEntry)
|
|
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
|
*
|
|
* @module web/reboot-restore-registry
|
|
*/
|
|
|
|
import type { RebootRestoreEntry } from '../reboot-restore.js';
|
|
|
|
/**
|
|
* A plan older than this is dropped on read. A machine that rebooted yesterday
|
|
* has moved on, and an offer nobody took by then is noise rather than a rescue.
|
|
*/
|
|
const PLAN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
export class RebootRestoreRegistry {
|
|
/** Keyed by session id, in the order the boot pass found them. */
|
|
private entries = new Map<string, RebootRestoreEntry>();
|
|
/** When the boot pass built the plan, in ms since the epoch. */
|
|
private builtAt = 0;
|
|
/** True while a restore route call is between its take and its last pane. */
|
|
private spending = false;
|
|
|
|
/** Replace the plan with what the boot pass found. An empty list clears it. */
|
|
set(entries: readonly RebootRestoreEntry[]): void {
|
|
this.entries = new Map(entries.map((entry) => [entry.sessionId, entry]));
|
|
this.builtAt = entries.length > 0 ? Date.now() : 0;
|
|
}
|
|
|
|
/**
|
|
* The entries a viewer may see, newest plan first-come order preserved.
|
|
*
|
|
* @param canAccess Ownership predicate, so a user sees their own entries and
|
|
* an admin sees all. Applied here rather than in the route so the count the
|
|
* banner shows and the entries a click spends come from one filter.
|
|
*/
|
|
list(canAccess: (owner: string | undefined) => boolean): RebootRestoreEntry[] {
|
|
this.dropIfExpired();
|
|
return [...this.entries.values()].filter((entry) => canAccess(entry.owner));
|
|
}
|
|
|
|
/**
|
|
* Remove and return the entries a click is about to spend.
|
|
*
|
|
* Synchronous and total: an entry leaves the plan here, before any pane is
|
|
* created, so a second click finds nothing to spend. Entries a caller may not
|
|
* access are left in place, and unknown ids are ignored.
|
|
*
|
|
* @param sessionIds The ids to spend, or undefined for every visible entry.
|
|
*/
|
|
take(canAccess: (owner: string | undefined) => boolean, sessionIds?: readonly string[]): RebootRestoreEntry[] {
|
|
this.dropIfExpired();
|
|
const wanted = sessionIds ? new Set(sessionIds) : undefined;
|
|
const taken: RebootRestoreEntry[] = [];
|
|
for (const entry of [...this.entries.values()]) {
|
|
if (wanted && !wanted.has(entry.sessionId)) continue;
|
|
if (!canAccess(entry.owner)) continue;
|
|
this.entries.delete(entry.sessionId);
|
|
taken.push(entry);
|
|
}
|
|
return taken;
|
|
}
|
|
|
|
/**
|
|
* Put entries back after a rebuild never got as far as creating a pane.
|
|
*
|
|
* Used for the click-time rejections, so a conversation the user resumed by
|
|
* hand meanwhile does not silently vanish from the banner while a workspace
|
|
* that came back stays offered.
|
|
*/
|
|
restore(entries: readonly RebootRestoreEntry[]): void {
|
|
for (const entry of entries) this.entries.set(entry.sessionId, entry);
|
|
if (entries.length > 0 && this.builtAt === 0) this.builtAt = Date.now();
|
|
}
|
|
|
|
/** Drop the entries a viewer can see. Returns how many went. */
|
|
clear(canAccess: (owner: string | undefined) => boolean): number {
|
|
const removable = [...this.entries.values()].filter((entry) => canAccess(entry.owner));
|
|
for (const entry of removable) this.entries.delete(entry.sessionId);
|
|
if (this.entries.size === 0) this.builtAt = 0;
|
|
return removable.length;
|
|
}
|
|
|
|
/**
|
|
* Claim the right to run a restore, or report that one is already running.
|
|
* Callers that get `true` must call `endSpending()` in a `finally`.
|
|
*/
|
|
beginSpending(): boolean {
|
|
if (this.spending) return false;
|
|
this.spending = true;
|
|
return true;
|
|
}
|
|
|
|
endSpending(): void {
|
|
this.spending = false;
|
|
}
|
|
|
|
/** Test hook: forget everything, including the single-flight claim. */
|
|
reset(): void {
|
|
this.entries.clear();
|
|
this.builtAt = 0;
|
|
this.spending = false;
|
|
}
|
|
|
|
private dropIfExpired(): void {
|
|
if (this.builtAt > 0 && Date.now() - this.builtAt > PLAN_TTL_MS) {
|
|
this.entries.clear();
|
|
this.builtAt = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Process-wide singleton, mirroring `approvalInbox`. */
|
|
export const rebootRestoreRegistry = new RebootRestoreRegistry();
|