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):

  • Agent is the Arc. Identity and immutable config, fixed at construction, except status (the avatar’s own register) and children (the spawn site pushes onto it). Held by its avatar, its parent, and the fleet’s by-name/by-id doors.
  • Avatar is the embodiment. Everything only the attend thread touches, plus the Arc<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’s children and the fleet’s roots/names hold Weak, and every walk prunes what fails to upgrade. Avatar’s Drop is the whole of what deregister used 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, and reply are written by the avatar alone and read by everyone else — a process publishing its own status. Agent::status is a Mutex that 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_exchange was never agent state — it is “when did a human or parent last push into this mailbox”, a fact the inbox itself witnesses. It moved to Mailbox, stamped by Mailbox::steer (a human line) and by Agent::message (a parent’s marked note) before the item is pushed, so a park verdict woken by that push already reads engaged. renew as a method is gone with it — Mailbox::steer is one door, not renew-then-drop-then-push.
  • Up is strong, down is weak. A child holds Arc<Agent> to its parent (Agent::parent), so deposit_reply and the scope climb never dangle; a parent whose avatar has gone is still a reachable Agent with a terminated token, not an absence. children holds Weak, so there is no cycle to reason about and a walk prunes what settled. Nothing holds an Arc<Agent> to a descendant — the invariant every reaper closure and every upward result respects by carrying Weak, not Arc.
  • The lock rule. Two per-agent mutexes, status and children; hold at most one at a time. children is locked only to push or to snapshot, never across a walk. status is a single-writer register. parent is an Arc, read lock-free. The inbox → agent order stands unchanged (a park verdict reads agent.rest() under the consumer’s own inbox mutex), and nothing now needs the other direction: steer touches 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 of agent.rs for exactly this reason — reaper closures and upward results must carry Weak.
  • Reading live where a snapshot was load-bearing. HostServices::generation and ::cwd are deliberate snapshots, not staleness bugs — fleet/desk.rs states as much on the field itself (“a desk older than its own /clear refuses to spawn”); anywhere else a future change is tempted to read self.agent.… live instead of a captured field needs the same scrutiny.
  • Tests keyed by literal ids — spawnable_desk, the fleet.rs tests, reply_cancels_live_descendants — became two-agent trees built with agent/testkit.rs’s test_agent and no thread behind them; they read shorter, but a future test reaching for a bare id instead of a held Arc<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::roots rather 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).