feat(agents) 2-5/5: the Host → Agent → Session tree, vocabulary, wire and control - #189
Conversation
|
Too many files changed for review (204 files, 100 file limit). Bypass the limit by tagging |
|
Important Review skippedToo many files! This PR contains 204 files, which is 54 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to Pro+ to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (204)
You can disable this status message by setting the Comment |
The page rendered `Runtime::workers()` flat and called each row a host — its own comment admitted the collapse. That was true only while a machine advertised one worker; a machine now declares one agent per `harness × workspace`, so the list was agents with the host level taken out of it, and a fleet you cannot see the shape of is one you cannot manage. The tab is now the topology the advert is a projection of (spec §2.4): - **The hosts this machine runs come first, always.** They are resolved from config, not from what started, so a host that is declared but idle is still listed — that is the state where the operator most needs to see it. A running primary overrides its identity from the live observation, since `[host].workspace` is usually blank and only it has resolved that. - **Each host carries its agents**, from two sources that are deliberately not merged: declarations for a local host (an agent exists because it is written down), and the roster for a remote one — because the host link does not exchange declared agent lists yet (plan §D1). A remote host says so on its own row and again in its preview rather than passing this hub's roster off as that machine's declarations. - **The cursor walks both levels.** A host row previews the machine — capacity, readiness, budgets, read from whichever entry probed it — and an agent row previews the thing a dispatch targets, with the role toggles. **Roles are assigned per agent and persisted.** `set_roles` moves the roster this process holds, and the roster is rebuilt from the declarations at every launch — so assigning a role that way was a change the operator watched take effect and then lost. Every toggle now writes the declaration through `declare_agent` *first* and moves the live roster second; a failed write makes no live change either, because a UI showing a role the file does not have is worse than one that refused. An agent the roster knows but nothing declared — the migration seed — is declared here from what it reports, since a role assigned to something nobody wrote down has nowhere to persist to. Renaming and removing follow the same rule. **The v1 capability split is enforced in the UI**: the local host offers agent creation (`n`) and role editing; a remote host is operator-read-only, because its agents are declared on that machine. Orchestrator dispatch to a remote agent is untouched — this is only about affordances in this terminal. Shared, so the list and the binder cannot disagree about which address a `[[hosts]]` section will bind: `medulla::config::local_hosts` now owns the device-local host resolution the TUI binary derived privately, and `medulla::ui::hosts` owns the tree itself. Co-Authored-By: Claude <noreply@anthropic.com>
The rail concatenated two lists — lanes folded from task events, and the
operator's own harnesses under a `── your harnesses ──` divider — which is
exactly the split the agent/session redefinition removes. A task *is* an
agent session; they differ only by origin. And lanes come from traffic, so
an agent nobody had dispatched to had no row at all.
- A3: `RailRow::{Agent(Lane), Agent(Sub), NewHarness, HarnessSeparator,
Harness}` becomes `RailRow::{Host, Agent, Session, NewAgent, Lane}`.
Agents come from `[fleet].agentDeclarations`, so a declared agent with
zero sessions still renders; a lane nothing declares still gets a row.
Sessions are one row type under their agent whatever started them, and
the divider is gone. Host rows appear only once a remote host exists.
`Lane` is the residue for what is not an agent — the orchestrator's own
conversation, the functions divider, a `+N more` counter.
- A2: `+ New agent` replaces `+ New harness`. It reuses the harness picker
(now carrying a `PickerPurpose`) for harness type × workspace dir, then a
name prompt, and persists through `declare_agent`. Starting a session in
an undeclared directory offers the same flow inline.
- A4: `Ctrl-T` on a row that names an agent opens a session *of that agent*
— its declared harness in its declared workspace, named by the operator,
`SessionOrigin::User`, with the managed/unmanaged choice kept.
- A7: the orchestrator's conversation carries a "sessions started" block;
clicking an entry moves focus to that session and the rail follows.
`Ctrl-O` returns — scoped to "not already on the orchestrator" so the
chord keeps releasing the mouse where drag-select is actually wanted.
Session → agent resolves by the id the hub already filed the task under for
a dispatch, and by `harness × workspace` for an operator-started PTY. A
session in an undeclared directory stays listed rather than hidden.
Co-Authored-By: Claude <noreply@anthropic.com>
The Agents rail was written before the Hosts tab grew the shared `Host → Agent` projection, so it built its own host and agent levels out of declarations plus the event fold. Two derivations of one tree is two answers to "what exists", and the rail left a follow-up note saying so. The rail's host and agent levels now come from `medulla::ui::hosts::host_rows` — the same call the Hosts tab renders — with the folded lanes placed onto the agents it produces. A lane the tree does not know (a backend-side roster agent, a peer session) still keeps a row, so nothing that used to be visible disappears. Session rows are unchanged: they are the rail's own level, dispatched ones resolved by the roster id the hub filed them under and operator-started ones by harness × workspace. Progressive disclosure now reads off the projection: host headers appear once it holds more than one host, which is also when a registered remote peer first becomes a machine of its own rather than a bare lane. Two fixes fall out of making the projection load-bearing for both tabs: an agent declared with no host id is claimed by the machine looking at it (it was rendered by the rail and dropped by the Hosts tab), and a peer that reached the registry twice is one agent, not two. Co-Authored-By: Claude <noreply@anthropic.com>
Three things an operator hit on the Agents tab. The pane leaked the orchestrator's stream onto rows that are not it. `Selection::lane_index` defaulted to lane 0 — the orchestrator's — for every row with no lane of its own, so selecting `+ New agent`, a host header, or an agent nothing had been dispatched to showed the orchestrator thinking, attributed to a row that had not thought anything. The fallback is gone: `lane_index` is an `Option`, `lane()` answers `None`, and the new `summary` module renders what each of those rows actually is — the agent's identity, harness, workspace, roles and session count; the host's reach and agent count; what each action row will do. `+ New session` was unreachable. `open_new_session` shipped with the tree bound only to `^T`, so only an operator who already knew the chord could find it. `RailRow::NewSession` closes each agent's group, under its sessions, for the agents this machine declares — Enter and a click open the same named, user-owned flow. Spawned sessions were listed in one block for the whole conversation. They are attributed to the user turn that caused them — the event stream already carries both halves, a `User` opening a turn and every `TaskStart` until the next one belonging to it — and drawn under that turn. Sessions this stream cannot account for keep a trailing group rather than vanishing. The click-through is unchanged in kind: still keyed by task id through `focus_session_for_task`, but the hit map is now one slot per drawn row, since the entries are no longer contiguous. Co-Authored-By: Claude <noreply@anthropic.com>
Three tests stand a session up by pointing the codex bin at /bin/sh, which Windows has no equivalent of — CreateProcessW cannot find it, so they fail there while passing everywhere else. This module's sibling pty tests already carry the same guard for the same reason; these were written without it. The row model they exercise is portable; only this way of standing a session up is not. Co-Authored-By: Claude <noreply@anthropic.com>
Review follow-ups on the Host → Agent → Session tree (#182). The two that change what an operator can do: - `^T` acted on any selected agent row, including one declared on another machine, so it started a *local* process for a remote agent — which the rail then listed as an orphan, because it resolves sessions against the local declarations. `declaration_for` now filters by host, and the refusal names the machine to open it on instead. - A session started from a custom preset was compared to declarations by the CLI underneath it (`claude`) rather than by the preset id the declaration records (`deepseek`), so every preset-backed session was filed under no agent. The preset id is carried through the launch and `SessionRow::harness_id` is what the rail matches on. The rest: - `on_orchestrator_lane` matches a lane's own row explicitly. `RailRow:: Lane` also wraps the overflow control and the `── functions ──` divider, and the divider names no lane at all — it fell through to the "no lanes yet ⇒ the orchestrator is all there is" answer and would have claimed the orchestrator's composer. - One rule for the no-config-file path: an edit applies for this run, in the declaration list as well as the roster, and says how long it lasts. Roles updated only the roster (so the row redrew with the old ones), a rename was silent, and undeclaring refused. A seed with no workspace is now refused like one with no harness — an agent is `harness × workspace`, and a declaration missing half of it is one no session can be opened from. - `local_hosts` returns unique addresses; two sections that resolve to one cannot both bind, so listing both drew a host that will not be there. - A remote host's detail row picks a *probed* entry rather than the first. - `mod.rs` keeps the wiring; the host projection moves to `projection.rs`. - Clicking either action row retargets the watch, so a click arriving from a task row stops that stream. - `d` on the Hosts page clears the role focus before reshaping the tree. - The role list never draws past the rows the pane gave it. Co-Authored-By: Claude <noreply@anthropic.com>
Plan §A6/F1: a harness is a *type* — the value on an agent that says which
coding CLI its sessions run — not an entity. Everything the operator
touches is an agent or one of its sessions, and the old vocabulary was the
route by which the host ≡ worker ≡ agent collapse kept leaking back in.
Types and fields: `HarnessControl` → `SessionControl` (control is
per-session), `LocalHarnesses` → `LocalSessions`, `HarnessPicker` →
`AgentPicker`, `App.harnesses` → `local_sessions`, `harness_pane_session`
→ `pane_session`, `selected_harness_session` → `rail_session`,
`own_harness_lines` → `own_session_lines`, `harnesses_waiting` →
`sessions_waiting`, `Cmd::{HoldHarness,HandOffHarness}` →
`{HoldSession,HandOffSession}`, `BaselineSource::HarnessLaunch` →
`SessionLaunch`, and the take/hand-back/toggle/release entry points with
them. `ui/app/harness_control.rs` and `render/harness_modals.rs` follow
their contents to `session_control.rs` / `session_modals.rs`, as do four
integration-test files.
Copy: every status line, prompt, modal, pane title, help line and toast
that called a session a harness now calls it a session — including the
hand-back and take-control questions, the kill confirmation, and the key
hint bar. The `/harness` command is now `/session` (old spelling kept as
an alias, with a test pinning it), and Routing's `Harnesses` page is
`Harness Types`. The rail title counts the agents on the tree instead of
the lanes traffic happened to fold, so a quiet machine with three declared
agents no longer reads "Agents · 0".
Kept as "harness" where it genuinely means the type or the CLI process:
`HarnessProvider`/`HarnessChoice`, the `harness` field on a declaration,
`[harness]` config, custom presets, `harness_pty` and the pane internals
that talk to the child, and every wire name (`HarnessControlFrame`,
`HarnessHandoff`, `harness_select`) — hub and protocol are untouched
beyond two stale doc cross-references.
Docs: TERMINOLOGY.md now defines Agent as a declaration, Harness as a type
and not a level, Host → Agent → Session as the chain, and Session as the
runtime instance with origin and owner; README and workflows.md follow.
Co-Authored-By: Claude <noreply@anthropic.com>
Review follow-ups on the vocabulary pass (#183). `took_control_by_attach` was only ever set to `true`, and nothing clears it on release — so a `true` left by an attachment that really did take a session survived into the next one. If that next session was already the operator's, a pty write failure read the stale flag and handed it to the orchestrator, taking away a session they had been holding before they focused in. It is now assigned from `took` on every attachment. `SessionControl`'s doc defined "unmanaged" as a session *born* user-held. It is not a kind of session: `claim_idle` reads the current holder, so a dispatched session the operator takes is unmanaged for as long as they keep it. The doc now describes current control and leaves origin to `SessionOrigin`. And the containment diagram in `TERMINOLOGY.md` gets a language on its fence, for MD040. Co-Authored-By: Claude <noreply@anthropic.com>
…erved a task
register_agents gains a hosts[] block (one entry per host this hub fronts,
kind derived from the locally declared hosts, address/name/resources only
where real values exist) and each agent now carries its own hostId plus
maxSessions. Task results report the sessionId that served them, which the
backend's ManagerTaskEntry.agentSessionId already expects.
metadata.workspace deliberately stays a bare path string: the backend parses
both shapes, so {path,type} buys nothing today and ships with phase D.
Control and handoff keys are byte-identical, pinned by assertions — the
backend's control folds depend on them exactly as they are.
Co-Authored-By: Claude <noreply@anthropic.com>
The library reserves AgentDescriptor.hostId for a LOCAL agent — 'only meaningful when workspaceId is absent' and 'must NEVER be set on a harness-backed agent', whose host is derived by walking up from its workspace. Setting it on every agent made the server take its 'a supplied workspaceId or hostId always wins' early return and skip synthesizing a workspaceId from metadata.workspace, orphaning every agent from the agent -> workspace -> harness -> host chain: host_list still rendered the topology, but placement answered 'no agent inside <host> is available (none declared there)' and no task could be dispatched. The host reaches the wire once, in the hosts[] block. An agent with no workspace keeps hostId — it has nothing to walk up from, and that is the case the field exists for. Co-Authored-By: Claude <noreply@anthropic.com>
Phase E of the agent topology: dispatch candidacy and the control
semantics around a session an operator is holding. Everything here is
exercisable on one machine — declare two agents, dispatch, take a
session, watch the work route around it, hand back, see the result.
Candidacy (E1) is now two independent rules that only coincide today.
A hold is on a *session*: `claim_idle` already refuses to hand a
user-owned session to the orchestrator, so reuse is consulted first and
a person working never makes a dispatch fail. Serialization is on the
*checkout*: under `strategy: checkout` an agent's sessions share one
working tree, so a fresh session cannot start beside the writer that is
there — it queues. `PtyManager::operator_hold(cwd)` ("is this workspace
held") is replaced by `sessions_in(cwd)`, a neutral query, with the
policy stated where the strategy lives. Under `worktree` (§G) rule 2
simply stops applying, with no control logic to revisit. F3 still owns
serializing two *orchestrator* sessions in one checkout, which main
allows and which is a scheduling change, not a control one.
A mid-turn takeover suspends instead of discarding (E3): the fold, its
events, usage and workspace context are retained, everything already
written is folded out first, and the task stays open. Held time does not
accrue against the worker's idle ceiling, and the worker announces the
hold so the hub's no-progress window pauses too (E4) — gated exactly
like link liveness, so a worker that dies mid-hold is still reaped once
the session comes back. On hand-back the runtime runs a *fresh* turn in
that same session (E5), prompted with the original instruction plus a
directive to review the session history and workspace state and either
report the finished work or complete it; its answer is emitted as the
pending task's result, under the same task id, because it is the same
call. The blanket `harnessHeld` refusal is retired (E6): the only path
left to it is a queue that outlived the caller's budget, which keeps its
frame byte-identical so the backend's retry stays a retry. Taking
control is gated to local sessions and says so by name (E7).
Control state is no longer advertised at all: `control`, `controlReason`,
`controlSince` and the handback brief are per-agent keys describing a
per-session fact, and a backend folding them by `agentId` would mark
every task on an agent as held when a person took one session. The local
state and every behaviour it drives are unchanged; only the wire is
quieter. That makes the hand-back turn the *only* way a held in-flight
task reaches a result, so a dispatch that meets a person now always ends
in a real result or a real error — never in silence.
Co-Authored-By: Claude <noreply@anthropic.com>
…a slot Both the networked and embedded daemons capped concurrent tasks at 2, so a third dispatch waited on a semaphore permit even when it targeted a different agent in a different workspace — where nothing could collide. That cap predates declared agents: it was the only guard when a machine was one worker with one implicit session. The limits that own the real hazard now sit at its grain: per-agent max_sessions derived from the workspace strategy, and the checkout serialization that keeps a second writer out of a tree someone is in. A host-wide count knows about neither, so it could only delay work that was already safe. The semaphore stays as the accounting behind active_count, and an operator can still set concurrency to impose a real cap on a small machine. Co-Authored-By: Claude <noreply@anthropic.com>
…apse the hand-back instruction
- projection.rs deduped undeclared workers with raw ids while the loop above
claimed them with trimmed ones, so a padded agent_id listed the same worker
twice — the duplicate that loop exists to prevent.
- SessionRow::harness_id() is a join key; it now trims once at the source
rather than leaving every caller to trim defensively.
- render/agents/mod.rs and render/mod.rs held behaviour; moved to child
modules, per the repo's mod.rs rule.
- the hand-back prompt is one line with text after {instruction}, so a
newline in it dropped the 'do not redo it' tail entirely — collapsed now.
- covers the render-driven remote-session lifecycle.
Co-Authored-By: Claude <noreply@anthropic.com>
db3ec0c to
032bf9e
Compare
#187's pointer tests were written against the pre-sweep prompt, which said "You still have this harness". The sweep renamed the thing the operator holds, so the question now reads "...this session" and four of these assertions were looking for a string nothing prints. One of them was worse than a failure: the same literal appears under a `!`, so the test asserting that clicking a session's *own* row asks nothing was passing because the text it searched for could never be found — it would have passed just as well if the question had been asked every time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Overview
Layers 2–5 of the agents/hosts redefinition. Layer 1 landed in #181; the rest merged into their stacked base branches rather than
main(auto-delete was off, so no retarget fired), so this carries the remainder in one piece.Harness is retired as an entity. It survives only as an agent's type. Agents become declared identities, sessions nest under them, and the UI is one
Host → Agent → Sessiontree rendered by two tabs.Needs no backend change — the wire shape here has been accepted since the orchestration line merged.
Changes
The tree, in two lenses
+ New agentdeclares one;+ new sessionsits under each agent.declare_agent. Local host editable; a remote host is operator-read-only, since its agents are declared on that machine. Orchestrator dispatch to it is unaffected.host_idwas drawn by the rail and dropped by the Hosts tab, and a peer registered twice produced duplicate rows.Vocabulary
HarnessControl→SessionControl,HarnessPicker→AgentPicker,/harness→/session(alias kept), Help's "Harnesses" → "Sessions",TERMINOLOGY.mdrewritten. "Harness" remains only where it means the type or the CLI process.Wire
register_agentsgains ahosts[]block and per-agentmaxSessions; results report thesessionIdthat served the task, which the backend'sManagerTaskEntry.agentSessionIdalready expected.hostIdis emitted only for an agent with no workspace — the library reserves it for a local agent and forbids it on a harness-backed one, and setting it universally made the server skip synthesizing aworkspaceId, orphaning every agent from theagent → workspace → harness → hostchain:host_listrendered the topology while placement answered "no agent inside <host> is available" and nothing could dispatch.Control
checkout, a session that must be created waits for the tree rather than starting beside the person.harnessHeldrefusal is retired, with the frame kept byte-identical on the one path that still reaches it (a queue outliving the caller's deadline) — the backend treats it as retryable, so a terminal error there would strand the task.Testing
cargo fmt --check,clippy --locked --all-targets -D warnings,check,buildclean; ~3925 tests passing.Pre-existing failures unrelated to this branch, verified against clean trees: 2 ×
daemon::providers::*idle-watchdog (pass in isolation, fail under parallel load) and 3 ×worker::pty::tests::session::*launch_root*(macOS/varvs/private/var).Review history
This content was reviewed as #181–#185 (a five-way split) and carries every fix from those rounds — 7 findings on the model layer, 12 on the tree UI, 3 on the vocabulary sweep, plus a Windows
#[cfg(unix)]fix. A final round of five (two trim-consistency fixes, amod.rsbehaviour extraction, added remote-session coverage, and collapsing newlines in the hand-back prompt so its "do not redo it" tail cannot be truncated) is being pushed onto this branch shortly.Two findings were rejected with evidence rather than implemented: an identity fix that would have clamped every user session to
Orchestrator, and an overflow-row claim whose stated mechanism was unreachable — the fix was still right, for a different reason, and the code says so.Notes
Deferred deliberately: inbound session targeting (needs a backend field), remote hosts sharing their declared agent lists,
strategy: worktree, and remote takeover. Declarations whose workspace differs from their host's are refused at start-up rather than silently running elsewhere —[[hosts]]is the supported way to bind one address per directory until a task frame can name an agent.🤖 Generated with Claude Code