Aether is meant to survive being run on hardware that reboots, fills up and loses connections. This file says what actually happens in each case, what you can tune, and where the behaviour is proven. The chaos scenarios that drive these paths are in testing.md.
The tuning knobs
Five aether-server serve flags, all with working defaults. For duration
settings, zero means "use the default". A negative value disables a guard
where noted; for --run-container-ttl, negative means no retention and
immediate cleanup.
| Flag | Default | What it controls |
|---|---|---|
--stall-threshold |
10m |
How long a live run may go with no agent output, no file changes and nothing from its agent's own reporter before it parks at needs-attention. A run already parked because its agent said it is waiting keeps that reason. |
--poll-interval |
30s |
How often that is checked, and the granularity of the return to running. |
--checkout-ttl |
72h |
How long a finished run's worktree is kept before the GC reclaims it. Negative disables the GC. |
--run-container-ttl |
1h |
How long an explicitly closed TUI run retains its exact container, checkout, row, member account, and coordination surfaces. 0 uses the 1h default; negative means no retention and immediate cleanup. |
--min-free-disk |
1GiB (1073741824) |
Free bytes below which new runs are refused. Negative disables the floor. |
They are also server.Config fields (StallThreshold, PollInterval,
CheckoutTTL, RunContainerTTL, MinFreeDiskBytes) and pass straight through
to the scheduler.
Picking a stall threshold
The threshold is the hang detector, and the fallback for harnesses that cannot report their own state.
Where the agent reports (claude, codex, opencode, pi and omp - see
harnesses.md), a turn that ends parks the run immediately
with a reason that says what it is waiting for, and the threshold is left
to catch the case the agent cannot report: one that hangs mid-turn, which
still parks with a stalled: reason. Where the agent does not report,
silence is all the server has, and the threshold is a bet about the longest
legitimate silence: an agent thinking, compiling, or waiting on a slow tool
call produces no PTY output and touches no files, and there is no way to
tell that apart from a hang.
- Too low and long tool calls park healthy runs, which trains people to ignore the badge.
- Too high and a wedged agent burns an afternoon before anyone notices.
10 minutes suits interactive TUI runs on a normal codebase. Raise it for headless runs that do long builds; lower it to a minute or two for a fleet of short scripted runs where a real stall should surface fast. The poll interval only needs to be small relative to the threshold - a third of it is plenty, and polling faster than that just wakes the scheduler up more often.
Parking is not terminal. A stalled run whose agent starts producing output
again returns to running on the next poll, and steering it (aether inject,
or typing on an attach) is usually what gets it talking. A run parked
because its agent said it was waiting is the exception: output alone does
not release it, because a TUI repainting while you type is output and is not
work. The agent's own next turn releases it, which is what typing into it
produces - across a server restart too.
Steering is not itself that output. A steer's attributed banner is the server's own, and so is the terminal's echo of the steered line - or of keystrokes typed on an attach - which comes back even when the agent never reads its input. The server discounts what it wrote, so poking a hung agent does not hide the hang for another threshold. That discount is best effort: an unusually configured terminal, a steer over 8 KiB, or an echo that takes more than a second to come back falls through to counting the bytes, and the run then takes one more threshold to park again. What the agent puts on the stream itself always counts: a full-screen agent that repaints its UI in response is producing real output and clears its stall, which is the point. The run parks on silence from the agent, not on silence from the stream.
Picking a disk floor
The floor is headroom for what a new run is about to write: its checkout, the container's writes, its transcript, and its share of the event log. It is checked before the run row is created, so a refusal leaves nothing behind. Runs already on the disk are never touched - a half-written checkout is worse than a refused one.
Raise the floor if your workspaces are large (the checkout is a full clone) or if the data directory shares a filesystem with something that must not be starved. The refusal names the numbers, and the dashboard's disk gauge says what is holding the space.
Capture an unresponsive host
If the server is not answering even though CPU and RAM look free, capture evidence before restarting, rebooting, pruning Docker, or killing processes. Keep the capture outside the repository and review it before sharing: journal lines, command lines, paths, and socket names can be sensitive.
Run this in a root shell (sudo -s) on the server host. It writes a
permission-restricted capture; Docker probes have five-second deadlines:
umask 077
capture=$(mktemp /tmp/aether-capture.XXXXXX)
{
date -Is
systemctl show aether-server docker -p Id -p MainPID -p TasksCurrent -p TasksMax \
-p LimitNOFILE -p LimitNPROC
systemctl status aether-server --no-pager -n 80
journalctl -u aether-server -u docker -b --no-pager -n 200
journalctl -k -b --no-pager -n 200
journalctl -u aether-server -u docker -b -1 --no-pager -n 200
journalctl -k -b -1 --no-pager -n 200
} >"$capture" 2>&1
pid=$(systemctl show aether-server -p MainPID --value)
if [ "${pid:-0}" -gt 0 ]; then
{
printf '\n--- service process ---\n'
ps -L -p "$pid" -o pid,tid,stat,nlwp,comm
cat "/proc/$pid/limits"
cat "/proc/$pid/status"
printf 'fd_count='
find "/proc/$pid/fd" -maxdepth 1 -type l | wc -l
ls -l "/proc/$pid/fd" | sed -n '1,100p'
} >>"$capture" 2>&1
fi
{
printf '\n--- host pressure and capacity ---\n'
cat /proc/pressure/cpu /proc/pressure/memory /proc/pressure/io
cat /proc/loadavg /proc/sys/kernel/pid_max /proc/sys/kernel/threads-max
cat /proc/sys/fs/file-nr /proc/sys/fs/inotify/max_user_watches
cat /proc/sys/fs/inotify/max_user_instances
ps -e -o stat= | sort | uniq -c
printf 'host_threads='
ps -eLf --no-headers | wc -l
df -hT
df -ih
printf '\n--- bounded Docker probes ---\n'
timeout 5s docker info
timeout 5s docker ps --filter label=aether.managed=true \
--format 'table {{.ID}}\t{{.State}}\t{{.Names}}' | sed -n '1,50p'
timeout 5s docker stats --no-stream \
--format 'table {{.Name}}\t{{.PIDs}}\t{{.CPUPerc}}\t{{.MemUsage}}'
} >>"$capture" 2>&1
printf '%s\n' "$capture"
Previous-boot logs require retained journal history. Exit the root shell when
finished. Do not paste the capture into a repository or an issue without redaction.
Do not use SIGQUIT as a diagnostic shortcut: it terminates a Go server.
Prefer these read-only probes and preserve the original state for diagnosis.
What happens, per failure
Container wait errors
An error from Docker while waiting is inconclusive: it does not prove that the container exited. Run supervision retries the wait with a backoff (from 50 ms up to 1 s), keeps the run and container supervised, and only proceeds when Docker reports an exit or the container is definitively missing. Server shutdown cancels the wait without killing the container; a transport error is never converted into a made-up exit code.
File-change watch pressure
The checkout watcher prunes Git-ignored directory subtrees instead of adding
a kernel watch for every generated child. Tracked files and files made
visible by a negated rule remain reachable even below an ignored parent.
Changes to .gitignore, .git/info/exclude, the Git index, or directory
creation/rename schedule a coalesced refresh. Newly visible directories gain
watches; descendants of newly ignored trees lose theirs. If Git cannot answer,
Aether clears the stale prune state and temporarily walks all directories,
which is safer than silently missing changes.
The server logs watcher errors. A queue overflow discards stale watch
registrations before rescanning the checkout.
Pruning reduces watcher pressure; it is not a constant-time scan guarantee. Git refreshes and reconciliation still do work proportional to the paths they must inspect, and snapshot timing remains governed by the watcher's quiet, minimum, and maximum intervals.
Environment terminal exit
When the main shell of an environment terminal exits, Aether stops its terminal PTY sessions, destroys the exited container, and removes the matching durable terminal row before a replacement is created. If container destruction or durable-row handling fails during that sequence, the supervision entry stays marked for cleanup; the next terminal ensure retries cleanup before it can replace the terminal. A retry only removes a durable row that still names that same container, so a newer terminal cannot be deleted by an older cleanup.
Server reboot, or a hard kill
State is SQLite and git, both durable, so nothing on the shutdown path needs to run. On the next boot the scheduler reconciles every non-terminal run and every retained closed TUI run against the runtime's actual containers:
- An active container survived (the server died, the container did not):
supervision reattaches to it, the PTY session is re-adopted, the diff watch
restarts from the tree its last snapshot wrote so the next interval
continues the chain, and the run stays
running. Attaches, injects and the eventual exit all work as if nothing happened. A kill that was accepted before the crash is re-issued. A run the agent had parked stays parked with its reason: the last report is recovered with the run, so reattaching - which resizes the terminal and makes a full-screen agent repaint - does not read as the turn resuming. - An active container is gone: the partial work is committed as
wip:, the run branch is published, and the run is markedinterruptedwith its checkout preserved. An interrupted run is not relaunchable. - The run never started (it died between the row and the container): any container that was created is destroyed first - found by its sidecar or, in the narrow window before the sidecar exists, by the run ID the runtime persists as the container's creation key - and then the same wip-commit and interrupt applies.
- A retained closed TUI container survived: its merged or abandoned row, checkout, member account, and coordination surfaces remain owned by that exact container. Boot reconciliation preserves them for an eligible relaunch.
- A retained container is gone or expired: boot cleanup destroys any remaining runtime object, removes its retention metadata, and leaves the row unavailable for relaunch. It never creates a replacement.
Headless runs are not recovered into a shell. When their agent exits, Aether
commits and publishes the branch, records completed for a clean exit or
failed for an error, and destroys the container immediately. A completed
run remains available for review and an authorized member may close it as
merged or abandoned, but neither headless status is relaunchable.
Mission-assigned integrator and worker runs keep that same persistent supervisor even in headless mode, so a one-shot harness exit does not destroy the container or mark the run completed. They stay until Close, Kill, a successful worker report, or worker cancel.
Mission recovery also loads durable objectives and their bounded worker
attempts. If the initial mission inventory scan fails, aether-server serve
reports server: start service mission: mission: recover durable state: <cause>
and exits instead of deferring the failed scan to periodic recovery. The
underlying store error is preserved; fix that cause before restarting.
Saved missions and attempt reservations are not deleted.
TUI lifecycle and relaunch
For --mode tui, container PID 1 supervises the harness. After any normal
harness exit, PID 1 opens a login shell; when that shell exits, another login
shell opens. The run and its container therefore remain running until an
explicit Close, Kill, or Delete. A harness terminated by a signal or other
non-normal error does not get a replacement shell; supervision records the
failure and cleans up the container.
Close is explicit and records one of the two outcomes:
aether close <run> --outcome merged
aether close <run> --outcome abandoned
Closing a live TUI run pauses its container, commits and publishes the current
checkout, records the selected outcome, and retains the exact container,
checkout, run row, member account, and coordination surfaces for
--run-container-ttl. Zero uses the default 1h; a negative TTL disables
retention and cleans up immediately. Kill stops and destroys a run immediately.
Delete stops any live container and removes the checkout, transcript, and
durable run records; its timeline remains audit history. Its recorded cost
survives inside its workspace's and its member's spend totals - the numbers
aether cost and aether budget report, and a workspace
budget checks - so deleting a run over budget cannot reopen the cap.
Relaunch is available only for an explicitly closed, retained TUI run whose retention deadline has not passed:
aether relaunch <run>
It resumes the same run row, container, checkout, member account, and coordination surfaces. It does not create a run, checkout, branch, or replacement container, and it performs no new launch or disk-floor admission. An expired, unavailable, interrupted, killed, deleted, or headless run cannot be relaunched. The expiry sweep runs within at most one minute; boot reconciliation also sweeps expired or unavailable retained runs, so a failed relaunch never falls back to a new run.
Disk pressure
Four things grow without bound, and the dashboard's gauge covers all four
(GET /api/v1/disk, shown in the status bar with the breakdown in its
tooltip):
| Growing | Reclaimed by |
|---|---|
checkouts/ |
The TTL GC, deleting the run, or the archive sweep once deletes_at passes. |
transcripts/ |
Deleting the run, or the archive sweep. |
aether.db (and its WAL) |
Deleting the run's dependent records, or the archive sweep; the event log remains. |
repos/ |
Nothing - every push, run branch and reflog entry stays. |
The GC sweeps on boot and hourly. It only reclaims worktrees of runs that
reached a terminal state longer than --checkout-ttl ago, and never a path
an active run still names. The branch is the artifact: publishing
happens before the checkout is reclaimable, so reclaiming a worktree never
loses work. An authorized member can use Delete at every run status. For a
live run it first stops the container, waits for supervision to publish the
final branch, then removes the checkout and durable run records; its timeline
stays as audit history.
run.archive hides a run in a final disposition (merged, abandoned,
failed, or interrupted) from the board. Archiving itself removes
nothing - the run's checkout, transcripts, cost history, and timeline are
untouched - but the checkout TTL GC above still reclaims an archived run's
worktree once --checkout-ttl passes. The run can be restored at any time
with run.archive {"archived":false}. Archiving stamps archived_at;
the wire also carries deletes_at, the date the archive sweep deletes the
run. Re-archiving an already-archived run does not move either date.
Once deletes_at passes, the archive sweep deletes the run on the first
boot or hourly sweep at or after that time - it does not act the instant
the deadline arrives. Deletion removes the same checkout, transcripts,
evidence, and run-owned database records a manual Delete removes,
publishing the run's branch first if the checkout still held commits the
branch did not have. The published branch and the run's timeline survive,
the timeline carrying a system note that records the purge. The run's
cost stays in the workspace's and the member's spend totals, the same as
after a manual delete. A run whose retained
container is still held within --run-container-ttl, or whose branch
cannot be published, is skipped and retried on the next hourly sweep,
logging a warning on the server naming the run and the reason. Restore
works at any point before deletes_at; once the sweep has run, the row
is gone and restoring it returns -32000 not found. The retention period
is fixed at 14 days - there is no flag to change it. The sweep compares
archived_at against the server's wall clock at boot and hourly: a
forward clock jump, or a boot after the server was down past several
runs' deletes_at, deletes every one of them in that pass.
Below --min-free-disk, run.launch is refused with -32004 (unavailable)
and a message naming the numbers. Relaunching an eligible retained TUI run
does not perform a new launch or disk-floor admission, so it can reopen its
exact retained container and checkout below that floor. Everything else -
attaching, steering, pulling, closing, killing and deleting runs - keeps
working, which is what you need to actually clear space.
If the filesystem cannot be read at all, the floor allows the run: the guard exists to stop a disk from filling, not to stop the server.
Candidate assembly, verification, and delivery
Candidate work has its own durable lifecycle and is independent of the source run's checkout and evidence row. Preparation creates the candidate aggregate before allocating resources, then records each completed input copy as it retains the exact evidence Git revision and, when available, a bounded transcript artifact. Packet snapshots are bounded to 1 MiB total per candidate. A source packet may expire or be deleted after that ownership transfer; the candidate validates its own refs, transcript checksums, and metadata instead of trusting the original packet or run row.
Candidate inputs are applied in order in a server-owned isolated checkout.
When a cherry-pick conflicts, the journal and checkout remain in the
conflicted state for explicit file resolutions. The candidate does not
freeze until every retained input has applied; after freezing, the candidate
revision and inputs are immutable. A required source that is unavailable or
truncated refuses preparation. If an owned ref, transcript, or checksum is
missing later, the candidate becomes unavailable and verification and
delivery are blocked; Aether does not silently rebuild it from an expired
source.
Verification is asynchronous and finite. The server persists the runtime creation key before creating a container, runs the exact requested argv against a disposable copy of the frozen revision, bounds retained output to 64 KiB per verification (at most 2 MiB across 32 verification records) while continuing to drain it, and checks the tree after all child processes stop. A timeout, cancellation, runtime failure, or source change is never a pass. A restart reconciles persisted creation keys, destroys any discovered verification container, removes its disposable checkout, and marks an interrupted attempt as an error; it never reruns the command or invents an exit code. Cleanup failures leave the verification record and recoverable resources for a later retry.
Delivery claims its request durably before touching Git. A local workspace
target uses an atomic expected-old compare-and-swap. A mirrored target cannot
be updated directly: a proposal transaction creates the public
refs/heads/aether/proposal-<request-id> ref and a private receipt while
leaving the upstream-owned mirror base unchanged. A database failure after a
successful Git transaction is reconciled from that exact private receipt, not
by guessing from the target's current value; retrying therefore does not
duplicate a delivery. The public proposal remains available for the human's
normal fetch/push review route.
Candidate lifetime is 30 days. Verification validity is 24 hours, and a delivery request cannot outlive its candidate or its selected verifications. Expiry and deletion first fence new actions and persist the transition, then destroy runtime/checkouts and remove candidate-private refs and transcript copies. Public proposal refs are transport artifacts and are not removed by candidate-private cleanup. Tombstone metadata is retained for at most 30 days so retries and cleanup can be reconciled without keeping source artifacts. If the transition or preservation step fails, Aether keeps recoverable resources and retries cleanup rather than deleting evidence silently. See teams.md for the operator-facing flow and integration.md for the method-level contract.
Launch freshness and mirror failures
Launch freshness is server-owned. Before a run row, checkout or container exists, the scheduler captures the workspace base. A configured workspace mirror is refreshed from its source at that point; a local-only workspace reads its local base directly. The client does not decide whether this base is fresh; freshness does not require a pre-launch local operation. If that pre-run capture fails - for example, because the source is offline, rewritten or diverged - the launch is refused and leaves no run row.
When a strict mirror capture fails after a commit was already accepted, the error may include that exact accepted commit. The CLI can print the explicit retry:
aether run --cached-base <40-character-commit>
The --cached-base override is request-scoped: it applies only to the launch
request where it is supplied and is never inherited by later launches.
Repeating the same override is accepted while the SHA still matches the
accepted commit and the workspace base has not moved; a mismatched SHA or
moved base is refused. It is not a general freshness bypass. A successful
retry records the cached base as the run's immutable base provenance.
Mirror state does not remove review flow. Published run branches remain fetchable and pullable even while a mirror is failing, and a local-only workspace keeps its normal direct base writes and repository setup flow.
Scheduled occurrences
Each due schedule occurrence is consumed once. The server records the occurrence, re-checks the creating member's current launch permission, and then enters the same server-owned launch path as a manual run. An occurrence skipped by current permission or template checks, or refused by a pre-run guard (disk floor, budget or mirror/base capture), creates no run row; launch failures are reported as timeline failure notes when the schedule still has template context. It is never represented as a failed run. The next future occurrence is the next chance, rather than an immediate retry or a catch-up storm. Once the base is captured and the row exists, ordinary provisioning failures do produce the failed run row described below.
Missed slots while the server is down are skipped; the scheduler resumes at the next occurrence. See teams.md for schedule administration.
Agent stall or crash
needs-attention - Needs you on the board - means one of two things,
and the reason on the run says which.
The agent is waiting for you. A harness that reports its own state
parks the run the moment its turn ends, or it asks for permission or an
answer, with a reason that reads waiting for your input,
waiting for your permission or waiting for your answer. There is no
delay: the report arrives as the agent stops. A server restart does not
change that: the report is recovered with the run, so a run that was
waiting for you is still waiting for you afterwards.
How such a run comes back depends on how much its harness can say. Where the
agent reports both ends of a turn (claude, pi, omp), the run returns
to running with agent resumed when the agent starts its next turn -
which is what steering it produces - and not on terminal output alone, since
a TUI repaints while the member types. Where it only reports that a turn
ended (codex), there is no such report to wait for, so the run returns
with activity resumed on agent output or a file change. That takes
activity the report did not already cover: the answer the turn wrote before
it ended, and the frames the TUI keeps painting for a few seconds after it,
belong to the turn that is over. Those few seconds are measured on the
clock, not in polls, so --poll-interval can be set to anything without
turning a trailing repaint into a new turn; past them, output still has to
keep arriving into a later poll before the run reads as working again.
That second poll is what sets the delay: expect the return to running to
land one to two --poll-intervals behind the agent.
Output here is anything drawn in the terminal, because a harness that
cannot say when a turn starts leaves nothing else to go on. The echo of
your own typing counts: type a long prompt into a parked codex run and it
can read as running before you send it, and read as stalled: rather
than waiting for your input if you then walk away.
The run stalled. No agent output, no file changes and nothing from the
agent's reporter past --stall-threshold parks a live run at
needs-attention with a reason that leads with stalled:. This is the hang
detector: it catches an agent that said it was working and then wedged, and
it is the only signal at all for a harness that cannot report. Genuine agent
output or a file change returns such a run to running; server-written
steering echoes do not.
Either way the run remains supervised in its run container: while unpaused, members with the existing steer permission can attach, inject input, and open or reconnect a writable run-container shell to investigate it.
A clean TUI harness exit returns to the supervisor, which opens a login shell
instead of finalizing the run. Explicit Close commits and publishes the latest
work, records merged or abandoned, and applies the retention policy. A harness
terminated by a signal or other non-normal error marks the run failed and
cleans up the container; it does not receive a replacement shell. Headless
clean exit still commits and publishes, records completed, and destroys the
container immediately. A failed run's partial work is committed as wip:.
SSH drop mid-attach
The PTY session belongs to the server, not to the connection, so a dropped attach changes nothing about the run. Reattaching streams the complete recorded transcript before live output, including transcript segments preserved across a server restart. Input a member typed that the transport never delivered is dropped whole. What reaches the agent is always an exact prefix of what the connection delivered - never reordered, never duplicated - and a dead connection's straggler bytes can never land after the attach unwound, so they cannot interleave with the reattach's input.
SSH port-forward disconnect
For aether forward, a client half-close is not a full disconnect. The
forwarder half-closes the backend and lets the reverse direction drain, so a
request can finish after the client has sent EOF. A full SSH channel or
connection teardown cancels the forward and closes both sides, releasing the
backend instead of leaving a stuck dial behind.
Where each row is proven
Every row above has a covering scenario or unit test; the map lives in testing.md so the suite and the map stay in one place.


