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 proposes — merge 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):
- lifecycle — what the work needs, authored by the agent (
active/idle/awaiting/parked/error/asking/queued), never inferred — thestatusvalue above. - liveness — whether 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), transientstarting(window up, adapter signal still booting — see [[launch]]),unknown(the liveness PROBE ITSELF failed — see below), elseonline. 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 humanstopis authoritative rather than a probe: it stamps the retained record'sstoppedliveness metadata after tearing down the runtime, so even a failed tmux probe cannot turn that known stop intounknown. 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-execso it IS the agent's own pid), and onekill(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 — aconnect()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 paneonlineindefinitely — it must readofflinewithin seconds). Codex reads the hot tier'sagent.pid; its old whole-boxpsdescendant walk is demoted to a self-extinguishing legacy fallback for a pre-registration session with noagent.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 readonline/working; it converges tooffline.
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→ onemark-activehook: it writesaskingon an AskUserQuestion (the question → the note), elseactive— the freshness signal that also flips a staleidle/askingback 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 onlymerge, the one claim it contradicts:mergeasserts there is committed work to land, whilenothingis retained only to render historical records. The publicdone --propose nothingcommand 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 stillactive, auto-defaulting on the forced continuation toasking(the stopped agent needs a human prompt to resume — it never fakes a self-resumingparkedor a completednothing). 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.parkedis 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 isasking, never a falseparkedthe board misreads as self-resuming while you actually need the human. The teaching names the complete declared face:done --propose mergeis review — the sole proposal that offers a human-clickable merge;done --propose closeis 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.askis asking for a human reply or direction — including an answered exploratory question or a handoff awaiting the human's next direction — andparkis parked, waiting only for a managed watch delivery or real background wake-up.done --propose nothingis instead an intended correction prompt that records no terminal state. Its branches name the operative facts:mergeis committed spec and code not yet landed inmain;closeis 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;askis human input, direction, an answered exploratory answer awaiting follow-up, or that inspection;parkis 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 persistedawaiting/merge/reviewproposal while liveness isonline; 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.StopFailure→error; headless turn non-zero exit →error, but only as anactivecompare-and-set so a declaration written before child teardown wins;Notification(idle_prompt)→idle. All Stop-gate git goes through the sharedgit()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).