0018 — The Astra lane is queue-driven, and its launcher is a rung
- Status: Accepted
- Date: 2026-09-10
- Deciders: Gera (2026-09-10, on being told the rung was built but parked pending his go-ahead: "Never wait for me for the go. That is you violating your constitution. You must drive a solid, robust, and professional enterprise design. If you think we need to build this, you drive."). Written at decision time per ADR-0004, by the session that was in the room.
- Implements: ADR-0003 (rungs integrate by signals, never by calls)
The failure
An Astra job existed only because some session hand-typed:
nohup codex exec -m gpt-6-astra --approve-for-me -C <dir> "$(cat brief)" \
</dev/null > /tmp/astra/<name>.log 2>&1 &
There was no launchd job, no cron, no loop. So the moment a one-shot run exited, the lane sat at zero until a human or a session happened to remember.
On 2026-09-10 it sat at zero for 11.5 hours. The 15:00Z run had finished normally — clean Stop hooks, artifact delivered, 215k tokens — and simply nothing launched the next brief. Nobody was at fault and nothing was broken; that is the point.
If a fact has to be remembered to stay true, it will be false. A lane whose supervisor is a Claude session's memory is not supervised. This is the same lesson as ADR-0002 (a decision that lived only in a conversation), ADR-0004 (a decision that lived only in memory) and the catalog's own founding story (four detectors nearly retired because nothing on disk said they existed). This is that lesson applied to running work rather than to written knowledge.
The decision, in three parts
1. The Astra lane has a queue, and it is on disk. ~/.claude/jobs/astra/
with queue/ running/ done/ failed/ receipts/. Briefs are launched in
filename order, so authors sequence them (01-…, 02-…) and fixing a typo
does not send a brief to the back.
2. Sessions ENQUEUE. Sessions do not LAUNCH.
astra-relaunch --enqueue /tmp/astra/brief-deck.md --workdir ~/wt-design --name 01-deck.md
This is the part that changes how systems relate, and therefore the part that required this ADR. Before it, "the queue" was a session's intention, held in a context window that ends. After it, the queue is a directory that outlives every session that writes to it.
3. The launcher is a rung, in puller's shape. astra-relaunch, a launchd
StartInterval job (300s): read disk, decide, launch at most one run within a
hard cap, write one line, exit. It arms no loop, holds no GATE, never types
into a pane, never messages a session, and must never appear in the nudge
roster — operator/SKILL.md §0b orders a second looper killed on sight and
whoever found one here would be right. Per ADR-0003 it reaches nothing
sideways or down: it consumes files and emits a log line, and the Systems tab
consumes that.
Why a rung and not an agent
A live session watching for "the lane went to zero" would burn a full-price
context to produce "still running" — which is the answer for roughly two
hours out of every two hours and five minutes. It would also be a second
looper, and it would itself be a thing that has to stay alive to stay true,
which is the failure one level up. The tick calls no model, reads one ps, one
directory and one ledger, and is done in well under a second.
What this rung may NOT do
It may not invent work. An empty queue is a correct, healthy
IDLEat exit 0. A relauncher that generated a brief when it found nothing queued would be an agent with a budget and no acceptance criterion.It may not resolve, merge, approve, or write to production. It starts a process and records that it did. One writer per artifact: the only files it writes are its own, under
~/.claude/jobs/astra/.It may not exceed its caps, which are stated in the plist — the reviewed file — and not only in the tool's defaults:
env default bounds ASTRA_RELAUNCH_MAX_PER_TICK1the lane is serial ASTRA_RELAUNCH_MAX_PER_DAY4rolling 24h; a run is ~2h / 215k tokens ASTRA_RELAUNCH_RUN_CAP_S10800one run's wall clock — a wedged run bills ASTRA_RELAUNCH_BREAKER2consecutive short failures before it stops A runaway relauncher that burns an account overnight is strictly worse than a lane at zero. The lane at zero is what we are fixing; it is also the failure mode we must not trade up from.
Liveness is derived from the process table, never from what the job wrote
The observable is ps -axo pid,lstart,command — the kernel's answer about the
present. run.json supplies a pid and a start time and is never believed on
its own: the lane counts as occupied only when the process table agrees the
pid exists, its argv carries that run's --wrap <runid> sentinel, and its start
time matches the record, so a recycled pid cannot inherit a dead claim.
This cannot go stale because it is not remembered. A snapshot the kernel produces on demand describes the present by construction; the failure mode "a job believes the state file it wrote last tick" has no purchase, because the state file is only ever used to look something up in that snapshot.
A second check holds the lane for any foreign -m gpt-6-astra process — a
session still hand-typing, or fable-decide's rung-2 Astra call. Deliberately
over-cautious: holding costs one 300s tick; double-launching costs an account.
A ps that fails is UNREAD (exit 4), never "the lane is empty." For the
same reason, "there is no brief queued" (exit 0, IDLE) and "I could not
read the queue" (exit 4, UNREAD) have different exit codes and different log
words. A broken read rendering as nothing-to-do is the most repeated entry in
this fleet's memory.
Inventory of the superseded surface (ADR-0004 §2)
| Surface | Disposition |
|---|---|
Hand-typed nohup codex exec -m gpt-6-astra … & in sessions |
Superseded. Replaced by astra-relaunch --enqueue. Still possible — nothing forbids a shell — and safe: a hand-launched run is detected as a foreign run and holds the lane, so it cannot be double-fired against. |
/tmp/astra/brief-*.{txt,md} as an informal brief store |
Retained as the run-log location (/tmp/astra/<name>.log), so lane-status's existing Astra view stays true. Briefs themselves now live in ~/.claude/jobs/astra/, which macOS does not reap. |
lane-status's pgrep -f "codex exec" liveness check |
Not fixed here, and not copied. It matches any command line containing those words, including a Bash tool call that mentions them. astra-relaunch matches the model string instead. Flagged for a follow-up to lane-status; it is a display, not a control. |
| A session's memory as the lane's supervisor | Removed. That is the whole ADR. |
Fencing (ADR-0004 §3)
Enforcement candidate, not yet implemented: a guard-bash.sh rule in the
style of rule 13 flagging codex exec -m gpt-6-astra outside astra-relaunch
and fable-decide, pointing at --enqueue. Deliberately not shipped in this
effort: the hook runs on every Bash call in every session on the machine, and a
new rule there has a blast radius this change does not need. Until it exists,
this ADR is the authority — exactly as ADR-0002's last row records for
send-keys.
Liveness proof (ADR-0004, the inert-mechanism clause)
SYSTEMS.md → astra-relaunch, two signals: the launchd job is loaded, and
~/.claude/logs/astra-relaunch.log has matched the literal astra-relaunch OK
within 1200s (four ticks). UNREAD and BREAKER lines are written but do not
match, so the row goes stale exactly when the rung stops being able to decide.
The signal keys on the launcher deciding, not on a run being live: an empty queue is healthy, and a signal that went red on a quiet lane would page for the one thing nobody should be paged about.
Consequences
- The worst-case gap between one Astra run exiting and the next starting is five minutes, not 11.5 hours — and only when something is queued.
- A session that wants Astra work done writes the brief and stops. It does not have to stay alive, and it does not have to remember. Enqueueing is the whole handoff.
astra-relaunch --statusis the one place to ask what the lane is doing; nothing needs to be reconstructed frompsand scrollback by hand.- The unattended spend of this lane is now a number in a reviewed file. Raising
it is an edit to
co.propflow.astra-relaunch.plist, with a reviewer.