Skip to content

feat(session): bring sessions back after a host reboot, not just after a server restart #411

Description

The problem

Codeman sessions survive a server restart because the tmux server outlives it. They do not survive a reboot of the host, because tmux dies with the machine. Picking yesterday's work back up means finding each conversation in history and resuming it by hand, one session at a time.

The docs already promise something close enough to be read as this. Q&A discussion #293, "Do my agents keep running when I close the browser or my laptop?", answers that codeman service install "also survives reboots". That is true of the server but not of the sessions. The systemd unit makes the same assumption explicit: "Agents keep running in tmux when the server restarts, so only signal the server itself" (src/service-installer.ts).

Here is what the first boot after a reboot actually does:

  1. reconcileSessions() (src/tmux-manager.ts:2572) lists panes on the Codeman socket and finds none.
  2. Every tracked session lands in dead, leaves this.sessions, and drops out of mux-sessions.json.
  3. finalizeRestoredState() runs cleanupStaleSessions() (src/web/server.ts:2098), which prunes those sessions from state.json.
  4. The board comes up empty, and every conversation has to be resumed by hand.

I prototyped this before filing, so the numbers are measured

The core is about 140 lines of production code. A prototype against master at a017e9a8 adds 536 lines across four files: 135 in src/web/server.ts, 5 in src/web/schemas.ts, a new 155-line module holding the decision logic as pure functions, and 242 lines of tests. Typecheck, lint, format:check and the full npm test suite pass, including 19 new tests.

Three things in the existing code make it that small.

The construction path already exists. restoreMuxSessions() (src/web/server.ts:2791) rebuilds a Session from state.json for each surviving mux session, with the owner re-resolved, the permission mode recomputed through resolveClaudeModeForUsername, and env overrides recovered. Restoring a reboot-killed session is that same block with the mux session omitted, so a pane gets created rather than attached.

Pane creation is not in the route handler. startInteractive() (src/session.ts:1942) calls _setupOrAttachMuxSession() (src/session.ts:1593), which calls mux.createSession() whenever the Session carries no mux session. POST /api/sessions (src/web/routes/session-routes.ts:873) never creates a pane at all, so nothing about it needs extracting or refactoring.

The resume hook is already declared. resumeSessionId is a Session constructor option documented as "Resume a previous Claude conversation (used after server reboot)" (src/session.ts:670). It already flows into the create options at src/session.ts:2074, and CreateSessionSchema already accepts it (src/web/schemas.ts:538).

The timing window is real. state.json is not pruned until finalizeRestoredState() runs on the line after restoreMuxSessions() (src/web/server.ts:2503-2504). Inside restoreMuxSessions, the dead sessions' persisted records are still readable, which is where the restore pass belongs.

What the prototype does

An opt-in setting, default off, turns on a restore pass inside restoreMuxSessions. For each eligible dead session it rebuilds the Session with resumeSessionId set to the persisted conversation id, then starts it. Four rules keep it honest:

  • It restores only when the machine plausibly rebooted. The socket must hold no panes at all while state.json still lists sessions, and os.uptime() must put the host's boot after the newest persisted activity.
  • It never revives a session the user killed. See the invariant section below for how that survives a reboot.
  • A restored session comes back attached and idle. Nothing runs until its owner types.
  • Respawn controllers and Ralph loops stay disarmed. Re-arming them at boot would start unattended agents spending tokens with nobody watching.

The invariants it ran into

COD-108 needs a durable form. The rule that an intentional kill or detach must never be auto-revived is enforced by an in-memory guard (src/tmux-manager.ts:2385), and memory does not survive a reboot. The durable stand-in is demoteOrRemoveSession() (src/state-store.ts:503). An unpinned kill deletes the record, so absence from state.json is itself the guard. A pinned kill demotes the record to status: 'stopped' and keeps it, so the restore pass must refuse that status or it would undo the kill.

Stats collection and the hook sweep sit behind the wrong gate. startStatsCollection is inside a block gated on alive.length > 0 || discovered.length > 0 (src/web/server.ts:3057). After a reboot both counts are zero, so restored sessions would get no stats and no workspace-hook install without a small branch for that case.

Session ids must stay hex. SAFE_MUX_NAME_PATTERN rejects anything else, and a rejected name makes startInteractive() fall back to a direct PTY without an obvious error.

The crash-loop breaker has to be respected. Any session persisted as respawn-blocked must be skipped, or restore hands a crash-looping pane a fresh start.

What a real implementation still needs

The prototype is deliberately incomplete. Five things remain:

  1. The multi-user env clamp. clampEnvOverridesForOwner() lives inside the route module (src/web/routes/session-routes.ts:495) and strips privileged environment keys for a non-granted owner. The restore pass replays persisted envOverrides without it. This is the one piece that genuinely has to move somewhere both callers can reach, and I would rather you chose where.
  2. The other CLIs. Only Claude sessions are restored. Codex, omp and the rest name their thread with an id in their own config object, which the prototype does not thread through.
  3. Persisted per-session settings. Auto-compact, auto-clear, nice priority and the flicker filter are not re-applied on the rebuilt session.
  4. A server-sent event. Rebuilt sessions emit no session_created, so an already-open browser tab will not see them until it refetches.
  5. A checkbox. The setting exists in settings.json and in the Zod schema, so today it has to be edited by hand.

Remote and docker sessions are skipped on purpose. Both need another host or container to be up, which a freshly booted machine cannot promise, and the COD-108 watcher already owns the remote case.

Open questions

I would rather agree these before turning the prototype into a PR:

  1. Should the setting be one global toggle, per case, or per session?
  2. Should restore run automatically, or should the board offer a "restore N sessions from before the reboot" banner and wait for a click?
  3. Should respawn and Ralph ever re-arm on their own, or always wait for the user?
  4. Where should clampEnvOverridesForOwner live so that both the route and the restore pass can call it?

What this cannot do

A restored session is a new pane running a new process, so scrollback from before the reboot does not come back. The conversation continues and the terminal history does not, and the UI should say so rather than imply a full restore.

The reboot heuristic is the part I trust least. It correctly refuses an ordinary server restart and a hand-wiped tmux socket on a long-uptime host. It cannot tell a reboot from a crash that took tmux down inside the same window, and there is no equivalent of the remote case's has-session probe to confirm a session was alive when power was lost. That is the main reason the setting defaults to off.

Nothing here has been tested against a real reboot. Under vitest the tmux layer is an in-memory mock, so what the tests prove is that the construction path creates a session and threads the resume id.

Environment

Codeman 1.26.2, master at a017e9a8, installed from a git clone. Linux on WSL2, Claude Code sessions, tmux backend.

The prototype is pushed to my fork on the branch spike/reboot-restore, at https://github.com/irisitymichaelgrundberg/Codeman/tree/spike/reboot-restore. It is one commit against master, and I am happy to open it as a draft PR if you would rather review code than prose.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions