launch¶
Provenance¶
- Source:
.spec/spexcode/spec-cli/sessions/lifecycle/launch/spec.md - Source SHA-256:
60e778514cc764a510decc125ab4f010d4020cf95850bdabe7bab55ad9563245
launch¶
raw source¶
Launching a worker must be whole and bounded: the launch prompt arrives complete (never truncated by
the transport), the spec-discipline contract reaches the agent through materialized auto-discovered files
(so a dispatched agent loads its own CLAUDE.md + memory normally — the same launch a user takes, never a
hidden or moved-away CLAUDE.md), and no launch
ever crashes the box — past the concurrency cap a launch waits its turn instead of running. And
launching has a single owner: the running backend process, never whichever shell happened to type
spex new — because the launch env (and the cap) live in the backend, not in the caller.
expanded spec¶
The bounded session-create transaction mints the governed SpexCode session <uuid> and derives its node from exactly one place: the
raw caller prompt's first [[<id>]] mention. That mention is the truth for the session record, the
node/<slug(id)>-<shortid> branch/worktree name, board attribution, and the spec pointer
when the id names an existing node. CJK ids, leading-dot ids such as .plugins, and ids that do not exist yet
all remain valid binding text; with no mention the session is node-agnostic and its branch is named from the
prompt. There is no separate CLI flag, API field, or function parameter that can grant scope outside the task
text the worker sees.
The launch adds the node/<slug> worktree (off the base branch), then writes
the session's governed:true record session.json (+ best-effort the prompt artifact, and at launch the
launch.sh script) into the GLOBAL per-session store ([[runtime]]) — NOT the worktree, which stays
pristine — materializes the spec-discipline contract into the worktree's own CLAUDE.md/AGENTS.md
([[harness-delivery]]), and queues the worktree for launch on a private
tmux -L socket (spex new "<prompt>" [--launcher <name>]). The selected [[launcher-select]]
profile derives one of the four interactive [[harness-adapter]] entries and owns the actual agent command.
There is one launch lifecycle and no execution-mode router: create, drain, reopen, liveness, and delivery all
call that resolved adapter directly. Claude launches with
--session-id <uuid> — the SAME id the record is keyed by, the tmux window name,
the rendezvous socket, and the commit attribution, so the conversation --resumes after death, the board maps
it to its worktree, and a spec node links to it. Codex launches a visible TUI attached to the project's shared
codex app-server --listen unix://<runtimeRoot>/codex-app-server.sock; its Codex thread id is captured later
into harness_session_id because Codex does not let the launcher pin a new thread id. A launcher cmd may be a
wrapper (e.g. reclaude, configured as a [[launcher-select]] profile) that runs claude as a child rather
than exec'ing it, so the pane's foreground
command is the wrapper/shell — not a liveness signal ([[state]] reads the socket instead). The spawned
command alone carries CLAUDE_BG_BACKEND=daemon and a CLAUDE_BG_RENDEZVOUS_SOCK path derived from the
session id as an env prefix (never global, never a plugin), so [[dispatch]] addresses only our sockets.
Codex's app-server launch is project-idempotent: simultaneous spexcode serve processes in the same project
share the runtime socket and take a per-project launch lock before starting the server, so they do not fan out
one app-server per session or cross into another project's socket.
A queued launch carries a stable public-backend authority lease. Its identity is the normalized
SPEXCODE_API_URL the supervisor injects — the stable loopback proxy URL agents use, stripped of credentials,
query, and fragment — never the supervisor's ephemeral child port or PID (a direct server falls back to its
public PORT). Creation stamps the lease before the queue entry becomes visible; only an exact authority match
may drain it, and launch or close consumes it.
A hot-reloaded/restarted child at the SAME public URL therefore takes over automatically. A DIFFERENT endpoint
may display the row but never claims it; recovery is restart the owner or close and re-dispatch there. Existing
unowned queued records remain adoptable for compatibility, while new leased entries use a raw-state fence that
current code presents as queued but a legacy drainer cannot recognize as launchable.
Materialized delivery, not injection: the spec-discipline contract is NOT pushed on the command line.
Before the agent starts, the worktree is materialized ([[harness-delivery]]), writing the surface: system
bodies (name order — the core node + rules like memory-hygiene alongside it) into the <spexcode>
managed block of the worktree's CLAUDE.md/AGENTS.md, plus the dispatch shims. The agent then launches
plainly and auto-discovers them — the SAME path a user-self-launched agent takes — so editing any
always-on contract is a spec edit, not a code change. There is no --append-system-prompt and no --settings.
CLAUDE.md is no longer hidden (the old rename-to-CLAUDE.spexhidden.md isolation is gone): hiding it
also suppressed the agent's own MEMORY load, so with the contract delivered by discovery the agent loads its
CLAUDE.md + memory normally. This creation-time materialize is bootstrap, not best-effort: it is what
wires the worktree's hooks in the first place, and every lifecycle dispatch rides ON those hooks — so a
failed materialize means no hook ever fires and the worker would come up ungoverned (no contract, no
stop-gate) with
nothing saying so. A materialize failure therefore fails loud: the cause + worktree path are logged and
the failure is stamped on the session record's note (the board/watch surface it). Before publishing that
receipt, creation restores the fresh candidate to its branch baseline, including untracked materialized
artifacts while preserving its seeded local config, so a half-written managed block cannot make the degraded
queued row look like user-authored dirty work and block close. If
that restore cannot be proved, creation rolls the candidate back instead of publishing an unclosable row. The
launch otherwise proceeds — a visibly degraded worker the human can close and re-dispatch beats a refused
launch — and status stays agent-authored ([[state]]): the server stamps the note, never an inferred error
state. Only the launch line
itself (rendezvous env + harness command + the human
prompt + spec pointer) is written to the launch script file in the global store, so a long prompt never
hits the ~2KB tmux send-keys limit. Every path that file and its hooks reference resolves from the CLI
package's own on-disk location, never a hardcoded <repoRoot>/spec-cli, so relocating it can't break launch.
The backend is the single launch owner. spex new / spex session new POST to the running backend,
so the launch always runs where the launch env and cap live. The caller can be another agent running in a
stripped or divergent environment, so an in-process launch there would bring workers up in the caller's context
rather than the backend's. The CLI falls back to in-process only when the target explicitly refuses the
connection, proving that no listener owns it (warning that it then carries the caller's env, no cap). One
bounded settings probe also performs the implicit target's project check. Any HTTP response proves an owner,
regardless of status. A probe timeout, abort, reset, DNS failure, or unknown transport error is indeterminate
and fails loud without creating; it may hide an already accepted request.
That ownership starts at prompt invocation, not after a client has already interpreted it. A raw leading
/<preset> names a live surface: command plugin: the shared prompt resolver expands its body, fills {{targets}} from the
prompt's [[node]] mentions, and appends the
remaining free text. A body without {{targets}} receives a target block only when the invocation actually
names one; a targetless utility stays exactly its small body. That expansion is only the agent's launch payload: session node/title derivation and the
stored originating prompt use the raw caller text, so a plugin body's own [[links]] cannot silently become
session scope. [[dispatch]] uses the same resolver for a running session's text input, without launch's
identity bookkeeping. The dashboard, phone, CLI, direct API caller, and no-backend in-process fallback all
enter this same path; clients may list and insert preset names but never expand plugin bodies themselves. A
slash name that is not a live preset passes through verbatim.
The launch is project-bound; the route is not — so the launch guards its project. A launch builds the
worktree under the backend's OWN mainRoot, but the route to a backend is a bare URL (SPEXCODE_API_URL,
else the local-port default) carrying no project identity. So a stale inherited SPEXCODE_API_URL pointing
at another repo's backend would silently land the session in the WRONG repo — the exact decoupling the
[[remote-client]] read/control verbs exploit on purpose (point anywhere to monitor any machine) becomes a
correctness hole the moment the verb mutates. The fix lives at the client launch seam: before POSTing,
spex new compares the caller's cwd repo root to the backend's served root (GET /api/settings .layout.main)
and refuses, loud, on a provable same-host mismatch — cwd is in <A> but the backend serves <B>, with the
repair (cd <A> && spex serve, or point the env at it). It fires only on a positive mismatch: no local repo,
an unreachable backend, or a served root that isn't a resolvable local path (a genuinely remote backend) all
fall through to allow, so legit cross-machine dispatch and the viewer-points-anywhere model stay intact. This
is the same FAIL-LOUD-never-silent-fallback rule [[remote-client]] states, applied to launch: a mutating verb
must never silently act on the wrong project.
Resilient bring-up — but only where retrying can help. A launcher can fail before the agent becomes
addressable — for example a wrapper or daemon exits within seconds, before the rendezvous socket ever appears.
That fast exit is enough evidence to retry, but not enough to name the cause, so the launch script retries a
bounded number of times on a fast exit and reports only the observed fact, without calling it a daemon race.
It stops the instant the agent has run past the boot window, so a normal long-lived session is never relaunched
on its eventual exit. The boot grace and the ready-wait both span this retry window, so liveness reads
starting and the concurrency slot stays held until the worker is genuinely online — or truly dead.
A failure that is already settled is spent ONCE. Retrying it three times buries the one line that explains it and burns a slot, so a settled failure never enters that loop, and the retry stops being a wall-clock rule. Which failures are settled is decided in exactly two places, never by product code matching a harness's English. The launch transport answers what it can before a window is ever opened — no worktree, no branch, no resolvable launcher command — each a fact about this machine that no attempt can change, refused once with its own structured code, which the record's note, the API, and the CLI all consume as data ([[sessions-core]]). The harness adapter owns what only its harness can recognize: it declares the patterns of its own settled output ([[harness-adapter]]), and an attempt matching one stops immediately with the harness's own reason left visible. What it matches against is the pane, not the agent's streams: a harness picks its own stream (measured — real claude reports a missing conversation on stdout, so watching stderr classified nothing and spent the certain failure three times), and redirecting stdout to read it would take the terminal away from a TUI. The pane already holds both streams exactly as the human sees them, and the launch runs inside it. A missing conversation on a still-valid worktree is a settled failure too — it routes to the explicit repair/force entry, never to an implicit fresh-conversation fallback. Everything genuinely unclassifiable keeps the bounded retry. This adds no fallback and never masks a dead agent.
Concurrency cap (bounded working set)¶
At most N agents run autonomously progressing at once — N configured per project in spexcode.json
(sessions.maxActive, default 8), not hardcoded, read live so an edit applies on the next drain (the
SPEXCODE_MAX_ACTIVE env is a fallback). A slot is compute pressure: a session holds one only while
live AND working or parked (self-resuming). Everything waiting on the human frees its slot — idle,
asking, and the proposals (review/done/close-pending) — like offline/closed, since they burn no compute and
must never block a launch. A launch beyond the cap lands as a durable queued worktree
(fully prepared, claude not started, its prompt parked as the launch artifact in the global store). A drainer starts
queued sessions oldest-first the instant a slot frees — on every slot-freeing server action and on a
periodic tick (catching frees the server never sees: a hook subprocess, a crash). A restart re-drains
survivors. Occupancy is counted from the SAME liveness snapshot the board uses, so when that probe fails
(tmux timing out — the overload condition), occupancy is unknowable and the drainer launches nothing this
pass, deferring to the next tick: under load the safe move is to add no compute, never to over-launch off an
undercount ([[state]] board honesty applied to the cap). reopen relaunches a confirmed-dead session (the
resume guard refuses an alive one — [[state]]) and waits for its rendezvous socket before returning, so
a follow-on [[dispatch]] hits a live socket. closeSession is the only removal — human-only, deleting the
worktree, sweeping the rendezvous socket (in the tmpdir), and removing the session's global record dir.