Skip to content

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 slotidle, 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.