Skip to content
Back to skills

Session Lifecycle

ASecurity

ShipIt session lifecycle: session types (standalone, repo-backed, warm), creation paths, warm session pool mechanics, session activation flow, session switching. Load when working on session creation, warm pool, or activation logic.

  • 10 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 3, 2026
developmentrustgosqlreactexpressdockergitapi

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned September 29, 2026

npx -y skills add nikzlabs/shipit --skill session-lifecycle --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Session Lifecycle?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Session Lifecycle
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/nikzlabs-session-lifecycle/badge)](https://www.skillsdirectory.com/skills/nikzlabs-session-lifecycle)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: session-lifecycle
description: "ShipIt session lifecycle: session types (standalone, repo-backed, warm), creation paths, warm session pool mechanics, session activation flow, session switching. Load when working on session creation, warm pool, or activation logic."
user-invocable: true
---

# Session Lifecycle

This skill covers session creation, warm-up, activation, switching, and graduation. For container/runner infrastructure, see the `session-containers` skill.

## Key Components

| Component | Location | Role |
|-----------|----------|------|
| `SessionManager` | `orchestrator/sessions.ts` | Persists session metadata (title, workspace dir, remote URL, warm flag) in the SQLite `sessions` table |
| `SessionRunnerRegistry` | `orchestrator/session-runner.ts` | App-level map of session ID -> runner. Fires `onRunnerIdle` callback for container cleanup |
| `SessionRunnerInterface` | `orchestrator/session-runner.ts` | Abstract contract: agent state, message queue, viewer count, preview |
| `RepoStore` | `orchestrator/repo-store.ts` | Tracks imported repos, clone status, warm session IDs |

## Session Types

1. **Standalone session** — no repo, fresh git repo initialized in the session directory. Created via `POST /api/sessions`.
2. **Repo-backed session** — its own independent local clone cut from the per-remote bare cache (`git clone --local`, hardlinked objects), checked out on a unique branch off the default branch. Created via warm pool or `claim-session`.
3. **Warm session** — a repo-backed session pre-created in the background: clone-from-cache + metadata plus, where there is idle headroom, a pre-booted **standby container**. Invisible in the sidebar until the user sends their first message ("graduated"). Where no standby exists, the container is created on-demand when the WebSocket connects. A warm session never survives an orchestrator restart — see "Restart" below.

## Session Creation

### Path A: Standalone Session (no repo)

```
Client                          Server
  |                               |
  +- POST /api/sessions ---------> createSessionDir(title)
  |  {title}                      |   mkdir sessions/{uuid}
  |                               |   git init
  |                               |   configure credentials
  |                               |   sessionManager.track()
  |<- {sessionId, sessionDir} -----
  |                               |
  |  store pendingWsMessage       |
  |  navigate(/session/{id})      |
  |                               |
  |  useSessionWebSocket opens    |
  +- WS /ws/sessions/{id} -------> activateSession(id)
  |                               |   runnerRegistry.getOrCreate()
  |                               |     -> factory creates container
  |                               |   attachToRunner()
  |                               |
  |  useConnectionSync fires      |
  +- GET /api/sessions/{id}/history -> returns messages, commits, etc.
  |                               |
  |  send pendingWsMessage        |
  +- WS send_message ------------> handleSendMessage()
  |                               |   POST /agent/start to worker
  |<- WS streaming events ---------
```

### Path B: Warm Session (repo, pool hit)

The claim endpoint first checks for a **reusable** session: the never-graduated draft the server last gave to the same browser tab (the client sends a per-page-load `tabId`; `claim-session.ts` keeps a tab → draft map). If that draft is still warm and carries no settings, it returns it — reusing the existing container instead of creating a new one. No other draft is reused, so two tabs never share one (docs/285-network-mode-at-session-creation req 13). Otherwise, it claims from the warm pool.

```
Client                          Server
  |                               |
  |  navigate(/{owner}/{repo}/new)|
  +- POST /api/repos/:url/claim-session ->
  |                               |  1. this tab's own draft, still reusable?
  |                               |     YES -> return it (no new claim)
  |                               |  2. repo.warmSessionId exists?
  |                               |     YES -> clear warmSessionId
  |                               |     refreshCloneToLatestMain (git fetch)
  |                               |     re-warm next session (FIRE-AND-FORGET,
  |                               |       with standby -- not awaited; docs/144)
  |<- {sessionId, fetchDurationMs}-  (returns after refresh; re-warm runs async)
  |                               |
  |  store pendingWsMessage       |
  |  navigate(/session/{id})      |
  |                               |
  |  WS /ws/sessions/{id} -------> activateSession(id)
  |                               |   runnerRegistry.getOrCreate()
  |                               |     -> claim standby (instant) or create
  |                               |   attachToRunner()
  |<- WS preview_status ----------   (instant if standby; else once it boots)
  |                               |
  |  send pendingWsMessage        |
  +- WS send_message ------------> graduates warm session
  |                               |   (rename, clear warm flag, broadcast)
```

### Path C: Claim Session (repo, no warm pool)

Same as Path B but `claim-session` creates the session clone synchronously (~1-2s).
If the client disconnected before work starts (`request.raw.destroyed`), the
endpoint short-circuits to avoid creating abandoned sessions.

1. `createSessionDirFull("Warm session")`
2. Clone from bare cache + fetch real remote + `checkout -b` (branch from latest)
3. Configure credentials
4. Fire-and-forget re-warm of the next session (clone + standby + pre-install)
5. Return session ID + `fetchDurationMs` to client (no container created on this path)

The container is created on-demand when the WebSocket connects (`activateSession`
-> `getOrCreate` -> factory creates container), since the slow path had no
standby to claim.

The client passes an `AbortSignal` to the claim fetch. Navigating away (clicking
another session or "New Session" again) aborts the request, which the server
detects via `request.raw.destroyed`.

## Warm Session Pool

The warm pool pre-creates one session per repo so users get instant "New Session" with a running preview.

Two mechanisms prevent cascade during rapid "New Session" clicks:

1. **`warmingInProgress` set** (per repo URL) prevents concurrent `warmSessionForRepo` calls. Without this, each click triggers a replacement warm, and while those are in-flight (before `warmSessionId` is set), subsequent clicks see no warm session and fall to the slow path.

2. **`warmingPromises` map** stores the in-flight warming promise. When the claim endpoint finds no warm session but warming IS in progress, it awaits the promise and re-checks — claiming the freshly created warm session instead of falling to the expensive slow path.

### Warm-Up Sequence

Warm-up creates the session clone + metadata **and** pre-boots a **standby
container** (`containerManager.createStandby`), on which it then pre-runs
`agent.install`. The runner factory `claimStandby`s that container on
activation, so a warm hit reconnects to an already-running, already-installed
worker instead of building one — see the `session-containers` skill.

```
warmSessionForRepo(repoUrl):
  1. Check repo.status === "ready", no existing warm session, no warming in progress
  2. createSessionDir("Warm session")
  3. sessionManager.setWarm(appSessionId, true); setRemoteUrl(...)
  4. Remove workspace subdir (clone needs it absent)
  5. cloneFromCache + fetchAndResolveDefaultBranch + checkout -b (real-remote
     fetch so the branch is cut from genuine latest — see W2)
  6. Configure git credentials; materialize LFS; hand the tree back to the worker uid
  7. repoStore.setWarmSessionId(repoUrl, appSessionId)
  8. createStandby(...) + runPreInstall(...) [fire-and-forget], gated on: the OOM
     breaker not tripped, memory headroom (not at the docs/284 memory budget), and —
     for the pre-install only — the remote being trusted (docs/178)
  9. sseBroadcast("repo_warm_ready", ...)
```

There is deliberately **no caller-side opt-out**: every path that warms a repo
wants the standby and the pre-install, and a "warm but no standby" state is what
made docs/148 silently regress. A runtime with no containers expresses that by
passing `containerManager: null`. Callers are the initial warm of a freshly
added repo, the startup re-warm, the claim re-warm (`claim-session`) and
graduation (`send-message`). The claim re-warm is **fire-and-forget** (not
awaited) so it never sits on the claiming user's critical path — see
`docs/144-session-switch-latency`.

### Restart: Warm Sessions Do Not Survive It

A standby belongs to the process that created it. It holds no work and runs that
process's worker image, and nothing else would ever reap one — the idle enforcer
skips standbys deliberately, and `rediscoverContainers` re-adopts them standby
flag and all. So a standby used to outlive every deploy, and the next claim
handed the user a grandfathered worker carrying a pre-install and an overlay base
built under the image the deploy replaced. (Grandfathering a **real** session's
container across a deploy is the opposite call and it stands — docs/113 exists to
protect work in flight, which a standby has none of.)

Boot therefore retires the whole warm tier, in this order:

1. **`retireWarmSessions`** (`startup-tasks.ts`), called from `bootstrapManagers`
   **before** `setupContainerManager`: deletes every `warm = 1` row — the pool
   session a repo points at, and any claimed-but-never-graduated draft — clears
   each repo's `warmSessionId`, and reclaims the clone + overlay
   (`reclaimRegenerableSessionDirs`, which preserves `uploads/`). The ordering is
   the point: with the rows gone, the container sweeps on the next line treat
   each standby as an orphan and rediscovery cannot re-adopt one.
2. **`reapStandbyContainers(activeSessionIds)`** (`container-discovery.ts`),
   called from `setupContainerManager`: stops and removes every
   `shipit-standby=true` container **whose session is no longer tracked**. It
   runs outside the Docker-available branch so an injected container manager
   gets the same guarantee.

   The live-session set is not a refinement — it is what stops the sweep
   destroying live sessions. The label is set at create time and Docker cannot
   change one afterwards, so `claimStandby` only drops the in-process flag: a
   claimed, graduated session keeps a `shipit-standby=true` container for that
   container's whole life. Label means "was born a standby", never "is one
   now"; the session row is what separates them, cleanly, because step 1 has
   already deleted every warm row. For the same reason `rediscoverContainers`
   does not restore the standby flag from the label.
3. **Re-warm**: `scheduleStartupTasks` (deferred via `setTimeout(0)`) then finds
   every ready repo without a `warmSessionId` and calls `warmSessionForRepo`,
   rebuilding the pool — fresh clone, fresh standby, fresh pre-install — on the
   new image. Its zombie sweep and stale-clone validation loop normally find
   nothing now; they remain for the rows retirement cannot recognise (a zombie
   whose warm flag was already cleared, identifiable only by its title).

Guards: `startup-tasks.test.ts`, `container-discovery.test.ts`,
`integration_tests/standby-container.test.ts`.

### Graduation

When the user sends their first message on a warm session, `handleSendMessage` "graduates" it:
- Clears the `warm` flag
- Renames from "Warm session" to a meaningful title
- Broadcasts `session_list` update (session appears in sidebar)

## Session Activation (Per-Session WebSocket)

The client connects to `ws[s]://host/ws/sessions/{sessionId}?agent=claude`. The server handles this in `buildApp()`:

### Server-Side (on WS connect)

```
1. Validate session exists (close 4004 if not)
2. Initialize per-connection state:
   - activeAppSessionId = sessionId
   - activeSessionDir = session.workspaceDir
   - perConnectionAgentId = query.agent ?? defaultAgentId
3. activateSession(sessionId):
   a. existingRunner = runnerRegistry.get(sessionId)
   b. If exists: attachToRunner(existingRunner)
   c. Else: runner = runnerRegistry.getOrCreate(sessionId, dir, agentId)
            attachToRunner(runner)
4. Send log buffer
5. Re-send preview_status after log buffer (React batching mitigation)
```

### attachToRunner(runner)

```
1. Detach from any previous runner
2. Subscribe runner.on("message", send)
3. runner.attachViewer() -- increments viewer count
4. Replay turn event buffer (all events from current turn)
5. Send queue status if messages are queued
6. Send session_status if agent is running
7. Send preview_status ONLY IF runner.previewStatusKnown === true
   (otherwise, the runner will emit it later when SSE delivers state)
8. If previewStatusKnown === false:
   Register a one-shot "message" listener on the runner. When the first
   preview_status arrives, re-send it in a separate microtask (queueMicrotask)
   so it survives React 18 batching where rapid setLastMessage calls drop
   intermediate messages.
```

### Client-Side (on WS open)

```
useConnectionSync:
  1. GET /api/sessions/{id}/history -> messages, commits, fileTree, threads
  2. GET /api/sessions/{id}/preview-status -> HTTP fallback for preview state
     (only applied if store still has preview=null)
     If response is known: false -> retry once after 3s (by then SSE has connected)
  3. Send pendingWsMessage if present
```

## Session Switching (Client-Side)

### Switching to an Existing Session

```
handleSessionResume(sessionId, navigate):
  1. resumeSessionInternal(sessionId):
     a. Set sessionId in session store
     b. Clear messages, loading, queue
     c. Reset all session-specific stores (files, git, threads, terminal, UI, preview)
        -> preview store reset to null
     d. loadSessionHistory(sessionId) via HTTP (async)
  2. navigate("/session/{sessionId}")
     -> URL change triggers React re-render
     -> useSessionWebSocket computes new WS URL
     -> useWebSocket closes old WS, opens new WS
```

Server-side effects:
```
Old WS close:
  -> socket.on("close") fires
  -> detachFromRunner() -- decrements viewer count on old runner

New WS open:
  -> activateSession(newSessionId)
  -> getOrCreate() gets (or creates) runner
  -> attachToRunner() -- subscribes to events, replays state
```

### Creating a New Session from Home

```
newSession(navigate):
  1. Clear sessionId
  2. Reset all session state
  3. Show templates
  4. navigate("/")
  -> WS disconnects when leaving /session/* URL
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…