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
/branch is a root exactly as the trunk is: parent-less, pushed onto
roots, reporting to nobody.
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.
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).