An agent is the Arc
An agent existed three times, and the last two bugs fixed were the copies
disagreeing; the fix is not a fourth copy but two types split along who may
touch what. [Agent] holds the public half — identity, the two cancel
doors, mailbox, provider, immutable config, and a small single-writer
Status — behind an Arc the fleet shares. [Avatar] holds the private
half — the log, the seat, the inbox, everything only the attend thread
touches — plus the Arc<Agent> it embodies. An agent is live while its
avatar holds that Arc; nothing deregisters.
The diagnosis
Agent (agent.rs) owned the cancel token, seat, provider, log cell,
parent id, fuel, caps, returns, interactive, search. registry::Entry
(fleet/registry.rs) held copies of the token, reach, mailbox, provider,
name, log dir, and parent — plus the state that lived only there:
generation, consumer, rest, reply, last_exchange. Entries sat in
one HashMap<AgentId, Entry> under one mutex. HostServices (fleet/desk.rs)
snapshotted twenty-odd fields off Agent at desk install, because a handler
could not hold &Agent.
Everything reached an agent through the map by id. AgentRegistry had 34
methods across roughly 2,000 lines; each began with a lock and a lookup that
could fail, so each grew an Unknown… arm. Scope checks walked parent ids
over a flat map. The sync between Agent and Entry was by convention —
register_self, re-register-in-place, “leased iff parented” — and the last
two bugs fixed were the two copies disagreeing.
Looking at which fields got copied is what found the line the type itself
did not draw: Entry and HostServices between them wanted identity, the
two cancel doors, mailbox, provider handle, immutable config, and the small
mutable status. Neither ever wanted seat, nudges, last_input, the warn
latches, or the epochs — the fields the attend loop mutates through
&mut self. An agent has a public half, which anyone may touch and
therefore existed twice, and a private half, which only its own thread
touches and therefore should exist once. The bug was the doubled public
half; the fix is not to share the private one either.
The decision
Two types, along that line (agent.rs doc
comment):
Agentis theArc. Identity and immutable config, fixed at construction, exceptstatus(the avatar’s own register) andchildren(the spawn site pushes onto it). Held by its avatar, its parent, and the fleet’s by-name/by-id doors.Avataris the embodiment. Everything only the attend thread touches, plus theArc<Agent>— so every method the loop calls is plain&mut self, with no lock and no lookup between a call and the field it reads.- Liveness is the avatar holding the
Arc. Nothing deregisters; the parent’schildrenand the fleet’sroots/namesholdWeak, and every walk prunes what fails to upgrade.Avatar’sDropis the whole of whatderegisterused to do explicitly: it clears the avatar’s own armed schedules and records the session-ended bookend, and the last strong reference going with it is what a parent’s or the fleet’s next walk prunes away. - Status has one writer. Of
Entry’s mutable fields,generation,rest, andreplyare written by the avatar alone and read by everyone else — a process publishing its own status.Agent::statusis aMutexthat buys one atomic snapshot per read and computes nothing under the lock; nothing external ever writes it. - The exchange clock is the inbox’s.
last_exchangewas never agent state — it is “when did a human or parent last push into this mailbox”, a fact the inbox itself witnesses. It moved toMailbox, stamped byMailbox::steer(a human line) and byAgent::message(a parent’s marked note) before the item is pushed, so a park verdict woken by that push already reads engaged.renewas a method is gone with it —Mailbox::steeris one door, not renew-then-drop-then-push. - Up is strong, down is weak. A child holds
Arc<Agent>to its parent (Agent::parent), sodeposit_replyand the scope climb never dangle; a parent whose avatar has gone is still a reachableAgentwith a terminated token, not an absence.childrenholdsWeak, so there is no cycle to reason about and a walk prunes what settled. Nothing holds anArc<Agent>to a descendant — the invariant every reaper closure and every upward result respects by carryingWeak, notArc. - The lock rule. Two per-agent mutexes,
statusandchildren; hold at most one at a time.childrenis locked only to push or to snapshot, never across a walk.statusis a single-writer register.parentis anArc, read lock-free. The inbox → agent order stands unchanged (a park verdict readsagent.rest()under the consumer’s own inbox mutex), and nothing now needs the other direction:steertouches only the inbox.
Fleet (fleet.rs) shrinks to { names, roots, lease }: two Weak
indices — the by-name door a spawn claims identity at, the by-id door a
frontend command resolves through a pruning walk from roots — plus the
idle-lease duration every reporting child is armed with at Fleet::enrol.
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. A root
is parent-less, pushed onto roots, reporting to nobody, and a fleet may hold
many (amended 2026-10-18, below).
HostServices (fleet/desk.rs) is Arc<Agent> plus what is per call:
nursery, reply: ReplyCell, emit, acts, principal, generation,
cwd, kind: SeatKind, plus the avatar’s LogCell. schedules and pins
are public agent state and live on Agent. generation and cwd stay snapshots on purpose —
a desk older than its own /clear must refuse to spawn, and the model
starts a spawned child where it was, not where a live cd has since moved
to.
Trunk cancel publication — cancel::publish, the OS-signal path a
sub-agent never needs because its token is reached through the tree, not a
slot — moved to the sites that actually launch a process trunk:
headless::run, headless::converse_settled, and tui::tui_loop::run.
What this deleted
registry::Entry; HostServices as a field-by-field copy; register_self,
register_self_named, and re-register-in-place; deregister; renew and
renew_entry, and the renew-drop-push choreography inside steer/message;
the Unknown* refusal variants and their match arms; is_descendant_of as
a named predicate (folded into the descendant climb); the “absent reads
0” convention for a stale or unknown id; roughly 20 of AgentRegistry’s 34
methods, and the lock-and-lookup prologue every one of the rest used to
open with.
Risks
- A strong edge pointing down would be the one way to break this: if
anything ever holds
Arc<Agent>to a descendant, the weak-child invariant stops meaning what it says. The rule is stated at the top ofagent.rsfor exactly this reason — reaper closures and upward results must carryWeak. - Reading live where a snapshot was load-bearing.
HostServices::generationand::cwdare deliberate snapshots, not staleness bugs —fleet/desk.rsstates as much on the field itself (“a desk older than its own/clearrefuses to spawn”); anywhere else a future change is tempted to readself.agent.…live instead of a captured field needs the same scrutiny. - Tests keyed by literal ids —
spawnable_desk, thefleet.rstests,reply_cancels_live_descendants— became two-agent trees built withagent/testkit.rs’stest_agentand no thread behind them; they read shorter, but a future test reaching for a bare id instead of a heldArc<Agent>is reaching for a pattern this change retired. - The wire. Nothing on it changed; only how an id is resolved on
arrival — a pruning walk from
Fleet::rootsrather than a map lookup.
Amended 2026-09-01: the strong parent: Arc<Agent> this decision landed
raised a follow-on question — could the generation stamp become a
structural check on the tree? It could not: /clear rebuilds a context
without changing the tree, so no edge distinguishes before it from after.
Status::generation and Avatar::admits are gone; the one clock is
bus::inbox’s own clear-epoch, stamped at composition and judged at that
inbox’s own pop. The stamp is carried only by bus::Stamp, the addressed
envelope Mailbox::stamp mints — destination and epoch as one value — so a
message that cannot judge its own staleness (bus::Stamped) cannot be sent
unstamped, stamped from another inbox, or stamped fresh at push time.
HostServices::generation above became ::stamp, not removed — the
bullet’s point, that it must stay a snapshot read at install rather than a
live read at refusal, is now Stamp::is_stale and structural.
Amended 2026-10-02: the public half had grown a second kind of field — the
run’s own settings (interactive, allow_schedule, resume_on_reset, the
Toolset, disk_warn_bytes, egress, dial, the Bureau, the prompt
template and its index), each documented “shared verbatim by every fork” and
copied into every Agent through a twenty-three-field Build. They are not
node state; they are the run’s, and they now live once on
Fleet::launch (fleet::Launch), the Agent keeping only what is its own
(caps, fuel, returns, search, system, provider, token) as plain
pub(crate) fields. interactive itself was two facts — whether a node
converses, which is returns per node, and whether a human types into the
run, which is the launch’s attended — and RootConfig::trunk: Trunk::{Headless, Attended, Embedded} names the three ways a trunk is driven, so synod’s
embedded trunk parks on its fleet with no policy hook. The trunk-only
lifecycles left the node too: the run lock is the launcher’s, and --resume
is its own constructor, Avatar::resume. A reply is deposited on the agent by
every node alike, roots included, so the loop returns an AgentOutcome and
nothing else.
Amended 2026-10-18: a root is a session, and a fleet holds any number. The
run’s settings were the launch’s; the recipe for a root joins them. Launch
gained trunk, caps, fuel, search, sessions_root and genesis: Option<Genesis> — installers, cwd, terminal capabilities and the run’s
scratch, None on a wire host — and Avatar::trunk split in two:
Fleet::found seats the first engine and settles the launch, Avatar::hatch
makes one root of it. Launch::prompt_for is the one prompt resolver, shared
by roots and children. Resume reopens every root not ended by the new
Forensic::SessionClosed, each under the name SessionStarted now records.
/new and /branch hatch roots at runtime (Read::Hatch), /close writes
SessionClosed, /quit ends every root, resumable. The engine-side branch
door went with the special case it served: BranchOrder, the
_exarch-branch builtin, the Agents::Branch enquiry and Avatar::fork_with are
deleted, Avatar::child_of is the one child builder, and a root is never a
fork. Avatar declares agent last, since the fleet’s Weak must outlive
the seat’s teardown and the log’s SessionEnded when a root’s death ends the
process. In the frontend Tabs has no root and FleetBus no mailbox; every
command acts on the focused tab’s agent.
Supersession
reply-parks is superseded only in the
mechanism it named, not in the decision it made: reply still deposits and
parks rather than ending the agent, the parent still fetches with
agents `read <name>, and the roster’s state/idle-s derivation is
unchanged. What no longer holds is the sentence “a returning agent’s reply
deposits its value on the agent’s own registry entry” — there is no
registry entry; the value lives on Agent::status.reply, written by the
avatar and read by anyone still holding the Arc. A pointer is added at
that entry’s top.
See also
agents and agent (rewritten against this shape), residency (the resident ledger this tree is a chapter of), engine-protocol (landed the same week, on the enquiry side of the same fleet).