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.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add nikzlabs/shipit --skill session-lifecycle --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Session Lifecycle?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/nikzlabs-session-lifecycle)More formats (shields.io, HTML) on the badges page.
---
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) to JSON |
| `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()
| | threadManager.init()
|<- {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: a previously-claimed warm session for this repo that was never graduated (user navigated away without sending a message). If found, it returns that session — reusing the existing container instead of creating a new one. Otherwise, it claims from the warm pool.
```
Client Server
| |
| navigate(/{owner}/{repo}/new)|
+- POST /api/repos/:url/claim-session ->
| | 1. reusable ungraduated warm session?
| | 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
-> enforceIdleContainerLimit() -- may clean up excess idle containers
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
```
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!