agentleFS
nikzlabsnikzlabs/shipit/.agents/skills/session-lifecycle/SKILL.md

session-lifecycle

Skill9 starsChanged 17 days agoApache-2.0View on GitHub

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.

3 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

2 rules. 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • 0

    Common

    Most of the 3 edited versions we read keep these.

  • 0

    Distinctive

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    If the client disconnected before work starts (request.raw.destroyed), the endpoint short-circuits to avoid creating abandoned sessions.

    line 93
  2. Very specific:

    When the user sends their first message on a warm session, handleSendMessage "graduates" it:

    line 202

“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Session Lifecycle

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

Key Components

ComponentLocationRole
SessionManagerorchestrator/sessions.tsPersists session metadata (title, workspace dir, remote URL, warm flag) in the SQLite sessions table
SessionRunnerRegistryorchestrator/session-runner.tsApp-level map of session ID -> runner. Fires onRunnerIdle callback for container cleanup
SessionRunnerInterfaceorchestrator/session-runner.tsAbstract contract: agent state, message queue, viewer count, preview
RepoStoreorchestrator/repo-store.tsTracks 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", { warm: true }) — the row is inserted warm, so no session list shows it during the clone. Spawned children and scheduled runs always take this path (skipWarm), so a row inserted visible showed in the sidebar as "Warm session" for the whole clone.
  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 claimStandbys 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", { warm: true })  (row inserted warm)
  3. sessionManager.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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.