Skip to content

state

Provenance

  • Source: .spec/spexcode/spec-cli/sessions/lifecycle/state/spec.md
  • Source SHA-256: 5b5ac8c7dd8367b2b6f0afe84b150f44e83c6a962eb530f550ce067b655cf02b

state

raw source

External hooks only know something changed, never the exact transition, and the TUI is too special- cased to infer reliably. So the agent writes its own state; hooks merely gate at boundaries to force the write. The agent only ever proposesmerge and close are human-only, every proposal is reversible, and nothing auto-disappears.

expanded spec

The session state is the source of truth (never an in-memory map). It lives NOT in the worktree but in a per-user GLOBAL store, keyed by the governed SpexCode session id. For Claude this is also the harness session_id; for Codex, whose thread id is minted internally and cannot be pinned, the governed record keeps SpexCode's id as session_id and stores the real Codex thread id separately as harness_session_id once the backend's codex-launch has completed thread/start for that worktree. The layout mirrors Claude's own ~/.claude/projects/<enc>/: <SPEXCODE_HOME or ~/.spexcode>/projects/<enc>/sessions/<session_id>/, where <enc> encodes the project root (path separators → -). The project root is the MAIN checkout (dirname of the shared git common dir), which resolves identically from main or any linked worktree — so the board (running at main) and a hook (running in a worktree) compute the same dir; resolving it from git rev-parse --show-toplevel would not (in a worktree that is the worktree). The record itself is session.json, written one-field-per-line with every key always present — which is what lets the pure-shell hot-path hook (mark-active) READ it with exact-line greps and no jq: "already active, with nothing stale to clear?" is the common every-tool answer and costs no spawn. Every WRITE, including that hook's own, goes through the single structured writer ([[sessions-core]]); no hook ever edits the file's text, because a note is arbitrary prose and a substituting writer eventually meets a quote and destroys the record. Keying by session_id, not worktree path, is deliberate: it keeps the worktree completely clean (zero SpexCode files — the launcher products live in the store too, see [[runtime]]) AND gives EACH agent its own record, so a user may run several claude/codex in one folder without their states clobbering (a path key could not). The board ENUMERATES this store (projects/<this-project>/sessions/*), filtered to governed:true and ordered by the record's stored createdAt — it no longer scans git worktree list; each row's worktree_path (in the record) is what opens its terminal / diff / live-view. Each record carries a governed flag: the dashboard launcher ([[sessions-core]]) sets it true; a user-self-launched agent has no governed record (a non-board session). The governed flag is the explicit boundary that the old "is there a .session/ dir" presence implied — see the Hooks split below. The statuses: active (working / undeclared this turn), awaiting (a proposal — review or close-pending; historical nothing records remain readable as done), parked (waiting on a managed watch delivery or background task; self-resumes — nothing for a human to do), error (a turn died), asking (stopped and needs the human — a question, or the stop-gate's auto-default for an undeclared/uncommitted stop), queued (held below the cap — [[launch]]), and idle (stopped at the prompt without declaring). merges is a metadata count, not a state.

parked and asking split what a single over-loaded blocked used to conflate: a self-resuming background wait (leave it alone) versus a dead stop that won't move until a human nudges it (act on it). They carry distinct faces, so the board never reads "stuck, needs me" as "fine, self-resuming," or the reverse — and a still-going parked agent is never mistaken for one with something to act on.

Lifecycle and liveness are two orthogonal axes; neither overrides the other. A session carries two independent facts, computed independently (a third, the human's archived filing decision, is orthogonal to BOTH and owned by [[archive]] — it never reads as a status and never rewrites one):

  • lifecyclewhat the work needs, authored by the agent (active/idle/awaiting/parked/ error/asking/queued), never inferred — the status value above.
  • livenesswhether the adapter considers the durable session addressable, derived by the runtime for every session regardless of lifecycle: offline (no tmux window for the id, or the harness adapter's online signal never became session-addressable — genuinely dead), transient starting (window up, adapter signal still booting — see [[launch]]), unknown (the liveness PROBE ITSELF failed — see below), else online. Most interactive adapters derive that answer from process/transport probes. Headless adapters deliberately derive it from the intact, non-stopped session record instead: turn children are ephemeral, so no resident process is an idle state rather than death; controller faults fail loudly at delivery. A human stop is authoritative rather than a probe: it stamps the retained record's stopped liveness metadata after tearing down the runtime, so even a failed tmux probe cannot turn that known stop into unknown. For the process-probed adapters, detection runs in two tiers, never the pane's foreground command. The hot 100ms tier is a zero-spawn death detector: launch registers the agent's real pid (agent.pid, stamped pre-exec so it IS the agent's own pid), and one kill(pid,0) syscall reads it — an ESRCH death is latched per (pid, mtime) (the pid-reuse guard; only a relaunch's fresh write resets it), so a thrashed loop can't hang it. The warm 1s tier is one bounded tmux snapshot plus the rendezvous probe: Claude requires a live LISTENER on its rendezvous socket — a connect() the running agent accepts, not the socket FILE merely existing (a crashed claude leaves its socket path on disk; a file check read a DEAD pane online indefinitely — it must read offline within seconds). Codex reads the hot tier's agent.pid; its old whole-box ps descendant walk is demoted to a self-extinguishing legacy fallback for a pre-registration session with no agent.pid. For every interactive adapter, the session-owned pane/leaf remains a necessary online witness: stale record fields or a thread still addressable through a project-shared control plane cannot make a row with no target pane and no target leaf read online/working; it converges to offline.

Board honesty under load — the probe can fail, and a failed probe is not a death. The tmux snapshot is one bounded call; under heavy load it can time out — a timed-out probe means we cannot tell who is alive, categorically different from "tmux is up and this session is gone," so those rows yield unknown, rendered probe-failed, never offline/closed, and the row never vanishes (enumerated from the durable store). Its three pane fields are separated by a printable boundary that the format asks for and the parser splits on as ONE constant, because tmux itself rewrites control characters in a format string on the way out (a tab and a raw 0x1f both become _ on 3.6a; a raw 0x1f becomes the printable escape \037 on 3.4). A separator that survives one version and not the next is worse than a wrong reading on one row: no session is seen to own a window at all, so every live agent's row degrades to unknown at once. The listener probe is tri-state for the same reason: only a completed connect (live) or an instant refusal/absence (ECONNREFUSED off a stale socket file / ENOENT — proven dead) settle the question; a connect timeout (a thrashed loop fires the timer before the pending connect) or EAGAIN (a full backlog — a listener alive-but-busy) are unproven, read unknown, never offline. The board bounds concurrent listener connects so its own probe burst does not fill healthy listeners' backlogs. This is the honesty rule the mass-restore incident violated (a slow box read as a graveyard, live workers relaunched to death) and the false-offline wait verdict (issue #40) too. Fail loud (unknown), never guess (offline). The same rule reaches one layer further down, because a settled dead answers only about the TRANSPORT: a socket path can be unlinked out from under its own live listener — by a stray rm, or by any teardown that believes it owns the path — after which every connect ENOENTs (proven dead) while the agent keeps working, merely unreachable. So the transport is not the only witness: the launch-registered agent.pid is a second, independent one, and while it still answers, death stays UNPROVEN → unknown. Only a corpse both witnesses agree on is offline, because offline is the reading a supervisor ACTS on — it is what disarms the relaunch guard, and relaunching a working agent kills it.

The surfaces compose the two without precedence: the badge shows lifecycle, while liveness offline exposes resume through both the relaunch panel and the console toolbar's compact relaunch tool whatever the lifecycle — a dead asking agent still needs you, now resumable — the sole exception being queued, which has not launched yet and self-starts as a slot frees. unknown (probe-failed) exposes neither relaunch entry: we have not proven the agent dead, so we must not invite a restore that could kill a live worker.

The review reading (an awaiting proposal, as the board and spex watch surface it) is the orthogonality in one example: review means the agent has stopped active work — mark-active flips it back to active on any agent tool action — and says nothing about liveness. Done-but-alive reads review+online (process alive, rendezvous socket open, the terminal mounts); done-exited reads review+offline (the relaunch panel). A stable review+online session genuinely exists — a doer proposes, then idles awaiting the merge — not just a test artifact.

Offline is reachable on purpose, not only by a crash. stop is the human-only soft stop — the inverse of resume: it kills only the adapter-registered session-owned leaf plus that session's tmux + rendezvous socket, but leaves every project-shared control plane untouched ([[host-resource-budget]]) and leaves the worktree, branch, transcript, and global record, then writes only that record's stopped liveness marker, so the session reads offline and the relaunch panel offers to --resume the same conversation. The lifecycle fields the agent last authored survive the stop untouched — whereas a proven-owner close removes the worktree AND sweeps the global record dir. resume is the inverse of stop, and it is symmetric: it brings the agent back up (relaunching it --resumed into the same conversation only when it is genuinely offline; both frontend relaunch entries invoke this same action) and clears stopped as it restores the runtime and settles the resting lifecycle under the SAME active-only guard idle uses — a resumed agent that was active (working) is now just sitting at its prompt → idle, while every deliberate declaration survives the resume untouched (awaiting and its proposal, asking, parked, error). resume deliberately does NOT touch the proposal: resuming a session that is proposing a merge must not silently withdraw it — proposals are reversible only by MESSAGING the session (mark-active clears them), never as a hidden side-effect of a relaunch. So resume never itself makes the agent work; the merge dispatch, which resumes ONLY to relaunch a dead agent so the dispatch hits a live one, then sends the merge prompt — and THAT prompt is what flips the lifecycle to active (and clears the now-obsolete proposal) through mark-active.

Launch handoff is not proof that resume restored liveness. The resolved harness adapter supplies a bounded readiness fence. Resume persists an internal launch-readiness-pending fence while every public record, list, API, graph, resources, settings, and timeline projection remains the exact pre-resume stopped/offline state. After the adapter revalidates the same runtime, target reference, and unique governed owner across that durable boundary, one final record write clears the pending fence and publishes stopped:false plus the real resting lifecycle transition exactly once. False, throw, timeout, or stale-pending recovery retains/restores the exact original lifecycle, proposal, and note with no transition event, leaving an offline session that can be retried. Thus no stale readiness sample or transient active to idle candidate can become public online state. The frozen lifecycle and proposal must be members of their closed semantic enums before any public projection accepts the fence; an unknown string is corrupt/unknown on every surface. A valid pending row always carries offline liveness and an offline compact display without running live reconciliation, including defensive readings of an active/idle, stopped:false original while candidate runtime is already live.

The resume guard — restore-on-alive must be impossible. Relaunch is a kill-then-respawn, so it destroys a running agent's in-flight work the instant the agent is actually alive. That was the incident's kill-shot: the board lied (a live worker read offline), the human hit relaunch, and live claude processes died mid-task. So resume re-derives the agent's liveness freshly (the same listener-verified probe above, not a possibly- stale board reading) and REFUSES LOUD rather than relaunch a live agent — the API answers 409 and the dashboard's relaunch panel shows the refusal, never a silent no-op. You steer a live agent by messaging it, not by restoring it. Death must be proven: an unknown probe (the tmux timeout that starts under load) also refuses, since a live worker can't be ruled out. A force escape exists for a genuinely wedged-but- alive process (the one case where a deliberate kill is the repair). Only a confirmed offline agent (or force) is relaunched. The merge dispatch is the sole non-guarded caller: it merely needs a live agent to send the merge prompt to, so an already-online one is a satisfied no-op (never a refusal) and only a confirmed-offline one is relaunched — the guard protects the human relaunch, not the internal ensure-live. Contrast close, the other human-only terminal verb: with a readable owner it removes the worktree, discarding the work. An unreadable record proves no adapter, leaf, worktree, or branch owner, so close may copy the corrupt bytes to the control-plane quarantine but must then fail loudly before any signal or deletion and name the preserved residue. There is no fail-open cleanup path: a later exact recovery still enters through the same stop/close owner primitive, never a reclaim verb or second terminator. Both are human-only and direct (not agent proposals); stop is fully reversible (relaunch), close is not. Their public CLI commands exit nonzero whenever the backend commits no target transition; printing “no such session” while returning success is a false state-machine result. The third human-only verb, archive ([[archive]]), is the reversible cold-storage attention action: it reuses the exact existing stop guard, stops only the adapter-registered session-owned leaf plus that session's tmux and rendezvous transport, then writes archived:true. Success therefore implies archived => offline; if ownership cannot be proved, the command fails loudly and leaves the record unarchived and visible. It never touches the project-shared Codex app-server, whose control plane is reference-counted across sibling sessions/turns, and it preserves worktree, branch, transcript, and conversation identity. resume is the only way back: it clears archived first, then recreates the runtime through the normal starting -> online path; an archived record is never relaunched in place and never contributes to active slots or resource references. A stopped session occupies no working-set slot ([[launch]]) — offline never does — so the freed capacity drains a queued one. The one inferred refinement stays orthogonal and narrow: an online active session reads idle if the idle-prompt hook fired since the last tool use, else working, active-only guarded so it never clobbers a declaration. The compact DisplayStatus (the spex ls glyph, the row dot) is a derived label composing both axes for one-glyph surfaces — a convenience, never a third source of truth.

Hooks (delivered via the [[hook-dispatch]] dispatcher, gated by governed)

Every hook reads the effective session id through the harness resolver. For Claude the PAYLOAD's session_id is the acting identity (env SPEXCODE_SESSION_ID is only the fallback for payload-less events): a nested subagent inherits the parent's env, so env-first let every child tool call clobber the PARENT's declared state (measured: a park erased within seconds, the session reading working forever). Codex cannot: hooks run inside the shared per-project app-server, whose env can carry another session's SPEXCODE_SESSION_ID, so Codex hook state starts from the payload session_id (the acting thread id) and aliases that through harness_session_id to the governed SpexCode record. That alias is created by the backend launch path: spex internal codex-launch asks the shared app-server to thread/start { cwd }, stores the returned thread id on the governed record, then fires the first prompt. The global record path is project key from the git common dir → <store>/projects/<enc>/sessions/<id>/session.json. The hooks split on the governed flag. The board-lifecycle hooks below (mark-active, the Stop gate, StopFailure→error, idle) act ONLY when that record reads governed: true; on a non-governed (user-self-launched) record — or none at all — they no-op (the Stop gate exits 0 SILENTLY), because a self-launched agent has no board to feed, so the Stop gate must NOT misfire its declare-demand. EVERY one of them — mark-active included — shells to spex session … --session <id> to write, so the TS layer owns the JSON; they pass the id explicitly because there is no worktree .session to fall back on. mark-active stays cheap by reading, not by writing differently: its exact-line greps answer the no-op case without a spawn, and only a real state change costs one. A writer that refuses (an unreadable record, a retired session — [[sessions-core]]) says so instead of silently repairing it. The spec-discipline hooks ([[inject-spec-first]], [[inject-spec-of-file]]) are NOT gated on governed — they serve any agent, keeping their once-per-session sentinel/ledger as sibling files in the same global session dir (created on demand even for a session with no session.json). So board state is a managed- session concern; spec-awareness is universal.

For the two known pre-structured mark-active source blobs still tracked by existing projects, the dispatcher executes the package-owned structured implementation without rewriting the tracked hook; that bounded compatibility is specified by [[dispatcher-runtime]]. Thus a package upgrade protects frozen worktrees immediately, while a project's eventual source migration remains an explicit reviewed change rather than a hidden materialize effect.

For the known pre-structured mark-active source bytes still tracked by existing projects, the dispatcher executes the package-owned structured implementation without rewriting the tracked hook; that bounded compatibility is specified by [[dispatcher-runtime]]. Thus a package upgrade protects frozen worktrees immediately, while a project's eventual source migration remains an explicit reviewed change rather than a hidden materialize effect.

  • UserPromptSubmit + PreToolUse → one mark-active hook: it writes asking on an AskUserQuestion (the question → the note), else active — the freshness signal that also flips a stale idle/asking back the moment work resumes.
  • Stop → the gate, two jobs each with a hard loop-break. A commit gate judges the proposal the agent actually made. Uncommitted changes reject EITHER kind — both declarations claim the work is committed, and a dirty tree makes that false; and since SpexCode now writes NO files into the worktree (the runtime lives in the global store, [[runtime]]), every dirty path is genuine work, with no runtime-file filtering to do. Being 0 ahead of the base branch rejects only merge, the one claim it contradicts: merge asserts there is committed work to land, while nothing is retained only to render historical records. The public done --propose nothing command is an intended trap: it writes no state and tells the agent to choose merge, close, ask, or park. That removes a default "keep it just in case" completion face without rewriting old timeline truth; propose-close is exempt entirely. A declare gate blocks a stop while still active, auto-defaulting on the forced continuation to asking (the stopped agent needs a human prompt to resume — it never fakes a self-resuming parked or a completed nothing). The block reason gives each option its application condition, not a menu: a state is a claim others act on, so the agent picks the TRUE one. parked is policed hardest — claim it only when a real managed watch delivery or background task will wake you; with neither running to resume you the stop is asking, never a false parked the board misreads as self-resuming while you actually need the human. The teaching names the complete declared face: done --propose merge is review — the sole proposal that offers a human-clickable merge; done --propose close is close-pending only after the task is genuinely settled, its worktree is no longer needed, and no human decision, follow-up, or posted-artifact inspection remains. ask is asking for a human reply or direction — including an answered exploratory question or a handoff awaiting the human's next direction — and park is parked, waiting only for a managed watch delivery or real background wake-up. done --propose nothing is instead an intended correction prompt that records no terminal state. Its branches name the operative facts: merge is committed spec and code not yet landed in main; close is complete work that landed (or had nothing to land), is verified, and leaves neither a needed worktree nor a human decision, follow-up, or posted artifact awaiting inspection; ask is human input, direction, an answered exploratory answer awaiting follow-up, or that inspection; park is a managed delivery or background job that resumes a named next action — watching terminal children is not a wake-up. The dashboard keeps the merge tool's fixed slot for every selected session, but enables and paints it green only for the persisted awaiting/merge/review proposal while liveness is online; every other proposal, lifecycle, or liveness reading is muted, disabled, and names its reason. This is an affordance over the existing record projection, never a new merge, commit-gate, or lifecycle transition. After verified landing, close-pending is for a finished task whose worktree is no longer needed and which has no outstanding human decision or follow-up; otherwise the agent declares the state that is true. The first-stop teaching carries the same boundary, so unfinished work and human-directed handoffs stay review/asking rather than inviting discard.
  • StopFailureerror; headless turn non-zero exit → error, but only as an active compare-and-set so a declaration written before child teardown wins; Notification(idle_prompt)idle. All Stop-gate git goes through the shared git() helper, so a stray exported git dir can't misdirect repo discovery.

asking resumes only on a human prompt (unlike self-resuming parked); idle is its inferred opposite, a stop with no declaration. Surfacing an asking is the manager's job (see [[session-follow]]). The lifecycle writers live in sessions.ts; state's only stake in the shared cli.ts hub is the spex session declaration commands and the spex ls table — a sibling verb's churn there, like the eval usage line rewritten in the measure-and-score reframe, moves the file but is not state's drift. A declaration echoes a one-line confirmation — recorded for the dashboard, after which the next tool call (via mark-active) flips the record back to active, so an agent never reads that re-flip as a lost proposal. Every note-carrying declaration (done/ask/park/state, all of which accept --note — done included, its note reaches the record like the others') stores the note in full; the CLI table may cap it, but that cut must be transparent to the author. The table's explicit NOTE column keeps the first NOTE_BOARD_LIMIT display columns; dashboard titles do not display notes at all ([[session-label]]). The rule is taught once per session: the first time a declared note is cut by the table, the confirmation states the note's length, what the table leaves, and where the full text is readable (spex review <id> / spex ls --json), then drops a sentinel beside the record so later cut notes in the same session repeat none of it (the rule was taught; a verbatim repeat on every park/ask is noise — a field-reported irritation). Trimming stays the author's informed choice — never a silent loss — and like every echo addendum the notice is a nudge riding the confirmation, not a gate.

A record that EXISTS but cannot carry state is a different answer from a missing one, and the writer must not blur them: an unreadable session.json or a retired session (worktree gone) is refused with that reason and its repair — never the wrong-cwd diagnosis below, which would send the author hunting a directory that is fine ([[sessions-core]]). A declaration that genuinely cannot find its record diagnoses itself instead of answering a bare "no session record". The store resolves from the current directory (the cwd's git common dir), so the classic failure is declaring from outside the session's project — and the message must say so: it names the cwd, distinguishes the actual situations (cwd not a git repository at all — which must never surface as a raw git stack trace — cwd in a project with no sessions, a store found here that lacks the id, or no session id resolvable from env at all), and routes the fix for each — cd back into the session's worktree and re-declare, or pass/correct --session <id>. The diagnosis changes only the message; nothing is written either way. A propose-close declaration additionally carries a plain reminder to reclaim the ephemeral things the agent started to test this change — a stray process, a dev/preview server, a bound port, a throwaway session it spawned — before the worktree is discarded and they orphan (the leak the shared tmux socket made visible: a torn-down worktree's own backend outliving it). It is advisory, a nudge and never a gate (the agent checks, then carries on; the next tool call re-flips it to active), and project-agnostic: the criterion is whether a resource should outlive the task, never who started it — a deliberately long-running service or a production build is started-by-you yet left alone, and anything you are unsure about is left running. The sweep's scope is stated, not implied, and it excludes THIS session by name. close is human-only (above), so the declaration has proposed a close, not performed one — while spex session close accepts . and a bare own id like any other selector, so a session reading "shut down a session you started" at the exact moment it is contemplating its own close can read it as permission to close itself, which deletes the worktree it is running in mid-turn. Every surface that teaches close therefore says which side of the manager/worker split it is on: session close <SEL> retires ANOTHER session and its selector is never . nor the caller's own id, while done --propose close only proposes and names the human as the one who performs it. Beside that resource reminder the same declaration appends a data-driven issue closeout line, owned by [[local-issues]] (the store owns the query and the wording): the still-open local threads this session opened or replied to, listed by id, with the ask to resolve each or say why it outlives the session — silent when the session owes nothing or the issues feature is off, and equally a nudge, never a gate (a failure in the store check is reported loud but the declaration still lands).