Map: exarch / agent
agent.rs splits the node every fleet member is into two types along who
may touch what (agent-and-avatar).
Agent is the public half, held behind an Arc the fleet shares:
identity (id, name, log_dir), the two cancel doors (cancel::Token
and the eval-layer reach: EvalReach), the sender half of its own
mailbox, an owned hot-swappable provider: ProviderHandle, immutable
config (caps, fuel, returns, search, interactive,
allow_schedule, egress, dial, index, the resolved system prompt,
disk_warn_bytes), the single-writer status: Mutex<Status> — { rest, reply, awaiting } — consumer (the parent’s Stamp, minted at this
agent’s birth; None for a root), a strong parent: Option<Arc<Agent>>,
and a weak children: Mutex<Vec<Weak<Agent>>>. There is one fence, the
inbox’s own clear-epoch
(agent-and-avatar), and it is carried
only inside bus::Stamp — the addressed envelope Mailbox::stamp mints,
pairing the destination mailbox with its epoch at composition — so a
message that cannot judge its own staleness (bus::Stamped) is unsendable
with a stamp forgotten, skewed to another inbox, or refreshed at push time;
the destination judges it at its own pop.
Avatar is the private half: the canonical
model projection (AgentLog), a seat
(agent/seat.rs) carrying the transport this run drives through —
Seat::Identity, the persistent in-process `Shell`
behind an IdentityTransport (plus the session Scratch, the re-seed cwd,
and the interrupt target EvalReach interrupts through), or Seat::Wire, a
WireTransport driving a remote engine, one process per session
(engine-protocol) — the
canonical run and probe vocabulary either way
(engine-protocol) — plus the inbox,
the nudge Registry, the schedule ScheduleRegistry, and every other field
only the attend thread touches, beside the Arc<Agent> it embodies
(.agent, pub(crate) so a caller outside agent reaches identity and
config at .agent.… directly). Every method that runs the agent takes
&mut Avatar.
An agent is live while its Avatar holds that Arc. Nothing
deregisters: Avatar’s Drop (agent/build.rs) clears its own armed
schedules and records the session-ended bookend, and the last strong
reference going with it is what a parent’s children or the fleet’s
roots/names prune away at their next walk. There is no Session, no
is_root: every distinction reduces to position in the tree, read from
Agent::parent together with interactive and the agent’s own exchange
clock. What every node shares for identity resolution and the idle lease
— the by-name door — lives on the thin `Fleet`,
not on the node; the one `FleetBus` and the
transport Engine are session-level handles the frontend builds and holds,
not fields of Fleet itself. Output caps
are fixed agent/digest.rs constants, not per-agent state.
Status, the exchange clock, and the lock rule
Two per-agent mutexes exist, Agent::status and Agent::children. Rule:
hold at most one at a time. children is locked only to push
(Agent::adopt) or to snapshot (Agent::children, which prunes as it
copies); every walk (Agent::walk, descendant, cascade, roster) runs
over the snapshot, never under the lock. status is a single-writer
register — the avatar writes one field at a time (set_resting,
deposit_reply, heard, message, clear_subtree) and a reader takes
one snapshot and computes nothing under the lock, so the mutex buys
atomicity and nothing more. parent is a plain Arc, read lock-free.
The park verdict reads no fact another thread can change without a
delivery. Avatar::park_mode runs under the consumer’s own queue mutex
(Inbox::next_or_idle) and before the pop, and each of its inputs is one
of three kinds. The exchange clock is under that same mutex: Queue { posts, last_exchange } is what the mutex guards, so Mailbox::steer and
Agent::message stamp and enqueue in one acquisition (Mailbox::exchange),
and next_or_idle hands the verdict its reading as the engaged argument.
The agent’s own status — reply, and awaiting, the set of direct
children spawned (adopt) or messaged (message) and not yet heard from
(heard, at the moment the attend loop takes up the child’s
AgentResult) — is written only by the consumer’s thread, so
has_busy_children is live children ∩ awaiting and never reads a child’s
own rest. What remains — a child dying, a schedule firing — happens only
after a delivery into this queue, which the pre-pop verdict cannot lose
to. A Status writer drops its guard before pushing to any inbox; that is
the whole of the cross-type lock discipline.
last_exchange lives on the inbox, not on Agent::status. It was never
agent state — it is “when did a human or a parent last push into this
mailbox”, a fact the Mailbox itself witnesses. Agent::engaged() reads
it, Agent::idle() reads
mailbox.last_exchange().unwrap_or(started).elapsed() — the roster’s
idle-s and the reaper’s bound, in one door.
Up is strong, down is weak. Agent::parent is a strong Arc, so
deposit_reply and the scope climb (Agent::descendant) never dangle: a
parent whose own avatar has gone is still a reachable Agent with a
terminated token, not an absence. Agent::children holds Weak, so there
is no cycle to reason about and a walk prunes what has settled. Nothing
holds an Arc<Agent> to a descendant — every reaper closure
(fleet.rs’s lease_fire) and every upward result (AgentResult) carries
a name or a Weak, never a strong handle down the tree.
The trunk is the parent-less node (parent = None), built by
Avatar::root(RootConfig, RootSeat, provider): RootConfig carries the
prompt, caps, fuel (exarch’s and synod’s launch sites pass SPAWN_FUEL),
the IT-set Egress (exarch/src/egress.rs), and the optional dial a wire
trunk reaches its children through (below) — each set
once at launch and inherited verbatim by every fork — while RootSeat picks
the seat kind (Identity boots its own shell from scratch; Wire adopts a
built transport whose engine lives elsewhere, and spawns its sub-agents by
dialling back into it).
Egress bundles the two things a fleet’s outbound network shares across
every fork: net_policy::NetPolicy (exarch/src/net_policy.rs, the IT-owned
allowlist read from /etc/exarch/net-policy.ral or the embedded default —
an exact hosts list of lowercase ASCII DNS names plus search, with the
retired read/write, max-bytes and rate-per-minute keys now hard
errors naming their replacement) and an AuditLog (exarch/src/egress.rs),
reduced to one Tunnel record per attempt: its final vetted address, and on
close the byte count each direction carried — telemetry, not policy. None of
it is a model-facing verb any more — there is no fetch-url builtin
(builtins). The same Egress a trunk opens at launch
is what guest-net::Config::egress takes: the policy and ledger a synod
session’s guest network is gated by are the fleet’s own, not a second
copy (egress, synod). Host-mode exarch, which
has no guest to police, still keeps Egress for one thing: the search bit
that clamps the harness agents `start [... search: …] field
(builtins).
An interactive node
built to converse — the interactive trunk, and every /branch tab
(branch-minimal) — withholds reply and
parks for its human, but both behaviours fall out of construction and position,
never an is_root branch:
returns(a) // fixed at construction: fork true, branch false, trunk ¬interactive
conversing(a) = a.interactive ∧ ¬returns(a)
park_mode(a) = Quiesce if conversing(a) ∧ a.cancel.terminated() // /close reaped it
Held if conversing(a) // immune to cancellation
Engaged if a.parent ∧ returns(a) ∧ (engaged ∨ a.has_reply()) // talked to, or holding a reply; a terminate cause still ends it
HeldByChildren if some live child of a is still awaited
UntilCancelled if a.schedules.armed()
Quiesce otherwise
returns (agent.rs) is a construction-fixed field — one bit read by
returns(), parking’s conversing predicate, the desk’s reply refusal
(builtins), and the prompt’s per-agent resolver. The
resolver applies the index from returns, allow_schedule, and
spawns = fuel > 0, so no agent is shown a verb or section the desk would
certainly refuse; the nudge layer, parking, reply availability, and advertised
vocabulary cannot disagree
(reply-terminates-returning-agents).
park_mode (agent/attend.rs, returning a ParkMode of Held / Engaged /
HeldByChildren / UntilCancelled / Quiesce, bus/inbox.rs) is the should_park
verdict: a conversing node parks Held, immune to cancellation; a returning,
parented agent that is messageable — someone has messaged it, or it holds a
deposited reply — parks Engaged, the same wait except a terminate-class
cause still ends it, since an exchange is not a conversation — unless its
own cancel token has been terminated (self.agent.cancel.terminated(), the
one self-check not answered by a Weak::upgrade), which is what /close
stamps, since a conversing root has no parent to prune it away and parking
Held past that stamp would be a zombie; busy children hold it until they reply or
die; a live self-schedule holds it until cancelled; otherwise it terminates at
quiescence — the one-shot contract a headless trunk satisfies. --chat builds the trunk with no system
prompt, no tool at all (tool_enabled: false), and no nudge registry — a bare
conversation, the same attend loop.
The attend loop
Three nested loops, the same for trunk and child alike:
-
attend— the per-agent lifetime. The trunk publishes its sticky cancel token for the OS-signal path (cancel::publish, called at each site that launches a process trunk —headless::run,headless::converse_settled, andtui::tui_loop::run— held for the whole attend); a sub-agent publishes nothing, since its token is reached through the tree cascade (Agent::cancel_tree), not the slot. Each pass pulls the next item from this agent’s inbox vianext_or_idle(|| self.park_mode(), …), which re-evaluates the park verdict on everyCondvarwake — so the idle lease’s terminate-cause cancel, or the last live child settling, is seen on the very next wake — and hands it totake_up, the per-item step shared withattend’s bounded twinattend_backlog(converse’s per-exchange drain): every item reaching here already survived the inbox’s own pop-time fence, so what is left is the exchange-boundary latch reset, a session command’s dispatch toControl, thedeliberatecall itself, and the nudge reaction. A genuine exchange boundary resets the nudge budget and clears the sticky cancel token (cancel::Token::reset), so a prior exchange’s Esc cannot bleed into the next; a self-nudge is the same exchange continuing and resets neither. A child’sreplydeposits its value on its ownAgent(Agent::deposit_reply, which also posts the one-line notice) and the loop parksEngaged; only the headless root’sreplyends its loop. At the single exit the loop winds a stranded prompt back throughquiesceso the nextappend_useris always admissible (exchange-ends-ready); the trunk’sAvatarthen drops (a child’s drops when its detached thread returns), pruned from its parent’schildrenand the fleet’s doors at their next walk — the whole of what deregistration used to require — so the fleet empties as its last agent’s avatar goes. A panictake_upcatches around itsdeliberatecall is a host-side fault (provider transport, surface decode, render, digest), recorded asAgentOutcome::Failed; an eval-side panic never unwinds this far — the engine’s own run door (Shell::run) checkpointsenv/contextat entry, rolls them back, and reports the failed run, durability being engine-owned (panic-recovery). The per-call host —RunHost, wrapping the desk and applier (shell-eval) — is never installed as shared state: it is a plainArcAvatar::run_shellbuilds and holds on its own stack, so a panic unwinding throughdeliberatedrops it with everything else there, with nothing separate to retire. -
deliberate— one prompt stepped to quiescence over the agent’s own provider (self.provider.current(), read once at the top bytake_upso a/modelswap lands next item, never mid-deliberation): render the transcript, stream a reply throughprovider.complete(onestep), admit then append the assistant message, run the resulting tool-call batch (run_batch), append their results, optionally append a drained steering prompt, repeat until the model emits no tool call. The admission step (admit_assistant, at the commit boundary) enforces the transcript-admission invariant: it repairs a non-object tool-callfn_argumentsto{}(X2) and substitutes a stub for an otherwise-empty assistant message (X7), so every committed message serialises to a request a strict backend accepts.StopReason::MaxTokenswith no captured tool call raisesProviderError::Truncatedafter appending the partial, so acontinuenudge keeps the work as context; with captured tool calls it dispatches them and continues instead, since returningTruncatedthere would strand the protocol inAwaitingToolResultsand fail the nudge’s nextappend_user(X6). A hardMAX_STEPSceiling (250) ends a deliberation whose model never stops calling tools — the headless/autonomous counterpart to interactive Esc — returningdeliberate::Outcome::Capped. That outcome matches no nudge rule, soattendtreats it as terminal; re-attending would only spend the ceiling again. -
run_batch— runs one step’s tool-call batch in order, each call throughinvoke. Every call returns aSessionToolResultsynchronously — and there is onlyralto call (tools); a spawn verb inside it hands the script a start receipt after launching the detached child. Once every requested tool id has a result,run_batchdrains this agent’s inbox’s tool-boundary steering. A non-slash steering prompt is appended after the complete tool-result batch, and the next step asks the provider with the user’s steering in context (tool-boundary-steering). A sub-agent has no human writer, so its inbox holds no steering and this is always empty.A streamed step closes through one
close_stepdoor: it seals the answer and reasoning choppers, then publishesTransient::Boundary, even when the provider or cancellation path already carries an error.abandon_stepuses the same ordering best-effort, so a live display edge cannot survive into the next step (frontend).
Worker-reap and large-binding notices need no drain at attend’s top:
core’s own engine pushes both as `notice surface classes at the ready
boundary of the run that produced them
(engine-protocol), decoded by
shell-eval’s decode_surface into Surface::Notice
and recorded/rendered from there. A reap notice names a worker removed by policy — the lease
chain’s idle or backstop bound on a running worker, or the retention sweep
expiring a settled entry’s unclaimed result — rather than one an eliminator
observed away. Transcript and TUI only — the rendered one-liner is
cards’s reap_card, the completion card’s sibling — never
model-facing, since delivery of a reap to the model itself is deferred.
What attend’s
top still runs, each pass its own ready boundary: check_disk_warn. (The
protected services pin and its reconciler are gone —
pins.)
The retention clock itself is core’s: the engine ticks the worker registry
once per source dispatch and sweeps it at each settled run’s ready
boundary (shell-state), armed with
shell-eval’s SETTLED_WORKER_RETENTION. The
agent keeps its own mirror of the same drum — Avatar::ral_epoch,
incremented once at the top of every run_shell call, a failed eval still
a call — which /resources reads to render nearest time-to-reap and
check_disk_warn reads for its amortisation; the two clocks coincide
one-to-one (decisions/260629_agent-binding-reaping). The counter starts
at 0, a fork’s child starts its own at 0, and /clear does not rewind it.
Retention notices need no plumbing of their own: they ride the same drain
above.
The binding-lease ledger is armed by bootstrap::arm_session_ledgers —
the one policy site, run by the identity seat’s ceremony right after the
session-dir seeding (seeding then arming stay one visible sequence) and by
the wire engine’s own boot (engine_boot_shell) — with
shell-eval’s BINDING_IDLE_CALLS (256) and
LARGE_BINDING_BYTES (1 MiB), beside arm_worker_retention. The
large-binding residency nudge rides the pushed `notice channel above,
and the prune half is engine housekeeping too: idle top-level names fall at
the engine’s own ready boundary, announced as a pushed
`notice [kind: `prune] class the host decodes into the same
recorded Display::Notice posture as a reap. The engine’s
run-entry checkpoint orders after any prior boundary’s prune, so a later
panic rollback can never resurrect a name a pass just pruned.
/resources is the probe fold over the same accumulators
(probe-convention): routed exactly as
/clear — an Item::Command drained at the exchange boundary, handled by
the TUI’s Control against the agent the attend loop owns —
Avatar::resource_rows surveys what this thread may legally read — the worker
registry’s running/settled split with the nearest time-to-reap and the
binding-ledger figures read as data through the transport’s Enquiry desk
(probe_workers and its sibling probes,
engine-protocol), plus inbox depth per
source, the event ledger’s logical length and history bytes, log-dir and scratch
disk walked at invocation, and the sub-agent idle lease as two rows (nearest
time-to-reap, and the demote threshold) — and
emit_resources posts one Transient::Resources
carrying the raw rows beside their already-rendered card together — chrome
only, so unlike a recorded observation there is no later fold to re-render one
from.
A probe fold is an interactive diagnostic, read when it is run: no session
keeps a pressure history, so the figures live only in this emission, never
in record.jsonl. The frontend appends the rows for the accumulators it
owns (viewports, views, the bus) at render time; neither half reaches
across a thread. Probing never mutates and never renews a lease —
enumeration is not observation — and the fold is never model-facing.
The inbox’s per-source depth is a probe figure, not a quota.
Mailbox/Inbox::push is infallible, one shared rule (Shared::push)
split by source: the three idempotent sources (user, schedule, nudge)
coalesce instead of growing the queue — a ScheduledWakeup replaces a
still-queued wakeup for the same schedule id (newest wins), consecutive
UserSteering pushes merge one per line (never across a slash line, which
would silently change its exchange-boundary classification), and a Nudge
replaces any still-queued nudge — newest wins, since a second means a
fresher continuation superseded the first, not that both are owed.
The other four (AgentResult, AgentMessage, Command, Surface) simply
queue. A per-source cap of 64 with a 256 total once rejected them, on the
long-session-budgets principle that every accumulator needs a bound; it was
retired because none of them is machine-floodable — a child or worker posts
its result once, a slash command arrives at a human’s typing rate, and a
message costs its sender a model turn that fuel already bounds — so the
cap could fire only wrongly, and when it did the child parked with its reply
staged but its parent never notified, the very silent loss the cap was meant
to rule out.
The headless-completion gate is gone with expect_action: the one role flag
that did not fit the parent collapse is dropped, not relocated. What
remains splits into two disciplines, named and owned by agent/nudge.rs’s
Nudges (Avatar::nudges, None for a toolless --chat trunk): a
repair — Empty, Stopped, Truncated, or a returning agent’s
Complete without reply — is event-shaped, exceptional, and spends the
per-exchange repair budget (BUDGET, 3); a standing condition — the pin
register, the context-pressure gauge — is a fact about the agent’s own live
state, edge-triggered: told once when it changes, silent while it holds,
budget-free. Both still compose into at most one EXARCH_REMINDER message
per completion, self-posted as Post::Nudge, committed by append_user
inside the same exchange. react, the sole post-attempt entry point, gates
everything on quiet — no standing reply, no detached shell work, no busy
children, the one condition every nudge kind shares — before it reads the
attempt:
| kind | trigger | budget |
|---|---|---|
| empty-turn repair | quiet ∧ Ok(Empty) | spends |
| early-stop repair | quiet ∧ Ok(Stopped) | spends |
| truncation repair | quiet ∧ Err(Truncated) | spends |
| reply repair | quiet ∧ Ok(Complete) ∧ must_reply | spends |
| pin reminder | quiet ∧ Ok(Complete) ∧ register non-empty ∧ digest changed | free |
| pressure warning | quiet ∧ Ok(Complete) ∧ gauge Over ∧ excursion untold | free |
Everything else is accepted as-is, unspent, and deliberately so: Replied (a
reply is final), Cancelled (the human asked), Capped (a nudge would only
buy the deliberation another MAX_STEPS after it already burned 250 round
trips without quiescing — the wrong channel for a terminal condition, which
already reaches its consumer through AgentOutcome::Stopped("step cap reached")), and every unclassified provider error (the transport’s own).
Nudges is the one owner of every nudge-relevant latch: the repair budget
used, the pin digest last told (pinned_told), and whether the live
pressure excursion has been told (pressure_told). Nobody else holds
state — pressure_gauge is a pure &self reading with no latch of its own,
and take_up neither latches nor unlatches around it. An edge is consumed
at decide time, in the same act as the part’s emission, so “told but not
sent” and “sent but not told” have no spelling. reset(), called on every
exchange-opening item, clears only the budget — a new exchange is not a new
condition, and clearing the edges there would re-tell an unchanged pin once
per delivered item. Every path that discards a decided-but-uncommitted nudge
instead rebuilds Nudges whole: /clear (Inbox::clear sweeps the queue,
Avatar::clear rebirths) and /rewind (drop_nudges sheds the queued
nudge, rewind rebirths) — the rebirth also covers a /rewind typed mid-
deliberation, whose Barrier queues ahead of the nudge take_up posts
after it: the nudge is decided (its edges already consumed) and then shed,
so without the rebirth the telling would be recorded but never committed.
Edge-triggering the pin reminder closes a livelock: a stationary register can
now produce at most one nudge, where a bare per-completion reminder let a
Complete, its own reminder, and the next Complete cycle forever while the
register sat unchanged — the no-pins set-goal advertisement that used to
steer an agent straight into that state is deleted outright with it. A
standing condition staying event-shaped on the wire looks like it should
strain the fold law
(context-is-a-projection’s “one
state, one fold”) — a condition that holds seems like it should not be
re-stated per turn — but it does not: the transcript is already persistent,
so a committed turn stands in every later render until an edit folds it, and
re-stating the register on every turn was never persistence, only
redundancy. What must be recorded is the level’s transitions, and an edge —
“the register changed”, “pressure crossed the line” — is genuinely
event-shaped, so recording it as a committed user turn is honest, not a
workaround. The alternatives are strictly worse against the law: a
render-time seam re-injecting live state would break fold(log) == memo,
since the model’s view would stop being reproducible from the record; a
dedicated “standing condition” record class still has to enter the fold to
reach the model, so it is a user turn with a fancier name plus a new record
variant, fold arm, and admission rule — machinery for no semantic gain. A
compaction may fold a telling into the digest without harm either way: both
conditions self-heal regardless — pressure re-fires per excursion by
construction, the pin reminder re-fires on the next register change, and the
model can always pin-list.
A --chat trunk holds no Nudges (nudges: Option<nudge::Nudges>,
None when the tool is withheld): every nudge steers the model toward a
tool it does not have, so no rule runs, no reminder fires, and nothing
synthetic ever joins the conversation. Its provider errors still reach the
human, since that report is the attend loop’s own step — take_up emits
Forensic::ProviderError for whatever error the attempt carries before it
ever asks for a nudge, so react decides and nothing more.
The Fleet
fleet.rs’s Fleet is { names: Mutex<HashMap<String, Weak<Agent>>>, roots: Mutex<Vec<Weak<Agent>>>, lease: Duration } — two Weak doors and the
idle-lease bound they share, fixed at construction, nothing else. It is not the tree: the tree is
Agent::parent/Agent::children, and every walk — the roster, the cancel
cascade, the scope check — runs there. names is the by-name door a spawn
claims identity at and agents `message/`cancel/`read
resolve through (Fleet::resolve); roots holds the trunk and every
/branch — a root reports to nobody, so a walk from roots
(nearest_reap) is how the idle-lease scan reaches every live agent in the
run. The frontend arrives through no fleet door at all: each tab was handed
its agent’s Weak on the birth notice
(frontend). Both are Weak, and a lookup prunes what has
settled as it walks. FleetBus and the Arc<provider::Engine> are not
fields of Fleet — the frontend builds its own (FleetBus::session) over
the trunk’s inbox and holds the engine handle separately; what every node
shares through Fleet is only identity resolution and the lease.
- One door:
Fleet::enrol. Construction is the only place an agent joins the fleet, and every refusal (Unborn::SessionDead— the parent raced a cancel;NameTaken;NameMalformed, checked here too since a wire peer is not trusted to have usedcheck_name) is decided under the one lock a racing spawn’s own claim takes. A refused agent never appears in the fleet, however briefly: the name is claimed, then — only then — the agent is adopted into its parent’schildrenor pushed ontoroots, and a parented birth arms its lease (arm_lease). - The fleet is alive while
rootsprunes to empty — the literal “dies when no active agents remain” (Fleet::is_empty). An agent leaves by its avatar dropping (quiescence, cancel, a non-reply finish); a conversing node stays until/quit(or/close) because it parks; a headless trunk leaves at quiescence; a replied sub-agent parks under its idle lease and leaves when it is reaped. There is no human-less daemon: nothing lingers without a present human, running work, a bounded lease, or a bounded self-schedule. - The idle lease is dynamic; focus is not. A leased child — every
agent
Fleet::enrolgave a parent, i.e. every returning sub-agent; the lease is a consequence of having a reporting parent and of nothing else, no caller chooses it — is reaped once its idle span (Agent::idle, measured off the inbox’s last-exchange clock, seeded at birth) exceeds the fleet’s bound: the reaper (fleet.rs’slease_fire, on the process reaper daemon thread) re-arms itself for the remaining margin on every fire that finds the agent’sWeakstill upgrading and under bound, and cancels the subtree withCancelCause::Deadline(Agent::cancel_tree) once it is not. The one thing that renews the clock is a delivered message — a human’s (Mailbox::steer) or the parent’s (Agent::message); nothing else does — not matrix navigation, not the attachment itsEntersets (tui::tabs::Tabs::focus, a presentation-onlyAgentIdneither the fleet norpark_modeever reads), not a/resourcesprobe. A returning agent’sreplycancels its proper descendants and parks it, regardless of which tab the human is attached to.
Cancellation cascades the subtree, across both layers
The single cascade serves the deliberate teardowns — agents `cancel, a
returning agent’s reply, and the /clear / /close subtree reaps — and it
runs over the agent tree itself, not a registry. Agent::name is its
identity — well-formed and unique among live agents, both enforced at
Fleet::enrol (Unborn::NameMalformed/NameTaken; the trunk holds
TRUNK_NAME, agent/build.rs), which is where a name becomes identity and
so the one place a wire peer cannot go around. The desk refuses both a beat
earlier, before it forks a log or dials anything, but only enrol is
authoritative. A name is also the handle agents `message/`cancel
resolve descendants by, through Fleet::resolve and then the scope climb
(Agent::descendant). Every Agent carries a strong parent, so the tree
is the spawn tree directly: Agent::cancel_tree cancels an agent and its
whole subtree, Agent::cancel_descendants abandons a returning agent’s
children, and Agent::clear_subtree reaps a subtree and forgets what it
was awaiting. The fence a late result or deferred surface batch must
survive is no longer here: Avatar::clear drains this agent’s own inbox,
and that drain is what bumps the clear-epoch a bus::Stamp-borne message
is judged against at its own pop. The epoch is per inbox, not per fleet,
and the envelope a worker carries is its reader’s by construction — a
/clear in one tab must not throw away work another tab is still waiting
on. Each cancelled node is
stopped across both layers:
its cooperative Token (read by deliberate between steps and raced by
the provider’s mid-stream cancel) and the eval layer through its own
reach: EvalReach (fixed at construction — every agent carries one, the
trunk included) —
EvalReach::Identity holds the session’s durable
root (Shell::cancel_handle) for terminate and, for interrupt, the cell its
transport publishes each dispatch’s scope into as that dispatch is minted —
ahead of the engine lock, so an interrupt racing a dispatch still waiting on the
lock reaches the run about to be born rather than the one just ended
(cancellation) — while EvalReach::Wire’s only
host-reachable primitive is
Control::Cancel on the in-flight dispatch, so both motions resolve to it
— and a ral eval already in flight
unwinds at the evaluator’s poll points instead of grinding to its
timeout_secs wall. The trunk’s reach is interrupt-only —
EvalReach::interrupt_only clears its eval_root to None at construction,
so a terminate there degrades to the Token alone: its session outlives any
cancel, and a captured root would both permanently poison it and go stale at
the next /clear, which rebuilds the trunk’s shell in place while an agent’s
reach is fixed once, at birth. Esc also reaches the trunk’s exchange through the
ambient foreground cause, which only the trunk’s session is minted facing
(cancel-is-a-watermark,
per-agent-eval-cancel,
cancellation). Esc / Ctrl-C, by contrast, are a
per-tab exchange interrupt, not a cascade: they stop only the focused agent’s
current exchange (Agent::interrupt, reached through the focused tab’s own
Weak, plus cancel::raise_interrupt
on the trunk), leaving its descendants running
(cancel-per-tab); the focused agent’s
sticky token is cleared at each exchange boundary (Token::reset).
Cancelling eval_root already reaches a cancelled node’s own detached ral
workers with no edge of its own: a worker’s cancel scope is a child of its
shell’s durable root, and every CancelScope::is_cancelled walks its
ancestors. What the cascade does not reach is a node that ends without ever
being cancelled — the ordinary reply/settle path, or the trunk’s own
end-of-attend exit, where the avatar simply drops. Avatar’s Drop
(agent/build.rs) closes that gap in one place: it clears its own armed
schedules unconditionally and records the session-ended bookend — the
underlying seat’s own teardown cancels the workers registered on its shell —
the same law clear already applies explicitly below, so a settled-but-never-cancelled
agent leaks neither
(residency, session-ledger).
The provider is per-agent and hot-swappable
ProviderHandle is owned by the Agent, not threaded through attend’s
parameters. take_up reads self.provider.current() once per item. /model swaps
the focused agent’s handle directly on the UI thread (resolved through
the fleet by id), so a
swap on one agent never disturbs another. fork seeds the child’s own handle
from the parent’s current provider (ProviderHandle::new(self.provider.current())),
so the child inherits the model in force at spawn and may diverge afterward.
Lifecycle: clear, compact, resume, fork
clear rebuilds the focused agent without carrying cancellation residue forward:
it drops the waiting inbox before the reboot, so a prompt typed during that
reboot belongs to the new context, then re-runs the seat’s ceremony
(Seat::clear) — the identity seat reboots a
fresh shell from boot_root_shell (agent/seat.rs, the cwd- and
scratch-seeding wrapper over bootstrap::boot_shell) onto the same
interrupt target; a wire session instead clears by killing its engine
process and booting a fresh one from the same recipe, so no caller routes
/clear to that seat. The identity seat rotates record.jsonl to the
first-free .n, then starts a fresh record ledger; it never truncates the
old record. The record rename is the rotation commit point. The rotation
swaps the file behind the seam, never the seam: the Emitter and its
attached bus sink are the session’s, so every clone already handed out — the
surface buffer’s, the frontend’s coupled channel — writes into the new
segment and keeps publishing. Swapping the Emitter instead stranded them
on the rotated-away file, and the frontend, whose clear-drain gate opens on
the Transient::Cleared that seam publishes, stayed dark for the whole first
exchange of the cleared session. Clear also
clears the schedule registry and cascades cancel to its subtree. Replacing the
transport drops the outgoing shell, whose LocalState teardown cancels
every worker still registered on it — explicit destruction outranks every
lease, the durable class included, with no host call site to forget
(shell-state). A worker settling after the cancel still tries to flush
its deferred done batch through the boundary it captured before the clear;
the same pop-time epoch fence (shell-eval) that
already drops a stale agent result drops that flush too, so no pre-clear
worker output survives into the rebuilt context. It is the focused agent’s,
not a fleet-wide reset.
--resume is a trunk-only lifecycle: it validates and replays session 0’s
record.jsonl, quarantining only an unterminated crash fragment, then reopens
the file for append. The live model and provider are selected again, while the
shell is fresh and receives a note describing the bindings, workers, cwd,
scratch, pins, and schedules that were not durable. Wire seats and child logs
are refused rather than half-resumed.
--no-logs chooses the mirror-only path at birth: no durable event ledger or
transcript, and no run lock, is created. The in-memory model view and live bus
still operate, children inherit the choice, and there is consequently nothing
for --resume to reopen.
compact runs provider.summarize over the closed prefix when context pressure
crosses the window’s reserve (digest.rs’s compaction_due — used tokens
into the top 15% of a known window; COMPACT_THRESHOLD, 500 KiB of serialised
history, is the fallback when the window is unknown) and AgentLog::can_compact
holds (no pending tool results). It is called at the top of deliberate,
where the agent is ReadyForUser (exchange-ends-ready)
and the gate actually holds — every provider round-trip (step) passes through
here, so long autonomous and headless sessions stay bounded without an
interactive /compact. An exchange-boundary Esc bails before the summarize
request (esc-non-escalating-interrupt).
A successful compaction records ContextEdited { op: Fold, by: Harness } with
the digest and cut. The same fold step removes the prefix spans from the model
view and evicts their resident events, retaining only their byte ranges in
record.jsonl; this is residency following the view, not a second drain rule.
There is no Compacted state or archival cut to infer. A failed attempt records
nothing and sheds nothing. The durable log is appended to, never rewritten.
Auto-compaction is one authority over the same Fold/Drop edit
(context-is-a-projection). The
model reaches the other two: context `survey/`drop/`fold and
transcript let it survey, read back, and shed closed exchanges of its own
choosing, each edit recording ContextEdited with EditAuthority::Model
rather than Harness. The user’s own hand is /rewind <exchange>, which
desugars to the same Drop at EditAuthority::User, sheds queued self-nudges,
and rebuilds the nudge state; /context surveys the transcript without editing
it, the read-only sibling ReplControl::command serves alongside /clear,
/compact, /branch, and /quit.
Avatar::check_disk_warn is the disk half of the same ADR (“Disk: report
and warn only”) — report-and-warn only, never rotation or deletion.
Unconfigured (config::disk_warn_bytes absent, the default) it is a no-op
by construction: no walk, no cost, ever. Configured, it rides the same
ral_epoch the settled-worker and binding-lease sweeps already read,
amortized to once every DISK_WARN_CHECK_INTERVAL (32) calls, at the same
ready boundary attend’s loop walks each pass.
Crossing the ceiling (session log dir + EXARCH_SCRATCH, summed via the
existing resources::dir_size) emits one Forensic::SystemNote, latched until
a later check finds the total back under — one warning per excursion, not
one per boundary.
A fork builds the child Avatar (and, inside it, the child Agent) for
sub-agent spawning through
Shell::fork_session (the flow matrix) rather than
hand-copying fields after a bare Shell::new. It takes the child’s
Capabilities as an argument, so the spawn site owns the authority decision
(the parent’s verbatim, or parent ⊓ base via `policy::narrow`).
The child sets parent: Some(self.agent.clone()) — the strong tree edge that
routes its result and drives the subtree cascade — and enrols itself in the
fleet (Fleet::enrol), joining the shared Arc<Fleet> every node holds.
It snapshots the serialisable fragment of the parent’s lexical scope
(prelude, agent library, every accumulated binding that has a wire form —
Shell::fork_scrubbed drops Value::Handle bindings, and both spawn arms
fork through it, so an identity fork and a wire hatch’s EngineSeed carry
the same scope, agents), its dynamic context (cwd, env,
grants, handlers),
and the installed builtin table, and starts fresh in everything else — fresh
control counters and a freshly-defaulted SessionState, so it holds no
terminal authority (TerminalAccess::Denied, no lease — a sub-agent is not the
foreground agent and can never seize the controlling terminal the TUI owns).
There is no flow-back: the child’s cd, env, and new bindings die with it. An
agent with fuel left may spawn, and each fork hands the child one less unit of
fuel than the parent holds (SPAWN_FUEL = 3 at the trunk; the parent’s own
fuel is never debited, so fuel bounds depth, not fan-out). At fuel == 0 the
prompt drops the spawn family — agents — and the desk refuses
agents `start with the exhaustion text; the desk remains the runtime wall
(spawn-fuel-ceiling) — so a delegation
chain bottoms out by refusal a fixed number of generations down. The fork
mirrors on the bus as Transient::Born / Transient::Died regardless of
remaining fuel.
Avatar::fork_with(caps, returns, name) is the shared fork core — a
returning child passes true; Avatar::branch is
fork_with(self.agent.caps.clone(), false, name) plus inherit_context,
minting a conversing peer tab with the parent’s verbatim
authority (branch-minimal) — returns: false means parent comes back None, so a branch is a root exactly as
the trunk is. A builtin
spawn takes the decomposed path instead: the `start tag’s body leaves
the fork where this run’s Fork door says, and the desk’s agents `start
arm collects it and calls Avatar::assemble at one less unit of fuel
(builtins).
Prompt resolution is shared across the root, identity-fork, and wire-child
paths. Each keeps the unresolved base and applies its own returns,
allow_schedule, and child-fuel bits; the resolver appends Agents
iff fuel remains and Agent iff the child returns. The child’s log bookend
records that fully resolved prompt length, including the filtered index and
late sections, so a child never inherits an already-appended Agent section.
Wire-seat spawn
Both seats spawn in one exchange of the same agents `start
vocabulary; they differ only in where the fork waits. The arm is chosen on a
stated fact — HostServices::kind, a SeatKind::{Identity { scratch }, Wire}
read off the seat when the desk’s capture is built, once per ral call
(agent/shell.rs) — one encoding, so an identity seat without a scratch is
unrepresentable. What varies is the fork tag the
engine mints beside the model’s own spec record, since the reentrancy law
bars a desk handler from holding the &mut Shell a fork needs:
- Identity. The run’s
Forkdoor isFork::Park(nursery), so the builtin body callsShell::fork_into_nurseryand names the slot as`parked <id>. The desk’s arm adopts by id, in the same process. - Wire. A guest engine’s runs carry
Fork::Listen(core/src/engine.rs), so the builtin body mints au64token from OS randomness and callsral_core::hatch::listen_for_hatch(core/src/hatch.rs) with a listening descriptorshell_eval/builtins/guest_port.rshas already bound — the oneAF_VSOCKendpoint exarch opens itself, and Linux-only because a guest port means nothing outside a VM, while core’s half is plain Unix plumbing tested overUnixListenerpairs on the production path. The scrubbed fork is packed into anEngineSeedon the caller’s own thread — aShellnever leaves it — and a thread is left on the socket. The enquiry names`listening [port, token].
The desk’s wire arm dials that port through its Dial capability
(exarch/src/agent/dial.rs — vm_manager-free by construction, a capability
object RootConfig carries, None on every identity trunk), writes the eight
token bytes little-endian, and blocks on one acknowledgement byte
(ral_core::protocol::HATCH_ACK) under the transport’s own deadline. The
listener thread accepts and compares the eight bytes, polling the wake pipe
before every partial read — a dial that does not know them is dropped and the
accept loop resumes, while one that sends only a prefix cannot pin shutdown.
Losing the token race costs a stranger nothing but its connection; stalling
mid-token costs the winner its spawn, which the decision records rather than
defends against. hatch_over spawns current_exe --engine with the connection
on fd 3 and the seed’s fd named by RAL_ENGINE_SEED_FD; the child reads the
framed seed before waiting for Attach, and the parent writes it after
spawn(), so even a seed larger than the channel buffer makes progress. It
writes the ack only once spawn() has returned and the seed has crossed. An
ack therefore means the child already exists and holds its seed. The desk then
adopts the
stream as Seat::Wire and hands the child to the same spawn_async an
identity fork reaches, at fuel = parent - 1.
One exchange, one token, one thread: the whole of failure is local. A refused
enquiry never dialled, so the builtin wakes its listener through a pipe and
joins it, raising the thread’s own reason over the host’s when it has one,
since it was nearer the failure. Past the ack a refusal simply drops the
stream — the child reads EOF on fd 3, and core’s HATCHED table, swept at the
next hatch and again at engine teardown, reaps whatever has since exited. That
sweep is waitpid and nothing lighter: a hatched child closes its seed channel
as soon as it has hydrated, so silence there means started, never stopped. A wire
trunk with fuel > 0 and no dialler is a construction error, refused at
Avatar::root with a sentence rather than discovered by a model calling
agent; off Linux the wire arm refuses in one sentence too.
The enquiring builtin cannot tell which arm served it — both answer the same
roster agents gives every tag. See agents for the
seed’s isolation law and synod for MachineDial, the Dial
over Machine::connect_guest. The `mnemon memory mode
additionally forks
the parent’s AgentLog and imports its model-visible context before assembly
(subagent-memory-modes); AgentLog
drops a pending unanswered assistant tool-call frame when the parent is
mid-dispatch, so the child inherits a request context rather than a dangling
provider protocol. Both memory modes seed the launch prompt through the
child’s inbox, so the prompt enters through the same item path.
Routing the fork through core matters because the builtin table is the easiest
thing to drop. The exarch host builtins — view-text/view-hash, grep-files,
edit-hash, edit-replace, explore-dir, fff, the skill loaders
(builtins) — live in the agent’s own dispatch table
rather than in the environment, and the
view-text-around helper in agent.ral calls view-text. A fork that copied
only the scope and the context would leave the child’s view-text-around
resolving to nothing and falling through to a failed PATH lookup.
fork_session snapshots scope, context, and the builtin table as part of the
flow matrix, so the decision lives in one place and the table cannot be
silently severed at this call site.
digest.rs holds clip and the fixed per-section byte caps for what the
model sees in history: each tool-result section has its own cap
(VALUE_CAP 20 KiB, STDOUT_CAP/STDERR_CAP 10 KiB), alongside separate caps
for opaque error blobs (OPAQUE_CAP) and the history-compaction threshold; a
child’s reply is not clipped, since it reaches the parent as a value through
agents `read rather than as text. An oversize section keeps a head+tail digest
and elides the middle, with a banner nudging the model to scope the query at
its source and re-read in slices — the same rendering the transcript records,
so the user never sees more of a result than the model does. A structured
VALUE now arrives pre-budgeted — the printer spends its own byte budget inside
the value, cutting where a container can still name what it dropped — so clip
is a backstop there, while stdout, stderr, and raw payload strings still meet it
head-on. run_shell here threads to shell-eval.
See also
agents (the role model these nodes realise — one parent
predicate, the conversing trunk vs returning agents),
tools (the one ral tool the
provider sees) and builtins (the harness verbs the
desk answers), frontend (the bus, the inbox, the
fleet doors, and the two frontends), provider,
policy, exarch,
residency (the resident ledger this cascade and the
worker/schedule teardown edge are chapters of),
engine-protocol (why a wire
seat is one engine process, one connection),
agent-and-avatar (why Agent and
Avatar split this way, what the three-copy shape it replaced deleted),
synod (MachineDial, and synod’s own helper surface built over
the wire-seat spawn above).