diff --git a/README.md b/README.md index 1779f1f8f..b46b56a5d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **One terminal. Every agent you have. Working at once.** -Claude Code, Codex, and OpenCode are remarkable at running one task deeply. Medulla is what runs a hundred of them. It decides what work to hand out, places each piece on a harness that can do it, streams back what every one of them is doing, and keeps a live picture of the whole operation in front of you. +Claude Code, Codex, and OpenCode are remarkable at running one task deeply. Medulla is what runs a hundred of them. It decides what work to hand out, places each piece on an agent that can do it, streams back what every one of them is doing, and keeps a live picture of the whole operation in front of you. Fleets with everyone. @@ -43,14 +43,14 @@ Prebuilt binaries ship for Linux (x86\_64, aarch64), macOS (Apple Silicon), and **Repositories it understands.** Point Medulla at your projects once. It writes a short profile for each and uses it to route work to the right place, rather than guessing from a directory name. -**Plans that actually run.** A workflow is a saved, multi-step plan whose steps each run as a real harness session — with parallel branches, and approval gates where a human has to say yes. Ask for one in plain words and an agent will build it for you. +**Plans that actually run.** A workflow is a saved, multi-step plan whose steps each run as a real agent session — with parallel branches, and approval gates where a human has to say yes. Ask for one in plain words and an agent will build it for you. **Small surface, low spend.** The bulk of your fleet's output never reaches the orchestrator's context. It reasons over a distilled, current picture, so what you pay orchestrator rates on stays small however much is running underneath. ## Documentation -**Routing › Harnesses** also manages named OpenRouter-backed presets that reuse -Claude Code or Codex as the coding harness. Presets select an OpenRouter model +**Routing › Harness Types** also manages named OpenRouter-backed presets that +reuse Claude Code or Codex as the coding CLI. Presets select an OpenRouter model and fleet host while referring to `OPENROUTER_API_KEY` by environment-variable name only; restart the local host after saving one. See [`config.example.toml`](config.example.toml) for the complete shape. @@ -60,7 +60,7 @@ Full documentation: **[tinyhumans.gitbook.io/medulla](https://tinyhumans.gitbook * [Workers and Sessions](https://tinyhumans.gitbook.io/medulla/features/workers-and-sessions) — capacity, threads, and what survives. * [Workflows](https://tinyhumans.gitbook.io/medulla/features/workflows) — authored multi-step plans and their runs. * [MEDULLA.md Workspace Profiles](https://tinyhumans.gitbook.io/medulla/features/workspace-profiles) — telling the orchestrator what a repo is. -* [Orchestrator Routing](https://tinyhumans.gitbook.io/medulla/features/routing) — cognitive tiers, harness selection, strategies. +* [Orchestrator Routing](https://tinyhumans.gitbook.io/medulla/features/routing) — cognitive tiers, harness-type selection, strategies. * [Token Efficiency and Budgets](https://tinyhumans.gitbook.io/medulla/features/token-efficiency) — small surfaces and enforced budgets. Building on Medulla, or running it yourself? Everything technical — the TUI in depth, the CLI, worker daemons, configuration, architecture, and the SDK — is in **[Developers](https://tinyhumans.gitbook.io/medulla/developers)**. @@ -73,7 +73,7 @@ Request access and tell us what you are orchestrating. ## Why an orchestrator -Ask a harness to coordinate other harnesses and you hit the same quiet failure mode everywhere: the orchestrator is just another model with a transcript, and every harness it manages writes into that transcript. Accuracy degrades well before the context window fills. An orchestrator that reads raw fleet traffic stops scaling at a handful of agents — long before it runs out of room, it stops being able to think. +Ask a coding agent to coordinate other coding agents and you hit the same quiet failure mode everywhere: the orchestrator is just another model with a transcript, and every agent it manages writes into that transcript. Accuracy degrades well before the context window fills. An orchestrator that reads raw fleet traffic stops scaling at a handful of agents — long before it runs out of room, it stops being able to think. Orchestration is becoming the dominant pattern in agentic systems, yet it has been running on architectures designed for chat. A chat model manages one thread. An orchestrator has to hold an operation in its head: agents in flight, work being decomposed and delegated, results streaming back, decisions made continuously. Medulla is built for that. diff --git a/docs/TERMINOLOGY.md b/docs/TERMINOLOGY.md index 08401f282..8cadd1650 100644 --- a/docs/TERMINOLOGY.md +++ b/docs/TERMINOLOGY.md @@ -14,25 +14,35 @@ system works on its behalf. ## Agent -A connected worker that executes **tasks**. Agents live inside a **workspace** on -a **host**, are surfaced through a **harness**, and are listed in `agent_list`. -Each agent has a set of **tools**, an MCP server inventory, and a health snapshot +A **declared** working identity on a **host**: a `harness` type × **workspace** +directory, written down in `[fleet].agentDeclarations` and carrying an `agentId`, +an optional name, `roles`, and a workspace `strategy`. Agents are declared, never +discovered — an agent exists because somebody wrote it down, not because a +process happens to be running. One host runs as many agents as you declare. + +The orchestrator delegates **tasks** to agents and lists them in `agent_list`; +each agent has a set of **tools**, an MCP server inventory, and a health snapshot (consecutive-ok / consecutive-failed). An agent is **idle** when it has no running -tasks and **busy** otherwise. The orchestrator delegates to agents; a manager -_manages_ them. +**sessions** and **busy** otherwise. A manager _manages_ agents. ## Harness -A runtime environment adapter — the layer that boots, supervises, and -communicates with a coding assistant CLI. Medulla supports several harness kinds: +**A type, not a thing.** `harness` is the attribute on an agent that says which +coding-assistant CLI its sessions run — `claude`, `codex`, `opencode`, or a +custom preset. It is a value in a dropdown; it is never an entity in the model, +never a level in the containment chain, and never a noun in the UI (the thing an +operator interacts with is an **agent** or one of its **sessions**). -| Harness | Transport | -| ----------- | ------------------------------------------------------- | -| Claude Code | ACP (Agent Client Protocol) over stdio, or legacy JSONL | -| Codex | ACP over stdio | -| OpenCode | ACP over stdio | +In code it also names the runtime adapter that boots, supervises, and talks to +that CLI: -A harness surfaces a **status** (idle / running / stopped), a **task board** +| Harness type | Transport | +| ------------ | ------------------------------------------------------- | +| Claude Code | ACP (Agent Client Protocol) over stdio, or legacy JSONL | +| Codex | ACP over stdio | +| OpenCode | ACP over stdio | + +The adapter surfaces a **status** (idle / running / stopped), a **task board** (tracked tasks with status open → active → blocked → done / cancelled), and an **event stream** (instruction queued, cycle start/end, task-board changes). The public wire shapes live in the `harness_contract` module and are versioned @@ -40,22 +50,32 @@ independently of any implementation. ## Host -A machine in the fleet — a physical or virtual environment that runs one or more -**harnesses**. A host is declared (not probed) and carries resource metadata -(CPU, memory). It is the top of the containment chain: +A machine, local or remote — the environment the agents declared on it run in. A +host is declared (not probed) and carries resource metadata (CPU, memory). It is +the top of the containment chain: +```text +Host → Agent → Session ``` -Host → Harness → Workspace → Agent -``` + +The local host is always present; a remote host is added by tiny.place address +and contributes the agents declared over there. This tree is what both the Agents +tab and the Hosts tab render, and its union is what the hub advertises to the +backend — one projection, rendered twice. + +*(The legacy `[fleet]` capacity snapshot still carries an older +`Host → Harness → Workspace → Agent` chain in its own types. That describes +declared capacity, not the entity model above.)* ## Workspace -A filesystem directory exposed by a **harness** on a **host**. A workspace is -where agents read, write, and run code. Each workspace can carry a `MEDULLA.md` +A filesystem directory an **agent** works in, declared as part of that agent +together with its `strategy`: `checkout` (every session of the agent shares the +directory, so they run serially — the v1 default) or `worktree` (a carved +per-session copy, so they run in parallel — a follow-up). A workspace is where +agents read, write, and run code. Each workspace can carry a `MEDULLA.md` **profile** — a short frontmatter + prose summary that tells the orchestrator -what the directory _is_ and how to route work over it. Workspaces are registered -in the fleet configuration; without a registration entry the orchestrator cannot -place work there. +what the directory _is_ and how to route work over it. ## Hub @@ -75,8 +95,23 @@ tool calls and agent delegation are internal to the cycle. ## Session -A conversation thread between the user and the orchestrator. Sessions come in -two orthogonal axes: +**An agent session** is one running instance of an **agent** — what a **task** +actually executes in, and the row under an agent on the Agents rail. It carries a +`sessionId`, its launch anchor and workspace context, and two facts that are +independent of each other: + +- **`origin`** — `orchestrator` (auto-created by a dispatch, labelled from its + task) or `user` (opened from the UI and named by the operator). Origin never + changes. +- **`owner`** — who may drive it right now. Ownership moves: `ctrl-g` takes a + session from the orchestrator, handing it back returns it, and dispatch skips + any session the operator holds. + +A task **is** an agent session; the two differ only by origin. Sessions are never +roster entries — only their control state rides the advert. + +The word also names the transport-level conversation the SDK keys by +`(conversation × provider)`. Those come in two orthogonal axes: - **Class:** `Bounded` (one turn — a single cycle) or `Unbound` (long-lived, spanning multiple cycles). @@ -131,7 +166,7 @@ orchestrator match tasks to agents by what they can reach, rather than guessing. Placing a **manager** at a specific **host** + **workspace**. A deployment is the concrete instantiation of the fleet's declared containment chain. The orchestrator selects a host and workspace from the fleet registry, spawns the -manager there, and the manager then picks a harness and begins delegating. Once +manager there, and the manager then picks an agent and begins delegating. Once placed, a deployment is fixed for the cycle — a manager cannot move to a different host or workspace. @@ -140,8 +175,8 @@ different host or workspace. A saved, multi-step **directed graph** definition, usually acyclic but allowed to contain bounded loops (see the `loop` node). Each step is a node — triggers, agent dispatches, transforms, code execution, HTTP requests, and -more. An `agent` node runs as a real **harness** session (Claude Code, Codex, or -OpenCode). Workflows are authored as JSON files, stored in layered directories +more. An `agent` node runs as a real **agent session** on the harness type it +names (Claude Code, Codex, or OpenCode). Workflows are authored as JSON files, stored in layered directories (personal + per-repository), run through the vendored `tinyflows` engine, and surfaced in the TUI's Workflows tab with a canvas, run overlay, and copilot. @@ -170,16 +205,16 @@ with `medulla init` and registered with `medulla workspace add`. ## Provider -A coding-assistant CLI that a **harness** wraps. The three supported providers -are `claude` (Claude Code), `codex` (OpenAI Codex), and `opencode`. A provider is -selected per-task; the daemon spawns the CLI as a subprocess and communicates -over ACP or legacy JSONL. +A coding-assistant CLI — the same axis as an agent's **harness** type, seen from +the process end. The three supported providers are `claude` (Claude Code), +`codex` (OpenAI Codex), and `opencode`. The daemon spawns the CLI as a subprocess +and communicates over ACP or legacy JSONL. ## Daemon A long-running background process (`medulla daemon --headless`) that listens for -inbound **task frames** from the **hub**, spawns **providers** through -**harnesses**, and streams results back. One daemon = one **workspace**; a fleet +inbound **task frames** from the **hub**, spawns **providers** through their +harness adapters, and streams results back. One daemon = one **workspace**; a fleet is N daemon processes, not one daemon with N directories. ## TUI diff --git a/docs/workflows.md b/docs/workflows.md index 22fc563dd..38f2e98ac 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -1,11 +1,11 @@ # Workflows -A Medulla task is one instruction handed to one harness. A **workflow** is a +A Medulla task is one instruction handed to one agent. A **workflow** is a saved, multi-step plan: a directed graph whose `agent` steps each run as a real -coding-harness session — Claude Code, Codex, or OpenCode — in the order and with -the parallelism the graph declares. The graph is usually acyclic, but it may -contain a **bounded loop**: a `loop` node repeats a section until its -`max_iterations` cap or its `condition` says stop. +agent session — on Claude Code, Codex, or OpenCode — in the order and with the +parallelism the graph declares. The graph is usually acyclic, but it may contain +a **bounded loop**: a `loop` node repeats a section until its `max_iterations` +cap or its `condition` says stop. The engine is [`tinyflows`](https://github.com/tinyhumansai/tinyflows), vendored under `vendor/tinyflows` (see [vendoring.md](vendoring.md)). Medulla supplies the @@ -160,7 +160,7 @@ is meaningless, or wrong, on another. Name both when you mean both. A harness that is not one of the three built-in CLIs is taken as a custom harness preset id — the ones this machine has configured are listed by -`workflow_host` and in the TUI's Routing → Harnesses screen. Whether the *worker* +`workflow_host` and in the TUI's Routing → Harness Types screen. Whether the *worker* that runs the step exposes that preset is only answered when it runs. `harness` must be written plainly, never as a `=`-expression. Which binary and @@ -384,7 +384,7 @@ Workflows is a top-level tab: a sidebar, a canvas, and a copilot. it never reached are dimmed, and the inspector shows the node's duration and any diagnostics. - **The copilot** (`c`) is a conversation that edits the graph. Ask for a change - in plain words; a real harness session makes it with the MCP tools below, and + in plain words; a real agent session makes it with the MCP tools below, and the graph is then re-read from the store so the transcript reports what actually changed rather than whatever the agent said it did. @@ -423,7 +423,7 @@ compatible: - A worker's capability probe now advertises `workflows` — the ids it has installed, with names, descriptions, and step counts. - A task frame may carry a `workflow` field. Naming one makes the worker run that - saved graph instead of handing the frame's `text` to a harness; the text + saved graph instead of handing the frame's `text` to an agent; the text becomes the trigger payload. The ack, the reply, the correlation, and the work-snapshot attachment are all the ordinary ones, so an orchestrator that knows nothing about workflows still sees a task it dispatched and a task that @@ -457,7 +457,7 @@ request that changes what this host holds. Everything is served from the same layered store the Workflows tab, the `medulla workflow` subcommand and the MCP tools read — a socket `get` and `medulla workflow get` are one implementation, so they cannot drift. `copilot` is -not a read: it is a whole authoring turn on this machine's own harness, with the +not a read: it is a whole authoring turn on this machine's own agent, with the `medulla-workflows` tools attached, and its result is derived from re-reading the store afterwards rather than from what the model said it did. @@ -486,7 +486,7 @@ Three properties are load-bearing rather than incidental: A run reports itself in the *existing* `harness_work` vocabulary — a `plan_update` naming every node, `todo_update` as steps settle, `subagent_start` per agent node, and a `run_result`. So a workflow renders through the same pane -that shows a harness's own todo list, with no rendering code of its own. +that shows an agent's own todo list, with no rendering code of its own. ## Configuration diff --git a/src/sdk/src/config/local_hosts.rs b/src/sdk/src/config/local_hosts.rs new file mode 100644 index 000000000..e237a494b --- /dev/null +++ b/src/sdk/src/config/local_hosts.rs @@ -0,0 +1,145 @@ +//! Which hosts this machine runs, resolved from config alone. +//! +//! A host is a machine with a bus address; `[host]` declares the primary one and +//! each `[[hosts]]` entry another beside it. Both the process that *starts* them +//! and the UI that *lists* them need the same answer to "what address will this +//! section bind, and what should it be called" — and they must not derive it +//! twice, because a UI that disagrees with the binder would file a running local +//! host under a remote one and quietly show it as read-only. +//! +//! Resolution is deliberately config-only: it holds before anything starts, +//! which is the case the Hosts tab needs (declared agents on a host that is not +//! running are still that host's agents) and the case the roster clean-up needs +//! (recognising remembered local entries when nothing started at all). + +use super::HostSection; + +/// One host this machine declares, as the UI and the starter both see it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalHostRef { + /// The bus address it binds — the `hostId` its agents are declared under. + pub id: String, + /// What to call it on screen. + pub name: String, + /// The directory it works in, as configured. Blank means "wherever medulla + /// was launched", which only the running host can resolve. + pub workspace: String, + /// Whether this is the `[host]` section rather than an `[[hosts]]` entry. + pub primary: bool, +} + +/// Every host this machine declares: the primary first, then the extras in +/// declaration order. +/// +/// Addresses come from [`local_host_address`], names from [`local_host_name`]. +/// +/// **The resolved ids are unique.** An address is a bus address, so two sections +/// that resolve to one can never both exist: the first binds and the second's +/// `bind` fails, which the host starter already reports as a start-up problem. +/// This is the same fact stated for the readers — a second row for a host that will +/// not be there is not a host, it is the collision drawn twice. The first claim +/// on an address keeps it, which is also the one that binds, so the list and the +/// binder still agree. +/// +/// Two sections collide by two routes, and both are dropped here: an extra that +/// *types* an address another section already resolved to, and two names that +/// slug to the same thing — `"API"` and `"api"` both give `local-api`, because +/// the slug is lowercased. +pub fn local_hosts(primary: &HostSection, extras: &[HostSection]) -> Vec { + let mut taken: Vec = Vec::new(); + std::iter::once(LocalHostRef { + id: primary.effective_address(), + name: local_host_name(primary, &primary.workspace, true), + workspace: primary.workspace.clone(), + primary: true, + }) + .chain( + extras + .iter() + .enumerate() + .map(|(index, extra)| LocalHostRef { + // Positional, so dropping a collision must not renumber the ones + // after it: the index is the entry's place in `[[hosts]]`, which + // is what the binder counts too. + id: local_host_address(extra, index), + name: local_host_name(extra, &extra.workspace, false), + workspace: extra.workspace.clone(), + primary: false, + }), + ) + .filter(|host| { + let id = host.id.trim().to_string(); + let fresh = !taken.contains(&id); + if fresh { + taken.push(id); + } + fresh + }) + .collect() +} + +/// The bus address for an extra host, derived from its name when it declared +/// none of its own. +/// +/// Two hosts cannot share an address — the second `bind` fails — so an operator +/// who adds `[[hosts]]` without thinking about addressing would otherwise get +/// one working host and one startup error. Deriving from the name means the +/// field is optional in the common case and explicit when it matters. +/// +/// The section default counts as unchosen, not as a choice: `[[hosts]]` shares +/// [`HostSection`], so an entry that names no address inherits the primary's. +/// An operator who *typed* the primary's address has made the same mistake, so +/// both are treated the same way. +pub fn local_host_address(config: &HostSection, fallback_index: usize) -> String { + let chosen = config.address.trim(); + let chosen = if chosen == HostSection::default().address { + "" + } else { + chosen + }; + match chosen { + "" => { + let slug = slug_of(&config.name); + if slug.is_empty() { + format!("local-host-{}", fallback_index + 1) + } else { + format!("local-{slug}") + } + } + value => value.to_string(), + } +} + +/// What to call a host that named itself nothing. +/// +/// The primary is "this device" — it is the machine the operator is looking at. +/// An extra is named for the directory it works in, because that is the only +/// thing distinguishing it from the primary. `workspace` is the *resolved* +/// directory where the caller has one; the configured value is the honest +/// fallback before anything has started. +pub fn local_host_name(config: &HostSection, workspace: &str, primary: bool) -> String { + match config.name.trim() { + "" if primary => "this device".to_string(), + "" => std::path::Path::new(workspace) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| workspace.to_string()), + value => value.to_string(), + } +} + +/// A lowercase, hyphenated form of `name`, safe to use as a bus address. +fn slug_of(name: &str) -> String { + let mut out = String::new(); + let mut hyphen = false; + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + hyphen = false; + } else if !out.is_empty() && !hyphen { + out.push('-'); + hyphen = true; + } + } + out.trim_end_matches('-').to_string() +} diff --git a/src/sdk/src/config/local_hosts_tests.rs b/src/sdk/src/config/local_hosts_tests.rs new file mode 100644 index 000000000..fb00cb481 --- /dev/null +++ b/src/sdk/src/config/local_hosts_tests.rs @@ -0,0 +1,126 @@ +//! Unit tests for device-local host resolution. + +use super::{local_host_address, local_host_name, local_hosts, HostSection}; + +/// A `[[hosts]]` entry as `load` produces it: fields default to the primary's, +/// which is exactly why an unchosen address must not count as chosen. +fn extra(name: &str, workspace: &str) -> HostSection { + HostSection { + name: name.into(), + workspace: workspace.into(), + ..HostSection::default() + } +} + +#[test] +fn an_extra_address_comes_from_its_name_then_its_position() { + let named = extra("backend API", "/w"); + let anonymous = extra("", "/w"); + let mut explicit = extra("ignored", "/w"); + explicit.address = "chosen-by-hand".into(); + + assert_eq!(local_host_address(&named, 0), "local-backend-api"); + assert_eq!(local_host_address(&anonymous, 3), "local-host-4"); + assert_eq!(local_host_address(&explicit, 0), "chosen-by-hand"); +} + +#[test] +fn inheriting_the_primary_address_counts_as_unchosen() { + // `[[hosts]]` shares `HostSection`, so an entry that names no address + // deserializes with the primary's default. Treating that as a choice would + // hand two hosts one address and the second would never bind. + let mut inherited = extra("", "/w"); + inherited.address = HostSection::default().address; + assert_eq!(local_host_address(&inherited, 0), "local-host-1"); +} + +#[test] +fn the_primary_leads_and_each_extra_follows_in_order() { + let primary = HostSection { + workspace: "/Users/me/medulla".into(), + ..HostSection::default() + }; + let extras = vec![ + extra("API", "/Users/me/Projects/backend"), + extra("", "/tmp/x"), + ]; + + let hosts = local_hosts(&primary, &extras); + let ids: Vec<&str> = hosts.iter().map(|host| host.id.as_str()).collect(); + assert_eq!(ids, vec!["this-device", "local-api", "local-host-2"]); + assert!(hosts[0].primary); + assert!(!hosts[1].primary); + assert_eq!(hosts[0].name, "this device"); + assert_eq!(hosts[1].name, "API"); + // An unnamed extra is named for the directory that distinguishes it. + assert_eq!(hosts[2].name, "x"); + assert_eq!(hosts[1].workspace, "/Users/me/Projects/backend"); +} + +#[test] +fn an_extra_that_takes_the_primarys_address_is_not_a_second_host() { + // A bus address belongs to one host: the primary binds it and the extra's + // `bind` fails, so listing both would draw a host that is not going to be + // there. The one that binds is the one that stays. + let mut primary = HostSection { + workspace: "/w".into(), + ..HostSection::default() + }; + primary.address = "chosen-by-hand".into(); + let mut clash = extra("second", "/w/second"); + clash.address = "chosen-by-hand".into(); + + let hosts = local_hosts(&primary, &[clash]); + let ids: Vec<&str> = hosts.iter().map(|host| host.id.as_str()).collect(); + assert_eq!(ids, vec!["chosen-by-hand"]); + assert!( + hosts[0].primary, + "the primary keeps the address it declared" + ); +} + +#[test] +fn two_names_that_slug_alike_are_one_host_not_two() { + // The slug is lowercased, so `API` and `api` are the same address. The + // second entry cannot bind, and a row for it would be the collision drawn + // twice rather than a host the operator has. + let primary = HostSection { + workspace: "/w".into(), + ..HostSection::default() + }; + let extras = vec![ + extra("API", "/w/api"), + extra("api", "/w/api-again"), + extra("web", "/w/web"), + ]; + + let hosts = local_hosts(&primary, &extras); + let ids: Vec<&str> = hosts.iter().map(|host| host.id.as_str()).collect(); + assert_eq!(ids, vec!["this-device", "local-api", "local-web"]); + // Dropping the collision must not renumber what follows it: the fallback + // index is the entry's place in `[[hosts]]`, which is what the binder counts. + assert_eq!( + local_hosts( + &primary, + &[extra("API", "/w"), extra("api", "/w"), extra("", "/w/x")] + ) + .last() + .map(|host| host.id.as_str()), + Some("local-host-3") + ); +} + +#[test] +fn an_unnamed_host_falls_back_to_its_directory_then_the_path() { + let unnamed = extra("", ""); + assert_eq!( + local_host_name(&unnamed, "/Users/me/Projects/backend", false), + "backend" + ); + assert_eq!(local_host_name(&unnamed, "", false), ""); + assert_eq!(local_host_name(&unnamed, "/anything", true), "this device"); + assert_eq!( + local_host_name(&extra("API box", ""), "/x", false), + "API box" + ); +} diff --git a/src/sdk/src/config/mod.rs b/src/sdk/src/config/mod.rs index db04db776..3fecbd156 100644 --- a/src/sdk/src/config/mod.rs +++ b/src/sdk/src/config/mod.rs @@ -14,6 +14,7 @@ mod appearance; mod core_socket; mod custom_harnesses; mod load; +mod local_hosts; mod persist; mod types; mod urls; @@ -27,6 +28,8 @@ mod custom_harnesses_tests; #[cfg(test)] mod load_tests; #[cfg(test)] +mod local_hosts_tests; +#[cfg(test)] mod persist_tests; #[cfg(test)] mod types_tests; @@ -44,6 +47,7 @@ pub use custom_harnesses::{ OPENROUTER_ANTHROPIC_URL, OPENROUTER_API_KEY_ENV, OPENROUTER_OPENAI_URL, }; pub use load::{default_link_config, explicit_config_from_env, load_config, CONFIG_PATH_ENV}; +pub use local_hosts::{local_host_address, local_host_name, local_hosts, LocalHostRef}; pub use persist::{ clear_setting, persist_agent_declarations, persist_custom_harnesses, persist_host_workspaces, persist_hub_workers, persist_link_peers, persist_local_hosts, persist_root_setting, diff --git a/src/sdk/src/control_socket/server/tests/hub_ops.rs b/src/sdk/src/control_socket/server/tests/hub_ops.rs index d3e2d56f7..00f993e04 100644 --- a/src/sdk/src/control_socket/server/tests/hub_ops.rs +++ b/src/sdk/src/control_socket/server/tests/hub_ops.rs @@ -37,6 +37,7 @@ fn done(reply: &str) -> Result { output_tokens: 2, }, harness: None, + session_id: None, }) } diff --git a/src/sdk/src/control_socket/server/tests/registry.rs b/src/sdk/src/control_socket/server/tests/registry.rs index f53822fa9..26c15f360 100644 --- a/src/sdk/src/control_socket/server/tests/registry.rs +++ b/src/sdk/src/control_socket/server/tests/registry.rs @@ -138,6 +138,7 @@ async fn admitting_a_replacement_preserves_a_settled_result_with_a_waiter() { output_tokens: 0, }, harness: None, + session_id: None, })); tracked.entry.finished_at = Some(crate::clock::now_millis()); tracked.settled.subscribe() diff --git a/src/sdk/src/control_socket/tests/mod.rs b/src/sdk/src/control_socket/tests/mod.rs index 5b82e8272..6e5880a2b 100644 --- a/src/sdk/src/control_socket/tests/mod.rs +++ b/src/sdk/src/control_socket/tests/mod.rs @@ -152,6 +152,7 @@ impl FleetOps for FakeFleet { output_tokens: 20, }, harness: None, + session_id: None, }), FakeOutcome::Fail(error) => Err(error), FakeOutcome::Hang => { diff --git a/src/sdk/src/daemon/embedded/types.rs b/src/sdk/src/daemon/embedded/types.rs index 0420ac68d..24dd23598 100644 --- a/src/sdk/src/daemon/embedded/types.rs +++ b/src/sdk/src/daemon/embedded/types.rs @@ -14,8 +14,20 @@ use super::super::types::{DaemonRuntime, LogFn}; /// local bus is an in-memory queue: there is no relay to be polite to, and this /// interval is the floor on how quickly a locally-dispatched task starts. pub const DEFAULT_LOCAL_POLL: Duration = Duration::from_millis(150); -/// Default concurrent task executions for an embedded host. -pub const DEFAULT_CONCURRENCY: usize = 2; +/// Default concurrent task executions for an embedded host — effectively +/// unlimited. +/// +/// A host-wide cap predates declared agents: it made sense when a machine was +/// one worker with one implicit session. It is the wrong grain now, and it +/// queued invisibly — a third concurrent task waited for a slot even when it +/// targeted a different agent in a different workspace, where nothing could +/// collide. The limits that own the real hazard live where the hazard is: +/// per-agent `max_sessions` from the workspace strategy, and the checkout +/// serialization that keeps a second writer out of a tree someone is in. +/// +/// The semaphore stays as the accounting mechanism behind `active_count`, and +/// an operator can still set `concurrency` to impose a real cap. +pub const DEFAULT_CONCURRENCY: usize = 1024; /// Default per-task execution timeout, in ms. pub const DEFAULT_TASK_TIMEOUT_MS: u64 = 600_000; diff --git a/src/sdk/src/daemon/entry.rs b/src/sdk/src/daemon/entry.rs index bfc4bb083..292038fdb 100644 --- a/src/sdk/src/daemon/entry.rs +++ b/src/sdk/src/daemon/entry.rs @@ -24,7 +24,24 @@ use super::types::{ DaemonConfig, DaemonRuntime, SendFn, DEFAULT_MAX_PENDING, DEFAULT_STATUS_THROTTLE_MS, }; -const DEFAULT_CONCURRENCY: usize = 2; +/// Host-wide task slots, effectively unlimited by default. +/// +/// This cap predates declared agents: when a machine was one worker with one +/// implicit session, a small number was the only thing standing between a +/// fan-out and a thrashed laptop. It is the wrong instrument now, and it queued +/// invisibly — a third concurrent task waited on a slot even when it targeted a +/// different agent in a different workspace, where nothing could collide. +/// +/// What actually bounds concurrency now sits at the grain that owns the hazard: +/// per-agent `max_sessions` derived from the agent's workspace strategy, and the +/// checkout serialization that keeps a second writer out of a tree someone is +/// already working in. A host-wide count knows about neither, so it can only +/// delay work that was already safe. +/// +/// The semaphore itself stays — `active_count` derives the running-task figure +/// from its permits — and an operator can still set `concurrency` in config to +/// impose a real cap on a small machine. +const DEFAULT_CONCURRENCY: usize = 1024; const DEFAULT_TASK_TIMEOUT_MS: u64 = 600_000; const DEFAULT_POLL_MS: u64 = 2_000; diff --git a/src/sdk/src/daemon/mod.rs b/src/sdk/src/daemon/mod.rs index 185ab4692..fa6cd5343 100644 --- a/src/sdk/src/daemon/mod.rs +++ b/src/sdk/src/daemon/mod.rs @@ -44,5 +44,5 @@ pub(crate) use status::TOOL_CALL_ID_SEPARATOR; pub use status::{status_detail, work_detail, THINKING_PREFIX, TOOL_PREFIX}; pub use types::{ DaemonConfig, DaemonRuntime, LogFn, NowFn, SendFn, CAPACITY_REJECTION_PREFIX, - HARNESS_HELD_PREFIX, + HARNESS_HELD_PREFIX, SESSION_HELD_STATUS_PREFIX, SESSION_RESUMED_STATUS_PREFIX, }; diff --git a/src/sdk/src/daemon/runtime.rs b/src/sdk/src/daemon/runtime.rs index 40d4d37e8..ff63ba563 100644 --- a/src/sdk/src/daemon/runtime.rs +++ b/src/sdk/src/daemon/runtime.rs @@ -287,7 +287,7 @@ impl DaemonRuntime { harness: Option, attachments: FrameAttachments, ) { - let body = crate::protocol::encode_task_frame_with_work( + let body = crate::protocol::encode_task_frame_with_attachments( EncodeFrameInput { kind, task_id: task_id.to_string(), @@ -308,8 +308,7 @@ impl DaemonRuntime { conversation: None, fleet_depth: 0, }, - attachments.usage, - attachments.work, + attachments, ); // Narrate the terminal frames only. Status and ack are throttled chatter // whose whole point is that nobody reads them one by one; a reply or an diff --git a/src/sdk/src/daemon/task_loop/run.rs b/src/sdk/src/daemon/task_loop/run.rs index e4c9f23e5..cd4e4e246 100644 --- a/src/sdk/src/daemon/task_loop/run.rs +++ b/src/sdk/src/daemon/task_loop/run.rs @@ -12,11 +12,19 @@ use super::super::providers::{Abort, RunTaskOptions}; use super::super::status::{status_detail, work_detail}; use super::super::types::{ DaemonRuntime, FrameAttachments, RunningTask, CAPACITY_REJECTION_PREFIX, + SESSION_HELD_STATUS_PREFIX, SESSION_RESUMED_STATUS_PREFIX, }; /// What a task waiting for a harness slot reports while it waits. const QUEUED_STATUS: &str = "queued for a harness slot"; +/// Whether a status detail is one of the two control markers the requester's +/// watchdog reads (see [`SESSION_HELD_STATUS_PREFIX`]). +fn is_control_marker(detail: &str) -> bool { + detail.starts_with(SESSION_HELD_STATUS_PREFIX) + || detail.starts_with(SESSION_RESUMED_STATUS_PREFIX) +} + /// What a running task reports through a stretch with no harness events — /// a single long tool call, typically. const HEARTBEAT_STATUS: &str = "still working"; @@ -279,7 +287,17 @@ impl DaemonRuntime { semantic.event.decoded(), HarnessEventKind::ToolCall(_) | HarnessEventKind::ToolResult(_) ); - if !changes_tool && current.saturating_sub(last_status_at) < throttle { + // Control changes are state transitions too, and the only ones + // the *requester* acts on: the hold marker is what pauses its + // no-progress watchdog, and the hand-back marker is what + // resumes it. Throttled away, a hold that began a second after + // the last tool call would never be announced, and the hub + // would reap a task a person is sitting in. + let changes_control = is_control_marker(&detail); + if !changes_tool + && !changes_control + && current.saturating_sub(last_status_at) < throttle + { if is_thinking { *pending_thinking.lock().unwrap() = Some((detail, Some(snapshot))); } @@ -507,6 +525,7 @@ impl DaemonRuntime { FrameAttachments { usage: None, work: snapshot, + ..Default::default() }, ) .await; @@ -567,6 +586,11 @@ impl DaemonRuntime { FrameAttachments { usage: run.usage, work: Some(final_work), + // Which session did the work. Only this process knows — + // it opened (or resumed) it — and the caller has no way + // to derive it, so a task whose session goes unreported + // is one nobody upstream can point at afterwards. + session_id: run.session_id.clone(), }, ) .await; diff --git a/src/sdk/src/daemon/task_loop/workflow.rs b/src/sdk/src/daemon/task_loop/workflow.rs index 2bf829d9c..29a58a231 100644 --- a/src/sdk/src/daemon/task_loop/workflow.rs +++ b/src/sdk/src/daemon/task_loop/workflow.rs @@ -119,6 +119,7 @@ impl HarnessDispatch for RuntimeDispatch { output_tokens: 0, }), harness: Some(provider), + session_id: None, }) } } @@ -307,7 +308,14 @@ impl DaemonRuntime { .await; let work = fold.lock().ok().map(|fold| fold.snapshot().clone()); - let attachments = FrameAttachments { usage: None, work }; + // No session id: a workflow run is a graph, not one harness session — + // each `agent` node opens its own. There is no single session that + // served this task, so none is claimed. + let attachments = FrameAttachments { + usage: None, + work, + ..Default::default() + }; match outcome { Ok(record) => { diff --git a/src/sdk/src/daemon/tests/capability_tests.rs b/src/sdk/src/daemon/tests/capability_tests.rs index 1567d7f4a..a5e40e6c8 100644 --- a/src/sdk/src/daemon/tests/capability_tests.rs +++ b/src/sdk/src/daemon/tests/capability_tests.rs @@ -11,8 +11,8 @@ use crate::protocol::{AgentCapabilities, HarnessEvent, TaskFrameKind}; use super::{ base_config, capabilities_frame, chatter_status_runner, counting_capability_runner, - decoded_frames, quick_thinking_runner, quick_tool_runner, recording_send, status_runner, - task_frame, tool_call_event, + decoded_frames, held_then_resumed_runner, quick_thinking_runner, quick_tool_runner, + recording_send, status_runner, task_frame, tool_call_event, }; #[tokio::test] @@ -52,6 +52,53 @@ async fn throttles_status_frames() { .any(|f| f.kind == TaskFrameKind::Reply && f.text == "ok")); } +#[tokio::test] +async fn control_markers_are_never_throttled_away() { + // The throttle is a rate cap on *chatter*, and a hold is not chatter: it is + // the frame that pauses the requester's no-progress watchdog, and the + // hand-back is the one that resumes it. Dropped by the throttle — which is + // exactly what happens when a person takes a session a second after the last + // status — the hub would go on counting the silence of a session somebody is + // sitting in, and reap a healthy task while they worked. + let (send, recorded) = recording_send(); + let runtime = DaemonRuntime::new(base_config(), held_then_resumed_runner(), send); + // All three events inside one 4s window: ordinarily only the first survives + // (see `throttles_status_frames`, which is the same clock). + let seq = Arc::new(vec![10_000i64, 11_000, 12_000]); + let index = Arc::new(AtomicUsize::new(0)); + let now: NowFn = Arc::new(move || { + let position = index.fetch_add(1, Ordering::SeqCst); + *seq.get(position).unwrap_or(seq.last().unwrap()) + }); + let runtime = runtime.with_now(now); + + runtime.handle_message( + "peer".into(), + String::new(), + Some(task_frame("t1", "work", None)), + ); + runtime.idle().await; + + let frames = decoded_frames(&recorded); + let statuses: Vec<&str> = frames + .iter() + .filter(|f| f.kind == TaskFrameKind::Status) + .map(|f| f.text.as_str()) + .collect(); + assert!( + statuses + .iter() + .any(|text| text.starts_with(crate::daemon::SESSION_HELD_STATUS_PREFIX)), + "the hold must reach the requester: {statuses:?}" + ); + assert!( + statuses + .iter() + .any(|text| text.starts_with(crate::daemon::SESSION_RESUMED_STATUS_PREFIX)), + "and so must the hand-back, or the watchdog never resumes: {statuses:?}" + ); +} + #[tokio::test] async fn flushes_final_thinking_snapshot_after_throttling() { let (send, recorded) = recording_send(); diff --git a/src/sdk/src/daemon/tests/mod.rs b/src/sdk/src/daemon/tests/mod.rs index b14943803..0dbe71592 100644 --- a/src/sdk/src/daemon/tests/mod.rs +++ b/src/sdk/src/daemon/tests/mod.rs @@ -87,6 +87,7 @@ pub(super) fn task_frame(task_id: &str, text: &str, correlation: Option<&str>) - TaskFrame { usage: None, work: None, + session_id: None, proto: MEDULLA_TASK_PROTO.to_string(), kind: TaskFrameKind::Task, task_id: task_id.to_string(), @@ -111,6 +112,7 @@ pub(super) fn input_frame(task_id: &str, text: &str, correlation: Option<&str>) TaskFrame { usage: None, work: None, + session_id: None, kind: TaskFrameKind::Input, ..task_frame(task_id, text, correlation) } @@ -121,6 +123,7 @@ pub(super) fn abort_frame(task_id: &str, correlation: Option<&str>) -> TaskFrame TaskFrame { usage: None, work: None, + session_id: None, kind: TaskFrameKind::Abort, ..task_frame(task_id, "", correlation) } @@ -275,6 +278,51 @@ pub(super) fn chatter_status_runner(count: usize) -> RunTaskFn { }) } +/// A runner whose second and third statuses are the control markers a held +/// session emits, all three inside one throttle window. +/// +/// The ordinary status first, so the throttle's clock is already primed when the +/// hold is announced — which is the case that matters: a person taking a session +/// a second after the last tool call must not have the hold silently dropped. +pub(super) fn held_then_resumed_runner() -> RunTaskFn { + Arc::new(move |mut opts: RunTaskOptions| { + Box::pin(async move { + if let Some(mut on_event) = opts.on_event.take() { + for (state, detail) in [ + ("working", "reading the migration".to_string()), + ( + "held", + crate::daemon::SESSION_HELD_STATUS_PREFIX.to_string(), + ), + ( + "running", + crate::daemon::SESSION_RESUMED_STATUS_PREFIX.to_string(), + ), + ] { + on_event(&HarnessSemanticEvent { + line: 0, + timestamp_ms: 0, + record_type: "medulla:control".to_string(), + event: HarnessEvent { + kind: "status".to_string(), + role: "system".to_string(), + payload: json!({ "state": state, "detail": detail }), + ..Default::default() + }, + }); + } + } + Ok(RunTaskResult { + session_id: None, + usage: None, + provider: opts.provider, + reply: "ok".to_string(), + events: 3, + }) + }) + }) +} + /// A runner that emits two cumulative thinking snapshots inside one throttle window. pub(super) fn quick_thinking_runner() -> RunTaskFn { Arc::new(move |mut opts: RunTaskOptions| { @@ -415,6 +463,7 @@ pub(super) fn capabilities_frame(task_id: &str, correlation: Option<&str>) -> Ta TaskFrame { usage: None, work: None, + session_id: None, kind: TaskFrameKind::Capabilities, ..task_frame(task_id, "", correlation) } diff --git a/src/sdk/src/daemon/tests/task_continuity_tests.rs b/src/sdk/src/daemon/tests/task_continuity_tests.rs index 04f5c37f7..41fe0283a 100644 --- a/src/sdk/src/daemon/tests/task_continuity_tests.rs +++ b/src/sdk/src/daemon/tests/task_continuity_tests.rs @@ -290,3 +290,44 @@ async fn two_frames_in_one_conversation_never_overlap_in_the_harness() { "both ran, strictly one after the other" ); } + +#[tokio::test] +async fn the_reply_names_the_session_that_served_the_task() { + // The worker is the only party that knows which session ran the work, and + // the terminal frame is the only place it can say so. Without this the id + // never leaves this process and nothing upstream can point at where a task + // actually happened. + let (send, recorded) = recording_send(); + let runtime = DaemonRuntime::new( + base_config(), + resume_runner(Arc::new(StdMutex::new(Vec::new())), "sess-1"), + send, + ); + + runtime.handle_message( + "peer".into(), + String::new(), + Some(task_frame("t1", "audit", None)), + ); + runtime.idle().await; + + let frames = decoded_frames(&recorded); + let reply = frames + .iter() + .find(|frame| frame.kind == crate::protocol::TaskFrameKind::Reply) + .expect("a terminal reply"); + assert_eq!(reply.session_id.as_deref(), Some("sess-1")); + + // Only the terminal frame claims it. An ack is sent before any session + // exists, and a status frame describes progress, not placement. + for frame in frames + .iter() + .filter(|frame| frame.kind != crate::protocol::TaskFrameKind::Reply) + { + assert_eq!( + frame.session_id, None, + "{:?} must not claim a session", + frame.kind + ); + } +} diff --git a/src/sdk/src/daemon/types.rs b/src/sdk/src/daemon/types.rs index 1d911de53..0fd84fc2c 100644 --- a/src/sdk/src/daemon/types.rs +++ b/src/sdk/src/daemon/types.rs @@ -63,6 +63,27 @@ pub const CAPACITY_REJECTION_PREFIX: &str = "daemon at capacity"; /// person is finished with it. pub const HARNESS_HELD_PREFIX: &str = "harness held by operator"; +/// The leading text of the `status` frame a worker sends when an operator takes +/// a session that is *already running a task*. +/// +/// A wire format in practice, like [`CAPACITY_REJECTION_PREFIX`]: the requesting +/// hub matches on it to pause its no-progress watchdog for the duration of the +/// hold (`crate::hub::runner`). A person reading their session is not a crashed +/// worker, and a 30-minute hold must not be reaped as one — but the window must +/// *pause* rather than be switched off, so a worker that dies while holding is +/// still given up on once the session is handed back. +/// +/// Distinct from [`HARNESS_HELD_PREFIX`], which is a *terminal* refusal ("I did +/// not attempt this"). This one says the opposite: the task is alive, retained, +/// and waiting on a human. +pub const SESSION_HELD_STATUS_PREFIX: &str = "session held by operator"; + +/// The leading text of the `status` frame that ends a hold. +/// +/// Sent when control returns to the orchestrator and the hand-back turn starts, +/// so the watchdog resumes on exactly the frame that says work has restarted. +pub const SESSION_RESUMED_STATUS_PREFIX: &str = "session handed back"; + /// A lock-serialized encrypted send: `(to, body) -> ()`. Errors are handled by /// the transport (logged), so the runtime never observes a send failure. pub type SendFn = @@ -291,14 +312,8 @@ pub struct DaemonRuntime { /// Optional payloads a task frame can carry beyond its text. /// -/// Grouped into one value rather than threaded as parameters because the list -/// grows with the protocol: token usage was the first, the child harness's work -/// snapshot the second, and every addition would otherwise widen four call -/// signatures. -#[derive(Debug, Clone, Default)] -pub(super) struct FrameAttachments { - /// Token counts the child harness reported, on reply frames. - pub(super) usage: Option, - /// What the child harness is working on as of this frame. - pub(super) work: Option, -} +/// The protocol's own type rather than a daemon-local copy of it: the list grows +/// with the protocol (token usage was the first, the child harness's work +/// snapshot the second, the serving session the third), and a parallel struct +/// here would only be a second place to forget an addition. +pub(super) use crate::protocol::FrameAttachments; diff --git a/src/sdk/src/flow_engine/tests.rs b/src/sdk/src/flow_engine/tests.rs index bd01efd72..8b631e4fe 100644 --- a/src/sdk/src/flow_engine/tests.rs +++ b/src/sdk/src/flow_engine/tests.rs @@ -78,6 +78,7 @@ impl HarnessDispatch for RecordingDispatch { output_tokens: 0, }, harness: None, + session_id: None, }), } } diff --git a/src/sdk/src/hub/boot/mod.rs b/src/sdk/src/hub/boot/mod.rs index 89ba269cf..33b1ba5da 100644 --- a/src/sdk/src/hub/boot/mod.rs +++ b/src/sdk/src/hub/boot/mod.rs @@ -103,6 +103,7 @@ pub async fn start_hub(config: HubConfig) -> anyhow::Result { super::socket::HarnessWiring { roster: roster.clone(), catalog: catalog.clone(), + local_hosts: config.local_hosts.clone(), runner: runner.clone(), subscription_strategy: subscription_strategy.clone(), log: config.log.clone(), @@ -122,6 +123,7 @@ pub async fn start_hub(config: HubConfig) -> anyhow::Result { address: hub_address, relay, catalog, + local_hosts: config.local_hosts.clone(), runner: runner.clone(), log: config.log.clone(), persist: config.persist.clone(), diff --git a/src/sdk/src/hub/boot/types.rs b/src/sdk/src/hub/boot/types.rs index 51725b4bc..e9dad826b 100644 --- a/src/sdk/src/hub/boot/types.rs +++ b/src/sdk/src/hub/boot/types.rs @@ -97,6 +97,15 @@ pub struct HubConfig { /// already loads, and a second read could disagree with what the operator /// is looking at on the Agent Templates page. pub agent_templates: Vec, + /// The hosts *this machine* declares, advertised as the payload's `hosts[]` + /// block and used to decide which advertised agents run locally. + /// + /// Shared rather than owned so a host started mid-session joins the same + /// list the hub reads at registration time (see + /// [`SharedLocalHosts`](crate::hub::SharedLocalHosts)). Default-empty is the + /// honest answer for a hub that hosts nothing itself: every agent it fronts + /// then belongs to another machine, and the block says so. + pub local_hosts: crate::hub::SharedLocalHosts, /// How often the runner drains the inbox. pub poll: Duration, /// Where diagnostics go. Defaults to stderr; a TUI supplies its own so the diff --git a/src/sdk/src/hub/handle/mod.rs b/src/sdk/src/hub/handle/mod.rs index 99636e342..6f8245377 100644 --- a/src/sdk/src/hub/handle/mod.rs +++ b/src/sdk/src/hub/handle/mod.rs @@ -158,6 +158,7 @@ impl HubHandle { address: wiring.address, relay: wiring.relay, catalog: wiring.catalog, + local_hosts: wiring.local_hosts, runner: wiring.runner, system_info: Arc::new(Mutex::new(HashMap::new())), log: wiring.log, @@ -446,7 +447,13 @@ impl HubHandle { async fn reregister(&self) -> anyhow::Result<()> { let workers = self.list(); let online = self.relay.presence(&addresses_of(&workers)).await; - let payload = register_payload(&workers, &online, &self.catalog); + // Read here rather than captured at build time: a host started since + // this handle was made must register as `local`, not as a remote host + // the hub happens to front. + let payload = { + let local_hosts = self.local_hosts.lock().expect("local hosts lock"); + register_payload(&workers, &online, &self.catalog, &local_hosts) + }; self.socket .emit("medulla:register_agents", payload) .await diff --git a/src/sdk/src/hub/handle/types.rs b/src/sdk/src/hub/handle/types.rs index f7bd73d0a..bc0dd460d 100644 --- a/src/sdk/src/hub/handle/types.rs +++ b/src/sdk/src/hub/handle/types.rs @@ -13,6 +13,9 @@ pub struct HubHandle { pub(super) relay: Arc, /// The agent-role catalog, read when re-advertising the roster. pub(super) catalog: Arc>, + /// The hosts this machine declares, read when re-advertising the roster so + /// a host started mid-session is registered as `local`. + pub(super) local_hosts: super::super::roster::SharedLocalHosts, /// Sender/receiver correlation used for lightweight worker probes. pub(super) runner: Arc, /// Latest capacity details keyed by stable worker id. @@ -47,6 +50,9 @@ pub(in super::super) struct HandleWiring { /// advertises. Shared and read-only: registration reads it, nothing here /// writes it. pub catalog: Arc>, + /// The hosts this machine declares, for the `hosts[]` block of every + /// re-registration this handle triggers. + pub local_hosts: super::super::roster::SharedLocalHosts, /// Runner used to request lightweight details from workers. pub runner: Arc, /// Where roster mutations are narrated. diff --git a/src/sdk/src/hub/handoff/types.rs b/src/sdk/src/hub/handoff/types.rs index 174f9538c..4cbfc4554 100644 --- a/src/sdk/src/hub/handoff/types.rs +++ b/src/sdk/src/hub/handoff/types.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; /// Who holds a harness, as the orchestrator is told. /// -/// The SDK-side spelling of the TUI's `HarnessControl`. Deliberately a second +/// The SDK-side spelling of the TUI's `SessionControl`. Deliberately a second /// type rather than `serde` on the first: that enum's contract is that it is /// process-local and never serialized, and it is the single gate on dispatch. /// Deriving `Serialize` onto it would quietly make it a wire type and put the diff --git a/src/sdk/src/hub/mod.rs b/src/sdk/src/hub/mod.rs index ce275b220..65670a65b 100644 --- a/src/sdk/src/hub/mod.rs +++ b/src/sdk/src/hub/mod.rs @@ -35,7 +35,7 @@ pub use boot::{ pub use handle::HubHandle; pub use handoff::{HandoffControl, HarnessHandoff}; pub use relay::Relay; -pub use roster::HubWorker; +pub use roster::{HubWorker, SharedLocalHosts}; pub use runner::TaskRunner; pub use screens::{ScreenStore, WatchedScreen}; pub use types::{stderr_log, HubLog, RosterSink, RunError, TaskOutcome, TaskRequest}; diff --git a/src/sdk/src/hub/roster/mod.rs b/src/sdk/src/hub/roster/mod.rs index 0d3fa938c..596abcae4 100644 --- a/src/sdk/src/hub/roster/mod.rs +++ b/src/sdk/src/hub/roster/mod.rs @@ -35,6 +35,17 @@ fn to_agent(w: &HubWorker, catalog: &[crate::runtime::AgentTemplate]) -> Value { // rather than sent empty when unknown, so the backend falls through to the // worker's probed `capabilities.cwd` instead of placing it at "". let mut metadata = json!({ "address": w.address, "harness": w.harness }); + // How many sessions this agent may run at once, derived from its declared + // strategy. Code-plane data: deterministic placement reads it to decide + // whether an agent has headroom, and no prompt ever does (spec §4.2). + // + // A zero is withheld rather than sent. Capacity of nothing reads as + // "saturated" — the opposite of the permissive default every other omission + // here means — and an agent that never stated a strategy should be treated + // as available, not as full. + if w.max_sessions > 0 { + metadata["maxSessions"] = json!(w.max_sessions); + } // The role ids, not just their text. The description and tags are what the // model reads; the ids are what anything downstream joins on — asking for a // role by name, or applying its tools and instructions at delegate time. @@ -45,40 +56,45 @@ fn to_agent(w: &HubWorker, catalog: &[crate::runtime::AgentTemplate]) -> Value { if !roles.is_empty() { metadata["roles"] = json!(roles.iter().map(|t| t.id.as_str()).collect::>()); } - // The path, not the `{path, type}` object the entity model carries: the - // object is the wire change, and this advert stays byte-identical for a - // worker whose placement has not changed. + // The path, not the `{path, type}` object the entity model carries. The + // backend parses both, so widening it buys nothing today and would only + // churn every reader of this advert; the type rides along with the + // remote-host work that gives a worktree a reason to be named. if let Some(workspace) = w.workspace_path() { metadata["workspace"] = json!(workspace); } - // Who holds the harness, and only when that is a person. Absent means the - // orchestrator has it, which is both the common case and the one worth - // keeping byte-stable: this advert is re-emitted on every roster mutation, - // and a key that flips on each one is a diff nobody can read. - if w.control.is_operator() { - metadata["control"] = json!(w.control.as_str()); - if let Some(reason) = w - .control_reason - .as_deref() - .map(str::trim) - .filter(|r| !r.is_empty()) - { - metadata["controlReason"] = json!(reason); - } - if let Some(since) = w.control_since { - metadata["controlSince"] = json!(since); - } - } - // The brief from the last handback. Carried only while the orchestrator - // actually holds the harness: an invitation to continue work in a workspace - // the operator has since re-taken is one the orchestrator cannot act on, and - // planning against it wastes a pass. - if let (false, Some(handoff)) = (w.control.is_operator(), w.handoff.as_ref()) { - if let Ok(value) = serde_json::to_value(handoff) { - metadata["handoff"] = value; - } - } - json!({ + // Control state is deliberately NOT advertised — not the hold, not its + // reason, not since when, and not the handback brief that only exists + // because of one. + // + // It used to be, and it was right when a worker *was* an agent *was* a + // machine *was* one implicit session: "this worker is held" and "this + // session is held" were the same sentence. They are not any more. An agent + // now runs N sessions ([`HubWorker::max_sessions`]), a person takes *one* of + // them, and this flag has no room to say which — so a backend that folds + // held-state onto its ledger by `agentId` would mark every pending task on + // the agent as held, including the ones running perfectly well in other + // sessions. Emitting something wrong is worse than emitting nothing: the + // wrong thing is acted on. + // + // Saying it correctly needs session identity on the wire, which is only + // actionable once a dispatch can name a session (spec §C3, deferred). Until + // then this stays local: [`HubWorker::control`] and the whole take / + // hand-back path are unchanged and still decide medulla's *own* dispatch + // (`session_for` skips a session an operator holds; a held in-flight task + // suspends and is delivered by the hand-back turn). None of it crosses the + // wire, and the backend learns what happened the only way that cannot be + // mis-keyed: through the task's own result. + // + // The host this agent runs on, when this hub knows which one. The backend + // prefers a supplied id and only synthesizes `host:${socketId}` as a last + // resort, so saying it here is what stops five machines behind one hub + // socket from collapsing into one synthetic host. + // + // Blank means this hub did not say — a remote peer the operator added by + // address, which has no declared host — and is omitted rather than sent + // empty so the backend's synthesis still applies to exactly those. + let mut agent = json!({ "id": w.id, // The name falls back to the id, not to a second constant. `agent_list` // renders `id (name)`, so two different readable tokens put the wrong @@ -107,7 +123,91 @@ fn to_agent(w: &HubWorker, catalog: &[crate::runtime::AgentTemplate]) -> Value { // fan-outs that ask for code. "tags": role_tags(&roles), "metadata": metadata, - }) + }); + // `hostId` goes ONLY on an agent with no workspace. The library's contract + // (`AgentDescriptor.hostId`) is explicit: it names the host a *local* agent + // runs on, "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 skip synthesizing + // a `workspaceId` from `metadata.workspace` (`a supplied workspaceId or + // hostId always wins`), which orphaned every agent from the + // agent→workspace→harness→host chain: `host_list` still rendered them, but + // placement reported "no agent inside is available (none declared + // there)" and no task could be dispatched. The `hosts[]` block carries host + // identity for the topology; a workspace-backed agent must not repeat it. + if w.workspace_path().is_none() { + if let Some(host_id) = host_id_of(w) { + agent["hostId"] = json!(host_id); + } + } + agent +} + +/// The host id a worker is placed on, when it declares one. +/// +/// Trimmed, and blank reads as "not declared" rather than as a host whose id is +/// the empty string — which would key every unplaced worker to the same +/// non-existent host. +fn host_id_of(w: &HubWorker) -> Option<&str> { + let host_id = w.host_id.trim(); + (!host_id.is_empty()).then_some(host_id) +} + +/// The `hosts[]` block: one entry per host the advertised agents are placed on. +/// +/// Derived from the agents rather than listed from config alone, so the two +/// halves of one payload cannot disagree: every `hostId` an agent carries has an +/// entry here, and no entry describes a host with nothing on it. A host whose +/// agents were all withheld by the liveness filter is therefore withheld too — +/// the same rule the agent list follows, applied one level up. +/// +/// `declared` is what this machine declares locally +/// ([`local_hosts`](crate::config::local_hosts)), and is the whole of how `kind` +/// is decided: a host this machine declares is `local`, and any other host an +/// agent names is one the hub merely fronts, so it is `remote`. Nothing is +/// probed to establish this, per the declaration doctrine (spec §2.1). +/// +/// `resources` is deliberately never emitted. The hub holds per-*worker* +/// capability probes, not host-level facts, and aggregating those into a host +/// resource claim would be inventing a number — the backend drops what it +/// cannot validate anyway, so an absent block is honest where a synthesised one +/// would not be. +fn to_hosts(advertised: &[&HubWorker], declared: &[crate::config::LocalHostRef]) -> Vec { + let mut hosts: Vec = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); + for w in advertised { + let Some(host_id) = host_id_of(w) else { + continue; + }; + if seen.contains(&host_id) { + continue; + } + seen.push(host_id); + let local = declared.iter().find(|host| host.id == host_id); + let mut entry = json!({ + "hostId": host_id, + "kind": if local.is_some() { "local" } else { "remote" }, + }); + // The name only exists for a host this machine declared; a host learned + // from an agent's placement has an id and nothing else to call it. + // Omitted rather than defaulted to the id, which the backend can do + // itself and which would otherwise look like an operator's choice. + if let Some(name) = local + .map(|host| host.name.trim()) + .filter(|name| !name.is_empty()) + { + entry["name"] = json!(name); + } + // The address its agents are reached at — the same value they advertise + // as `metadata.address`, taken from the agent rather than re-derived so + // the two can never disagree. + let address = w.address.trim(); + if !address.is_empty() { + entry["address"] = json!(address); + } + hosts.push(entry); + } + hosts } /// The tag set a worker advertises: `code`, plus each role's own tags. @@ -180,13 +280,23 @@ pub(super) fn register_payload( workers: &[HubWorker], online: &std::collections::HashMap, catalog: &[crate::runtime::AgentTemplate], + declared_hosts: &[crate::config::LocalHostRef], ) -> Value { - let reachable = workers.iter().filter(|w| is_reachable(w, online)); - json!({ + let reachable: Vec<&HubWorker> = workers.iter().filter(|w| is_reachable(w, online)).collect(); + let hosts = to_hosts(&reachable, declared_hosts); + let mut payload = json!({ "agents": reachable + .iter() .map(|w| to_agent(w, catalog)) .collect::>() - }) + }); + // Omitted rather than sent empty. `hosts: []` is a key that says nothing — + // no agent named a host — and this payload is re-emitted on every roster + // mutation, so a key that carries no information is only noise in a diff. + if !hosts.is_empty() { + payload["hosts"] = json!(hosts); + } + payload } /// Whether `w` should be advertised, given what presence reported. @@ -440,4 +550,4 @@ fn slug(text: &str) -> String { mod types; pub use types::SharedRoster; -pub use types::{HubWorker, SharedSubscriptionStrategy}; +pub use types::{HubWorker, SharedLocalHosts, SharedSubscriptionStrategy}; diff --git a/src/sdk/src/hub/roster/types.rs b/src/sdk/src/hub/roster/types.rs index 31c780878..76ce65543 100644 --- a/src/sdk/src/hub/roster/types.rs +++ b/src/sdk/src/hub/roster/types.rs @@ -21,9 +21,9 @@ pub struct HubWorker { /// address looks like; the backend then synthesizes a host for it exactly as /// it does today. /// - /// Carried but **not yet advertised**: emitting `hostId` (and the `hosts[]` - /// block it belongs with) is the wire change, and it is deliberately not - /// part of the declaration model landing here. + /// Advertised as the agent's `hostId`, and as one entry in the payload's + /// `hosts[]` block. Blank is omitted from both, which is what leaves the + /// backend's `host:${socketId}` synthesis in place for a peer nobody placed. pub host_id: String, /// tiny.place address (base58 cryptoId or `@handle`). /// @@ -57,7 +57,7 @@ pub struct HubWorker { /// placement, and reports "no workspaces are declared on this host" — which /// reads as an unusable fleet and makes it decline work it could in fact /// delegate. The *type* rides along in memory only for now; sending - /// `workspace` as an object is the wire change, not this one. + /// `workspace` as an object is deferred with the remote-host work. /// /// `None` for a remote peer whose working directory this hub has no way to /// know; the backend then falls back to the worker's probed `capabilities.cwd`. @@ -72,6 +72,9 @@ pub struct HubWorker { /// Never configured directly: a number an operator could raise past what the /// workspace can take is a number that corrupts a checkout. Code-plane data /// — deterministic placement reads it, and no prompt ever does (spec §4.2). + /// + /// Advertised as `metadata.maxSessions`; a zero is withheld, since a + /// capacity of nothing reads as saturated. pub max_sessions: u32, /// Who holds this worker's harness right now. /// @@ -130,6 +133,17 @@ impl HubWorker { /// The roster shared between the socket layer and the [`HubHandle`]. pub type SharedRoster = Arc>>; +/// The hosts *this machine* declares, as the advert's `hosts[]` block reads them. +/// +/// Shared and appended to rather than snapshotted at launch, for the same reason +/// the roster is: a host started mid-session must be advertised as `local` on the +/// very next registration, and a launch-time copy would describe it as a remote +/// host this hub merely fronts. +/// +/// Empty is the honest answer for a hub that hosts nothing itself — every agent +/// it advertises then belongs to somebody else's machine. +pub type SharedLocalHosts = Arc>>; + /// Live subscription-selection policy shared by the socket task path and the /// operator-facing hub handle. pub type SharedSubscriptionStrategy = Arc>; diff --git a/src/sdk/src/hub/runner/mod.rs b/src/sdk/src/hub/runner/mod.rs index 26dbacdb3..fa3578aa4 100644 --- a/src/sdk/src/hub/runner/mod.rs +++ b/src/sdk/src/hub/runner/mod.rs @@ -11,7 +11,7 @@ //! bounds, and orchestrator-driven abort. use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -98,10 +98,18 @@ const LIVENESS_TICK: Duration = Duration::from_millis(100); /// /// A bridge with no notion of reachability (the in-memory bus, every test fake) /// answers `Live` by default, so this behaves exactly like `sleep` for them. -async fn live_sleep(relay: &dyn Relay, peer: &str, window: Duration) { +/// +/// `held` is the second gate, and it is the same idea one layer up: the worker +/// reports (`crate::daemon::SESSION_HELD_STATUS_PREFIX`) that an operator has +/// taken the session serving this dispatch, and a person reading their own +/// session is no more "a crashed worker" than an unreachable one is. Held time +/// therefore does not accrue either, and the window **resumes rather than +/// resets** when the session is handed back — a worker that dies mid-hold is +/// still given up on, it just is not given up on *while* a human has it. +async fn live_sleep(relay: &dyn Relay, peer: &str, window: Duration, held: &AtomicBool) { let mut remaining = window; while !remaining.is_zero() { - if relay.liveness(peer).await == BridgeLiveness::Live { + if relay.liveness(peer).await == BridgeLiveness::Live && !held.load(Ordering::Acquire) { let step = LIVENESS_TICK.min(remaining); tokio::time::sleep(step).await; remaining -= step; @@ -409,6 +417,9 @@ impl TaskRunner { ); let (tx, mut rx) = oneshot::channel(); let activity = Arc::new(Notify::new()); + // Held for the whole attempt, not only for as long as the waiter is + // registered: the windows below read it, and the pump writes it. + let held = Arc::new(AtomicBool::new(false)); self.waiters.lock().await.insert( cid.clone(), Waiter { @@ -421,6 +432,7 @@ impl TaskRunner { reply: tx, status: status.clone(), activity: activity.clone(), + held: held.clone(), }, ); @@ -502,7 +514,7 @@ impl TaskRunner { // A frame: the peer is working. Reset the idle clock. _ = activity.notified() => continue, _ = live_sleep( - self.relay.as_ref(), &req.worker_address, self.idle_window, + self.relay.as_ref(), &req.worker_address, self.idle_window, &held, ) => { self.waiters.lock().await.remove(&cid); send_abort( @@ -514,7 +526,7 @@ impl TaskRunner { } } _ = live_sleep( - self.relay.as_ref(), &req.worker_address, self.ack_window, + self.relay.as_ref(), &req.worker_address, self.ack_window, &held, ) => { // Silence while the link was live — so the peer itself is not // answering, not the network. Reset and resend, or give up. diff --git a/src/sdk/src/hub/runner/pump/mod.rs b/src/sdk/src/hub/runner/pump/mod.rs index e4a867d02..6ac7189b9 100644 --- a/src/sdk/src/hub/runner/pump/mod.rs +++ b/src/sdk/src/hub/runner/pump/mod.rs @@ -140,6 +140,10 @@ pub(super) async fn route_frame( output_tokens: 0, }), harness: frame.harness, + // Carried through rather than re-derived: the worker is the + // only party that knows which session ran the task, and + // this frame is the only place it says so. + session_id: frame.session_id, })); } } @@ -150,6 +154,13 @@ pub(super) async fn route_frame( } TaskFrameKind::Status => { if let Some(w) = map.get(&key) { + // Control markers first, and read rather than merely forwarded: + // a held session is the one kind of silence that is not a dead + // worker, so it pauses this dispatch's no-progress window + // instead of counting against it (see [`Waiter::held`]). + if let Some(held) = control_marker(&frame.text) { + w.held.store(held, std::sync::atomic::Ordering::Release); + } if let Some(tx) = &w.status { let _ = tx.send(frame.text); } @@ -160,6 +171,24 @@ pub(super) async fn route_frame( } } +/// Whether a `status` frame announces a control change, and which way. +/// +/// `Some(true)` — an operator has taken the session serving the dispatch; +/// `Some(false)` — they handed it back and the hand-back turn has started; +/// `None` — ordinary progress chatter, which says nothing about control. +/// +/// Matched on the shared prefixes the daemon builds these from, so the two ends +/// cannot drift apart behind a copied literal. +fn control_marker(text: &str) -> Option { + if text.starts_with(crate::daemon::SESSION_HELD_STATUS_PREFIX) { + return Some(true); + } + if text.starts_with(crate::daemon::SESSION_RESUMED_STATUS_PREFIX) { + return Some(false); + } + None +} + /// The correlation key a frame routes under: its `correlationId`, or its /// `taskId` when it carries none. fn key_of(frame: &TaskFrame) -> String { diff --git a/src/sdk/src/hub/runner/types.rs b/src/sdk/src/hub/runner/types.rs index 7cd422cd4..818bbad76 100644 --- a/src/sdk/src/hub/runner/types.rs +++ b/src/sdk/src/hub/runner/types.rs @@ -19,6 +19,18 @@ pub(super) struct Waiter { /// Notified on ANY inbound frame for this dispatch — the "peer is alive" /// signal the runner's ack window waits on. pub(super) activity: Arc, + /// Whether an operator currently holds the session serving this dispatch. + /// + /// Set and cleared by the pump from the worker's control markers + /// ([`crate::daemon::SESSION_HELD_STATUS_PREFIX`]), read by the runner's + /// windows: held time does not accrue against the no-progress watchdog, the + /// same way time on a dead link does not. A person reading their session is + /// not a crashed worker, and a hold outlasts every window this runner has. + /// + /// Shared with the [`super::TaskRunner::run`] call rather than only living + /// in the map, so the window keeps reading it after a terminal frame has + /// removed the waiter. + pub(super) held: Arc, } /// Shared registry of in-flight dispatches, keyed by `correlationId`. pub(super) type Waiters = Arc>>; diff --git a/src/sdk/src/hub/socket/mod.rs b/src/sdk/src/hub/socket/mod.rs index a14425004..b3463d54f 100644 --- a/src/sdk/src/hub/socket/mod.rs +++ b/src/sdk/src/hub/socket/mod.rs @@ -102,6 +102,7 @@ pub(super) async fn connect_harness( let HarnessWiring { roster, catalog, + local_hosts, runner, subscription_strategy, log, @@ -110,6 +111,7 @@ pub(super) async fn connect_harness( } = wiring; let connect_roster = roster.clone(); let connect_catalog = catalog.clone(); + let connect_local_hosts = local_hosts.clone(); let cap_catalog = catalog.clone(); let connect_relay = runner.relay(); let connect_log = log.clone(); @@ -149,6 +151,7 @@ pub(super) async fn connect_harness( .on(Event::Connect, move |_payload, socket| { let roster = connect_roster.clone(); let catalog = connect_catalog.clone(); + let local_hosts = connect_local_hosts.clone(); let relay = connect_relay.clone(); let connect_log = connect_log.clone(); let workflows = connect_workflows.clone(); @@ -190,8 +193,15 @@ pub(super) async fn connect_harness( format!(" — withholding {} from agent_list", withheld.len()) } )); - let payload = - { register_payload(&roster.lock().expect("roster lock"), &online, &catalog) }; + // Both locks are taken and released inside this block, before + // the emit await: the hosts block and the agent list must + // describe one instant, and neither std guard may be held + // across a suspension point. + let payload = { + let workers = roster.lock().expect("roster lock"); + let local_hosts = local_hosts.lock().expect("local hosts lock"); + register_payload(&workers, &online, &catalog, &local_hosts) + }; let _ = socket.emit("medulla:register_agents", payload).await; // Beside the roster, not instead of it: the backend keys a // workflow advert to the socket that sent it and drops the whole diff --git a/src/sdk/src/hub/socket/task_run.rs b/src/sdk/src/hub/socket/task_run.rs index ca47e1cb5..acb5abf99 100644 --- a/src/sdk/src/hub/socket/task_run.rs +++ b/src/sdk/src/hub/socket/task_run.rs @@ -31,11 +31,15 @@ use super::{first_obj, str_field, wire_task_id}; /// later" is the one answer that is certainly wrong. medulla bounds the /// re-dispatch with its own attempt ceiling and exponential backoff, so this /// cannot become a hot loop against a saturated worker. -/// A workspace an operator is sitting in counts too. Nothing was attempted, and -/// the harness will be usable again the moment they hand it back — so the task -/// is deferred, not failed. It carries a `reason` on the wire (see -/// [`result_frame`]) precisely so the orchestrator can route elsewhere instead -/// of waiting on a person. +/// A checkout an operator is sitting in counts too — in the one case that still +/// reaches here. A dispatch no longer *refuses* on meeting a person: a held +/// session is not a dispatch candidate at all, and a dispatch with nothing else +/// to run in queues behind the checkout's writer instead of failing. What +/// survives is that queue running out of budget: nothing was attempted, the tree +/// will be usable again the moment the person is done, and the task is deferred +/// rather than failed. It carries a `reason` on the wire (see [`result_frame`]) +/// precisely so the orchestrator can route elsewhere instead of waiting on +/// somebody who may have left for the day. pub(in crate::hub) fn is_retryable(err: &RunError) -> bool { matches!( err, @@ -66,19 +70,49 @@ pub(in crate::hub) fn result_frame( outcome: &Result, ) -> Value { match outcome { - Ok(outcome) => json!({ - "taskId": task_id, - "ok": true, - "reply": outcome.reply, - "usage": { - "inputTokens": outcome.usage.input_tokens, - "outputTokens": outcome.usage.output_tokens, - }, - }), - // A held workspace is the one failure the orchestrator can act on + Ok(outcome) => { + let mut frame = json!({ + "taskId": task_id, + "ok": true, + "reply": outcome.reply, + "usage": { + "inputTokens": outcome.usage.input_tokens, + "outputTokens": outcome.usage.output_tokens, + }, + }); + // Which session served the task, when the worker said. Additive and + // ignorable — the backend's result handler validates nothing beyond + // `taskId` and strips no fields — and the slot it lands in already + // exists (`ManagerTaskEntry.agentSessionId`). The backend built that + // path expecting a session id back; this is medulla finally sending + // one. + // + // Reporting only. Nothing here accepts a session id *inbound*: + // targeting a specific session is a separate change with a separate + // trust story, and a task frame's `conversation` stays `None`. + if let Some(session_id) = outcome + .session_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + frame["sessionId"] = json!(session_id); + } + frame + } + // An occupied checkout is the one failure the orchestrator can act on // *specifically*, so it is the one that names itself. `reason` and // `retryAfterMs` are additive and ignorable: a backend that has never // heard of either still reads this as an ordinary retryable failure. + // + // Deliberately kept, and kept byte-identical, even though the blanket + // refusal that used to produce it is gone. It is now reached by exactly + // one path — a dispatch that queued behind a person for its whole + // budget — and that path needs precisely this frame: the backend already + // treats `harnessHeld` as retryable, so replacing it with a terminal + // error would turn a task that was never attempted into one nobody + // retries. The rule is that a dispatch ends in a real result or a real + // error; this is the error half. Err(err @ RunError::Held(_)) => json!({ "taskId": task_id, "ok": false, diff --git a/src/sdk/src/hub/socket/types.rs b/src/sdk/src/hub/socket/types.rs index f97a80c82..d40181b2d 100644 --- a/src/sdk/src/hub/socket/types.rs +++ b/src/sdk/src/hub/socket/types.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use super::super::roster::{SharedRoster, SharedSubscriptionStrategy}; +use super::super::roster::{SharedLocalHosts, SharedRoster, SharedSubscriptionStrategy}; use super::super::runner::TaskRunner; use super::super::types::HubLog; use super::super::workflows::WorkflowPlane; @@ -22,6 +22,9 @@ pub(in super::super) struct HarnessWiring { /// Agent-role definitions used to decorate roster adverts and constrain /// capability replies. pub catalog: Arc>, + /// The hosts this machine declares, for the advert's `hosts[]` block — the + /// one thing that says which of the advertised agents run *here*. + pub local_hosts: SharedLocalHosts, /// Where a delegated task is dispatched. pub runner: Arc, /// How an untargeted task chooses among a worker's provider subscriptions. diff --git a/src/sdk/src/hub/tests/dispatch/harness/mod.rs b/src/sdk/src/hub/tests/dispatch/harness/mod.rs index ef9d8f03f..367b0be18 100644 --- a/src/sdk/src/hub/tests/dispatch/harness/mod.rs +++ b/src/sdk/src/hub/tests/dispatch/harness/mod.rs @@ -15,10 +15,18 @@ use tokio::sync::Mutex; use crate::bridge::InboundMessage; use crate::hub::{Relay, TaskRequest}; use crate::protocol::{ - decode_task_frame, encode_task_frame_with_usage, AgentCapabilities, EncodeFrameInput, - TaskFrameKind, TokenUsage, WorkerSystemInfo, + decode_task_frame, encode_task_frame_with_attachments, encode_task_frame_with_usage, + AgentCapabilities, EncodeFrameInput, FrameAttachments, TaskFrameKind, TokenUsage, + WorkerSystemInfo, }; +/// The harness session this fake worker reports on every terminal `reply`. +/// +/// A real daemon opens exactly one session per task and names it on the reply, +/// which is the only way the id ever travels back up. Stamping it here keeps the +/// fake honest about that. +pub(in crate::hub::tests) const FAKE_SESSION_ID: &str = "sess-fake-01"; + impl FakeWorker { /// The kinds of frame the runner has sent us, in order. pub(in crate::hub::tests) async fn sent_kinds(&self) -> Vec { @@ -152,6 +160,35 @@ impl Relay for FakeWorker { usage, ), }; + // A terminal reply names the session that served the task; nothing else + // does. `mk` stays session-free so an ack or a status cannot claim one. + let reply = |text: &str, usage| InboundMessage { + from: to.to_string(), + text: encode_task_frame_with_attachments( + EncodeFrameInput { + kind: TaskFrameKind::Reply, + task_id: task_id.clone(), + text: text.to_string(), + ts: "T".to_string(), + correlation_id: cid.clone(), + harness: None, + provider: None, + custom_harness: None, + model: None, + tool_mode: None, + workflow: None, + workflow_fingerprint: None, + workflow_inputs: Default::default(), + conversation: None, + fleet_depth: 0, + }, + FrameAttachments { + usage, + work: None, + session_id: Some(FAKE_SESSION_ID.to_string()), + }, + ), + }; let mut q = self.inbox.lock().await; // A message the pump cannot decode must be skipped, not fatal — queue one // ahead of everything so the pump's skip-and-continue path runs first. @@ -196,16 +233,9 @@ impl Relay for FakeWorker { q.push_back(mk(TaskFrameKind::Ack, "accepted", None)); q.push_back(mk(TaskFrameKind::Status, "running python audit.py", None)); match &self.mode { - Mode::ImpostorThenReply { reply, .. } => q.push_back(mk( - TaskFrameKind::Reply, - reply, - Some(TokenUsage { - input_tokens: 3, - output_tokens: 5, - }), - )), - Mode::Reply(text) | Mode::RecoverAfterReset(text) => q.push_back(mk( - TaskFrameKind::Reply, + Mode::ImpostorThenReply { reply: text, .. } + | Mode::Reply(text) + | Mode::RecoverAfterReset(text) => q.push_back(reply( text, Some(TokenUsage { input_tokens: 3, @@ -213,12 +243,15 @@ impl Relay for FakeWorker { }), )), Mode::Error(text) => q.push_back(mk(TaskFrameKind::Error, text, None)), - Mode::GarbageThenReply(text) => q.push_back(mk(TaskFrameKind::Reply, text, None)), - Mode::Chatty { statuses, reply } => { + Mode::GarbageThenReply(text) => q.push_back(reply(text, None)), + Mode::Chatty { + statuses, + reply: reply_text, + } => { for n in 0..*statuses { q.push_back(mk(TaskFrameKind::Status, &format!("working {n}"), None)); } - q.push_back(mk(TaskFrameKind::Reply, reply, None)); + q.push_back(reply(reply_text, None)); } // Ack + status already queued above; no terminal frame follows. Mode::Silent | Mode::AckOnly => {} diff --git a/src/sdk/src/hub/tests/dispatch/mod.rs b/src/sdk/src/hub/tests/dispatch/mod.rs index c502e3da8..613980e5e 100644 --- a/src/sdk/src/hub/tests/dispatch/mod.rs +++ b/src/sdk/src/hub/tests/dispatch/mod.rs @@ -498,3 +498,19 @@ async fn a_frame_from_another_peer_cannot_settle_a_dispatch() { // The impostor's status must not have reached the progress sink either. assert_eq!(rx.recv().await.as_deref(), Some("running python audit.py")); } + +#[tokio::test] +async fn a_settled_dispatch_carries_the_session_the_worker_ran_it_in() { + // The worker is the only party that knows which session served the task — + // it opened it — so the reply frame is the one place the id can enter this + // process. This pins the whole path: reply frame → pump → outcome. + let worker = FakeWorker::new(Mode::Reply("done".to_string())); + let runner = TaskRunner::start(worker, Duration::from_millis(5)); + + let outcome = runner.run(req("audit"), None).await.expect("ok"); + + assert_eq!( + outcome.session_id.as_deref(), + Some(harness::FAKE_SESSION_ID) + ); +} diff --git a/src/sdk/src/hub/tests/handoff_advert.rs b/src/sdk/src/hub/tests/handoff_advert.rs index b142228b9..574a6f20b 100644 --- a/src/sdk/src/hub/tests/handoff_advert.rs +++ b/src/sdk/src/hub/tests/handoff_advert.rs @@ -1,9 +1,24 @@ -//! What the roster advert says about who holds a harness. +//! What the roster advert says about who holds a session: **nothing**. //! -//! The advert is the transport for the whole handoff feature: it is already -//! re-emitted on every roster mutation, so a control change is already an event. -//! These pin the two properties that makes safe — that the common case stays -//! byte-stable, and that a stale invitation is never advertised. +//! It used to say a great deal — `control`, `controlReason`, `controlSince`, and +//! the handback brief — and every one of those keys was right when a worker *was* +//! an agent *was* a machine *was* one implicit session. An agent now runs N +//! sessions, a person takes *one*, and none of those keys has anywhere to put +//! which one. A backend folding held-state onto its ledger by `agentId` would +//! therefore mark every pending task on that agent as held, including the ones +//! running fine in sibling sessions. +//! +//! So these tests pin the *absence*. They are written as absence assertions +//! rather than deleted outright because the keys were once present and correct, +//! and a future reader looking at [`HubWorker::control`] — which still exists, +//! and still drives medulla's own dispatch — will otherwise reasonably conclude +//! that not advertising it is an oversight. It is not: saying it correctly needs +//! session grain on the wire, which arrives with inbound session targeting (§C3). +//! +//! The local behaviour these keys used to describe is unchanged and covered +//! elsewhere: dispatch skips a session an operator holds, an in-flight turn +//! suspends instead of being discarded, and the hand-back turn delivers the +//! task's result through the ordinary result frame. use super::super::roster::{register_payload, HubWorker}; use super::super::{HandoffControl, HarnessHandoff}; @@ -40,86 +55,95 @@ fn brief() -> HarnessHandoff { /// The advert for one worker's `metadata`. fn metadata(w: HubWorker) -> serde_json::Value { - register_payload(&[w], &no_presence(), &[])["agents"][0]["metadata"].clone() + register_payload(&[w], &no_presence(), &[], &[])["agents"][0]["metadata"].clone() } #[test] fn an_orchestrator_held_harness_says_nothing_about_control() { - // Absent means orchestrator-held. Omitting the common case is what keeps - // this advert byte-stable across the re-emissions it gets on every roster - // mutation — a key that flips on each one is a diff nobody can read. let meta = metadata(worker()); assert!(meta.get("control").is_none()); assert!(meta.get("controlReason").is_none()); assert!(meta.get("controlSince").is_none()); - // The pre-existing keys are untouched, so a backend that has never heard of - // handoff reads exactly what it read before. + // The keys that do place the agent are untouched. assert_eq!(meta["address"], "GRVaddr"); assert_eq!(meta["workspace"], "/repos/acme"); } #[test] -fn an_operator_held_harness_advertises_the_hold_with_its_reason() { - let meta = metadata(HubWorker { +fn an_operator_held_agent_advertises_exactly_what_an_unheld_one_does() { + // The grain mismatch, stated as a test. Control is per-agent here and + // per-session in the model, so "held" on this advert cannot say *which* of + // the agent's sessions a person took — and a backend keying held-state by + // `agentId` would apply it to every task the agent is running. + // + // Do not "restore" these keys. Advertising a hold needs the session id the + // hold is about, which needs inbound session targeting (§C3). + let held = metadata(HubWorker { control: HandoffControl::Operator, control_reason: Some("pairing on the auth migration".to_string()), control_since: Some(1_753_420_000_000), ..worker() }); - // "operator", not "user": the orchestrator reasons about operators, and one - // word per concept is worth more than matching the local enum's variant - // name. - assert_eq!(meta["control"], "operator"); - assert_eq!(meta["controlReason"], "pairing on the auth migration"); - assert_eq!(meta["controlSince"], 1_753_420_000_000i64); -} - -#[test] -fn a_hold_with_no_reason_omits_the_key_rather_than_sending_blank() { - let meta = metadata(HubWorker { - control: HandoffControl::Operator, - control_reason: Some(" ".to_string()), - ..worker() - }); - - assert_eq!(meta["control"], "operator"); - assert!( - meta.get("controlReason").is_none(), - "whitespace is not a reason" + assert!(held.get("control").is_none(), "a hold is local state"); + assert!(held.get("controlReason").is_none()); + assert!(held.get("controlSince").is_none()); + assert_eq!( + held, + metadata(worker()), + "taking a session must not change one byte of the agent's advert" ); } #[test] -fn a_handed_back_harness_carries_its_brief() { +fn a_handed_back_harness_does_not_carry_its_brief() { + // The brief is per *session* — it names one (`sessionId`, and a transcript + // from that one pty) — but this slot is per *agent*, so two sessions handed + // back on one agent silently overwrite each other and the reader cannot tell + // that happened. It was also emitted through the same per-agent control gate + // as the keys above, which means whether an operator saw a brief at all + // depended on whether some *unrelated* session of that agent was held. + // + // With control off the wire this would be the last piece of agent-grain + // control state left on it: a brief exists only because a person took a + // session and gave it back. It travels again when a brief can name its + // session on the wire (§C3). let meta = metadata(HubWorker { handoff: Some(brief()), ..worker() }); - assert!(meta.get("control").is_none(), "the orchestrator holds it"); - assert_eq!(meta["handoff"]["id"], "w_3-1"); - assert_eq!(meta["handoff"]["workspacePath"], "/repos/acme"); - assert_eq!(meta["handoff"]["branch"], "feat/login"); - assert_eq!(meta["handoff"]["note"], "stuck on the failing e2e"); - assert_eq!(meta["handoff"]["transcriptTruncated"], false); + assert!(meta.get("handoff").is_none()); + assert_eq!( + meta, + metadata(worker()), + "a handback must not change the agent's advert either" + ); } #[test] -fn a_brief_on_a_re_taken_harness_is_not_advertised() { - // The stale-invitation case. A brief says "continue this work here"; on a - // harness the operator has since taken back, acting on it is refused. Left - // advertised it would cost the orchestrator a planning pass every cycle. - let meta = metadata(HubWorker { +fn the_local_hold_state_survives_even_though_it_is_never_advertised() { + // The other half of the change, and the reason `control` is still a field: + // medulla's own dispatch reads it. Dropping the advert keys must not be + // mistaken for dropping the feature — the roster still knows, it just does + // not tell the backend something the backend cannot key correctly. + let held = HubWorker { control: HandoffControl::Operator, + control_reason: Some("pairing on the auth migration".to_string()), + control_since: Some(1_753_420_000_000), handoff: Some(brief()), ..worker() - }); + }; - assert_eq!(meta["control"], "operator"); - assert!( - meta.get("handoff").is_none(), - "an invitation into a workspace the operator holds is not actionable" + assert!(held.control.is_operator()); + assert_eq!( + held.control_reason.as_deref(), + Some("pairing on the auth migration") + ); + assert_eq!(held.control_since, Some(1_753_420_000_000)); + assert_eq!( + held.handoff.as_ref().map(|b| b.session_id.as_str()), + Some("w_3") ); } diff --git a/src/sdk/src/hub/tests/held.rs b/src/sdk/src/hub/tests/held.rs index 49164bc65..8fb6080d5 100644 --- a/src/sdk/src/hub/tests/held.rs +++ b/src/sdk/src/hub/tests/held.rs @@ -1,4 +1,12 @@ -//! Refusing a task because a person is working in the workspace. +//! Deferring a task because a person is working in the checkout. +//! +//! Narrower than it was. A dispatch no longer refuses on meeting a person — +//! their session is simply not a candidate, and a dispatch with nothing else to +//! run in queues behind the checkout's writer. `RunError::Held` is what that +//! queue reports when it outlives the caller's budget, which is the one path +//! left to it and the reason the shape below is unchanged: the backend already +//! reads `harnessHeld` as retryable, and a task that was never attempted must +//! not come back as one that failed. //! //! Two halves, and they have to agree. The daemon can only say so in the text of //! an `error` frame, so [`settle`](super::super::runner) has to recognise that @@ -95,6 +103,7 @@ fn a_successful_task_reports_its_reply_and_usage() { output_tokens: 5, }, harness: None, + session_id: None, }), ); @@ -104,3 +113,60 @@ fn a_successful_task_reports_its_reply_and_usage() { assert_eq!(frame["usage"]["outputTokens"], 5); assert!(frame.get("retryable").is_none()); } + +/// The half of C2 that costs nothing on the backend: the result names the +/// session that served the task, so a manager's ledger can record *where* the +/// work happened. Additive — the backend's result handler validates nothing +/// beyond `taskId` — and the slot it lands in already exists. +#[test] +fn a_result_reports_the_session_that_served_the_task() { + let frame = result_frame( + "t1", + &Ok(TaskOutcome { + reply: "done".to_string(), + usage: crate::protocol::TokenUsage { + input_tokens: 0, + output_tokens: 0, + }, + harness: None, + session_id: Some("sess-42".to_string()), + }), + ); + + assert_eq!(frame["sessionId"], "sess-42"); +} + +/// A worker that reported no session — one that predates the key, or a workflow +/// run, which is a graph rather than one session — leaves the key absent. Blank +/// counts as absent too: `""` would be recorded as a session id nothing can +/// resume. +#[test] +fn a_result_claims_no_session_the_worker_did_not_report() { + let outcome = |session_id: Option<&str>| { + Ok(TaskOutcome { + reply: "done".to_string(), + usage: crate::protocol::TokenUsage { + input_tokens: 0, + output_tokens: 0, + }, + harness: None, + session_id: session_id.map(str::to_string), + }) + }; + + assert!(result_frame("t1", &outcome(None)) + .get("sessionId") + .is_none()); + assert!(result_frame("t1", &outcome(Some(" "))) + .get("sessionId") + .is_none()); +} + +/// A failure has no session to attach: the frame keeps exactly the shape it had, +/// so nothing downstream starts reading a key that is only ever there on the +/// success path. +#[test] +fn a_failed_result_carries_no_session() { + let frame = result_frame("t1", &Err(RunError::Worker("boom".into()))); + assert!(frame.get("sessionId").is_none()); +} diff --git a/src/sdk/src/hub/tests/held_watchdog.rs b/src/sdk/src/hub/tests/held_watchdog.rs new file mode 100644 index 000000000..111de296b --- /dev/null +++ b/src/sdk/src/hub/tests/held_watchdog.rs @@ -0,0 +1,250 @@ +//! The third gate on the no-progress watchdog: a session a person is holding. +//! +//! [`liveness`](super::liveness) covers the first two — a window only accrues +//! while the link to that peer is live. This is the same idea one layer up. A +//! worker whose session an operator has taken says so +//! ([`SESSION_HELD_STATUS_PREFIX`](crate::daemon::SESSION_HELD_STATUS_PREFIX)), +//! and from that frame until the hand-back it sends nothing at all — the harness +//! is not running a turn, a human is typing in it. Thirty minutes of that is +//! indistinguishable from a crashed worker to a clock that only counts frames, +//! and the old answer was to reap the dispatch: the task died while the person +//! was still working, and the only notice anyone got was `bridge task timed out`. +//! +//! The three tests here are deliberately a set, because any one alone would pass +//! for the wrong reason: +//! +//! - [`a_held_session_outlasts_the_no_progress_window`] proves the clock pauses. +//! - [`a_silent_worker_that_never_reported_a_hold_still_times_out`] proves it was +//! *gated*, not deleted — the same test would pass if the watchdog had simply +//! been removed. +//! - [`a_worker_that_dies_after_the_hand_back_is_still_given_up_on`] proves the +//! pause ends where it should. A hold that leaked past the hand-back would +//! make a dead worker unreapable for ever, which is exactly the leak the +//! window exists to prevent. + +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use crate::bridge::{BridgeLiveness, InboundMessage}; +use crate::hub::{Relay, RunError, TaskRequest, TaskRunner}; +use crate::protocol::{decode_task_frame, encode_task_frame, EncodeFrameInput, TaskFrameKind}; + +/// Comfortably longer than the runner's 240 s no-progress window, and about as +/// long as a person actually keeps a session: the point is the ratio. +const HOLD: Duration = Duration::from_secs(1_800); + +/// Longer than the window too, so a resumed clock has room to fire. +const AFTER_HAND_BACK: Duration = Duration::from_secs(600); + +/// A worker that acks, then goes quiet in the way a held session does. +struct HoldingPeer { + inbox: Mutex>, + /// The correlation id of the dispatch, learned from the frame we were sent. + correlation: Mutex>, + /// Whether to announce the hold at all. `false` is a worker that simply + /// stopped talking — a crash, which must still be reaped. + announces: bool, +} + +impl HoldingPeer { + fn new(announces: bool) -> Arc { + Arc::new(HoldingPeer { + inbox: Mutex::new(VecDeque::new()), + correlation: Mutex::new(None), + announces, + }) + } + + /// Queue one frame from the worker, under the dispatch's correlation id. + async fn emit(&self, kind: TaskFrameKind, text: &str) { + let correlation = self.correlation.lock().await.clone(); + let body = encode_task_frame(EncodeFrameInput { + kind, + task_id: "t1".to_string(), + text: text.to_string(), + ts: crate::clock::iso_now(), + correlation_id: correlation, + harness: None, + provider: None, + custom_harness: None, + model: None, + tool_mode: None, + workflow: None, + workflow_fingerprint: None, + workflow_inputs: Default::default(), + conversation: None, + fleet_depth: 0, + }); + self.inbox.lock().await.push_back(InboundMessage { + from: "host-a".to_string(), + text: body, + }); + } + + /// The status frame the worker sends when an operator takes the session. + async fn report_held(&self) { + self.emit( + TaskFrameKind::Status, + &format!( + "{} · the codex turn is suspended, not lost", + crate::daemon::SESSION_HELD_STATUS_PREFIX + ), + ) + .await; + } + + /// The status frame that ends the hold and restarts the hand-back turn. + async fn report_resumed(&self) { + self.emit( + TaskFrameKind::Status, + &format!( + "{} · reviewing what changed", + crate::daemon::SESSION_RESUMED_STATUS_PREFIX + ), + ) + .await; + } + + /// The hand-back turn's answer — the task's result. + async fn reply(&self) { + self.emit(TaskFrameKind::Reply, "finished after the hand-back") + .await; + } +} + +#[async_trait] +impl Relay for HoldingPeer { + async fn send(&self, _to: &str, body: &str) -> Result<(), String> { + let Some(frame) = decode_task_frame(body) else { + return Ok(()); + }; + if frame.kind != TaskFrameKind::Task { + return Ok(()); + } + *self.correlation.lock().await = frame.correlation_id.clone(); + // Alive, and working — then a person takes the session and the frames + // stop. + self.emit(TaskFrameKind::Ack, "task accepted").await; + if self.announces { + self.report_held().await; + } + Ok(()) + } + + async fn drain_inbox(&self, limit: i64) -> Vec { + if limit <= 0 { + return Vec::new(); + } + let mut inbox = self.inbox.lock().await; + let count = usize::try_from(limit) + .unwrap_or(usize::MAX) + .min(inbox.len()); + inbox.drain(..count).collect() + } + + async fn request_contact(&self, _peer: &str) -> Result<(), String> { + Ok(()) + } + + async fn contact_accepted(&self, _peer: &str) -> bool { + true + } + + async fn reset_session(&self, _peer: &str) {} + + async fn liveness(&self, _peer: &str) -> BridgeLiveness { + BridgeLiveness::Live + } +} + +/// The dispatch every test here runs. +fn req() -> TaskRequest { + TaskRequest { + task_id: "t1".to_string(), + abort_id: "t1".to_string(), + cycle_id: Some("c1".to_string()), + instruction: "finish the migration".to_string(), + worker_address: "host-a".to_string(), + provider: None, + custom_harness: None, + model: None, + tool_mode: None, + workflow: None, + workflow_fingerprint: None, + workflow_inputs: Default::default(), + conversation: None, + fleet_depth: 0, + } +} + +#[tokio::test(start_paused = true)] +async fn a_held_session_outlasts_the_no_progress_window() { + // Half an hour of a person working in the session, against a 240-second + // window. Ungated, this dispatch is reaped ~28 minutes before its result + // exists — and reaped is not merely "late": the runner sends the worker an + // abort, so the hand-back turn would never even run. + let peer = HoldingPeer::new(true); + let runner = TaskRunner::start(peer.clone() as Arc, Duration::from_millis(10)); + + let operator = { + let peer = peer.clone(); + tokio::spawn(async move { + tokio::time::sleep(HOLD).await; + peer.report_resumed().await; + peer.reply().await; + }) + }; + + let outcome = runner.run(req(), None).await; + operator.await.expect("the operator task completes"); + + let outcome = outcome.expect("a held session must not fail its task"); + assert_eq!(outcome.reply, "finished after the hand-back"); +} + +#[tokio::test(start_paused = true)] +async fn a_silent_worker_that_never_reported_a_hold_still_times_out() { + // The gate is a gate. This peer acks and then dies, saying nothing about + // control — the exact case the window exists for, and the one a deleted + // window would leave pinned for ever. + let peer = HoldingPeer::new(false); + let runner = TaskRunner::start(peer as Arc, Duration::from_millis(10)); + + let outcome = runner.run(req(), None).await; + + assert!( + matches!(outcome, Err(RunError::Timeout)), + "a worker that acked and vanished must still be given up on: {outcome:?}" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_worker_that_dies_after_the_hand_back_is_still_given_up_on() { + // The pause ends with the hold. A worker that reports the hand-back and then + // stops talking is a crashed worker again, and must be reaped on the same + // schedule as any other — otherwise one hold makes a dispatch immortal. + let peer = HoldingPeer::new(true); + let runner = TaskRunner::start(peer.clone() as Arc, Duration::from_millis(10)); + + let operator = { + let peer = peer.clone(); + tokio::spawn(async move { + tokio::time::sleep(HOLD).await; + // Handed back — and then nothing. No reply ever comes. + peer.report_resumed().await; + tokio::time::sleep(AFTER_HAND_BACK).await; + }) + }; + + let outcome = runner.run(req(), None).await; + operator.await.expect("the operator task completes"); + + assert!( + matches!(outcome, Err(RunError::Timeout)), + "the window must resume when the hold ends: {outcome:?}" + ); +} diff --git a/src/sdk/src/hub/tests/mod.rs b/src/sdk/src/hub/tests/mod.rs index acd92a9df..540cb5562 100644 --- a/src/sdk/src/hub/tests/mod.rs +++ b/src/sdk/src/hub/tests/mod.rs @@ -3,13 +3,15 @@ //! its attribution; [`roster`] covers advertising, addressing and dedupe; //! [`dispatch`] the sender-runner's full dispatch/route/settle path against a //! fake worker; [`liveness`] the two-layer timeout gate of host-link protocol -//! §6.3. +//! §6.3; [`held_watchdog`] the third gate on that window — a session an operator +//! is holding. mod activity; mod capabilities; mod dispatch; mod handoff_advert; mod held; +mod held_watchdog; mod liveness; mod roster; mod system_info; diff --git a/src/sdk/src/hub/tests/roster.rs b/src/sdk/src/hub/tests/roster.rs index 10d92ef18..13fd03297 100644 --- a/src/sdk/src/hub/tests/roster.rs +++ b/src/sdk/src/hub/tests/roster.rs @@ -190,7 +190,7 @@ fn subscription_routing_excludes_not_ready_and_fails_open_without_numbers() { #[test] fn register_payload_advertises_id_address_and_harness() { - let payload = register_payload(&[worker("w1", "GRVaddr")], &no_presence(), &[]); + let payload = register_payload(&[worker("w1", "GRVaddr")], &no_presence(), &[], &[]); let agents = payload.get("agents").unwrap().as_array().unwrap(); assert_eq!(agents.len(), 1); assert_eq!(agents[0]["id"], "w1"); @@ -206,7 +206,7 @@ fn register_payload_advertises_id_address_and_harness() { fn register_payload_advertises_a_known_workspace() { let mut w = worker("this-device", "this-device"); w.workspace = Some(crate::runtime::WorkspaceRef::checkout("/srv/repos/medulla")); - let payload = register_payload(&[w], &no_presence(), &[]); + let payload = register_payload(&[w], &no_presence(), &[], &[]); let agents = payload.get("agents").unwrap().as_array().unwrap(); assert_eq!(agents[0]["metadata"]["workspace"], "/srv/repos/medulla"); } @@ -216,13 +216,13 @@ fn register_payload_advertises_a_known_workspace() { /// win that fallback and place the agent nowhere. #[test] fn register_payload_omits_an_unknown_or_blank_workspace() { - let payload = register_payload(&[worker("w1", "GRVaddr")], &no_presence(), &[]); + let payload = register_payload(&[worker("w1", "GRVaddr")], &no_presence(), &[], &[]); let agents = payload.get("agents").unwrap().as_array().unwrap(); assert!(agents[0]["metadata"].get("workspace").is_none()); let mut blank = worker("w2", "ADDR2"); blank.workspace = Some(crate::runtime::WorkspaceRef::checkout(" ")); - let payload = register_payload(&[blank], &no_presence(), &[]); + let payload = register_payload(&[blank], &no_presence(), &[], &[]); let agents = payload.get("agents").unwrap().as_array().unwrap(); assert!(agents[0]["metadata"].get("workspace").is_none()); } @@ -268,7 +268,7 @@ fn an_advertised_worker_is_online_so_it_can_be_auto_assigned() { // availability is exactly "online". Advertising a blank one excluded this // hub's workers from every fan-out, and rendered as an empty column in // agent_list — which reads as a broken row, not an idle worker. - let payload = register_payload(&[worker("w1", "GRVaddr")], &no_presence(), &[]); + let payload = register_payload(&[worker("w1", "GRVaddr")], &no_presence(), &[], &[]); let agents = payload.get("agents").unwrap().as_array().unwrap(); assert_eq!(agents[0]["availability"], "online"); } @@ -450,7 +450,12 @@ fn an_unlabelled_worker_advertises_one_token_not_two() { // `agent_list` renders `id (name)`. When those differ and both read as // names, the model picks one and may pick the unroutable one — which is the // original bug. Unlabelled, they must coincide. - let payload = register_payload(&[worker("claude-worker", "3Hob1Fxu")], &no_presence(), &[]); + let payload = register_payload( + &[worker("claude-worker", "3Hob1Fxu")], + &no_presence(), + &[], + &[], + ); let agents = payload.get("agents").unwrap().as_array().unwrap(); assert_eq!(agents[0]["id"], "claude-worker"); assert_eq!( @@ -461,7 +466,7 @@ fn an_unlabelled_worker_advertises_one_token_not_two() { // A labelled one keeps its human name; the id stays a visible slug of it. let mut labelled = worker("sanil-laptop", "3Hob1Fxu"); labelled.label = Some("Sanil Laptop".to_string()); - let payload = register_payload(&[labelled], &no_presence(), &[]); + let payload = register_payload(&[labelled], &no_presence(), &[], &[]); let agents = payload.get("agents").unwrap().as_array().unwrap(); assert_eq!(agents[0]["id"], "sanil-laptop"); assert_eq!(agents[0]["name"], "Sanil Laptop"); @@ -477,6 +482,7 @@ fn a_worker_the_relay_reports_down_is_withheld_entirely() { &[worker("live", "GRVlive"), worker("dead", "GRVdead")], &online, &[], + &[], ); let agents = payload["agents"].as_array().expect("an agent list"); @@ -490,7 +496,7 @@ fn a_worker_the_relay_reports_down_is_withheld_entirely() { #[test] fn a_worker_the_relay_reports_up_is_advertised() { let online = std::collections::HashMap::from([("GRVaddr".to_string(), true)]); - let payload = register_payload(&[worker("w1", "GRVaddr")], &online, &[]); + let payload = register_payload(&[worker("w1", "GRVaddr")], &online, &[], &[]); assert_eq!(payload["agents"].as_array().expect("agents").len(), 1); assert_eq!(payload["agents"][0]["availability"], "online"); } @@ -503,6 +509,7 @@ fn no_answer_from_the_relay_advertises_everything() { &[worker("w1", "GRVone"), worker("w2", "GRVtwo")], &no_presence(), &[], + &[], ); assert_eq!(payload["agents"].as_array().expect("agents").len(), 2); } @@ -537,7 +544,7 @@ fn a_toggled_role_reaches_the_orchestrator_as_description_tags_and_id() { let mut w = worker("claude-worker-2", "GRVaddr"); w.roles = vec!["code-reviewer".to_string()]; - let payload = register_payload(&[w], &no_presence(), &catalog); + let payload = register_payload(&[w], &no_presence(), &catalog, &[]); let agent = &payload["agents"][0]; assert!( @@ -568,6 +575,7 @@ fn a_worker_with_no_roles_is_advertised_exactly_as_before() { &[worker("w1", "GRVaddr")], &no_presence(), &crate::agents::default_templates(), + &[], ); let agent = &payload["agents"][0]; assert_eq!(agent["description"], "claude daemon"); @@ -582,7 +590,12 @@ fn a_role_the_catalog_does_not_have_is_dropped_rather_than_advertised() { // backs. let mut w = worker("w1", "GRVaddr"); w.roles = vec!["deleted-role".to_string()]; - let payload = register_payload(&[w], &no_presence(), &crate::agents::default_templates()); + let payload = register_payload( + &[w], + &no_presence(), + &crate::agents::default_templates(), + &[], + ); assert_eq!(payload["agents"][0]["description"], "claude daemon"); // Including from `metadata.roles`, which is the join key: an id with no // template behind it hands a downstream lookup a key that resolves to @@ -598,7 +611,12 @@ fn a_role_the_catalog_does_not_have_is_dropped_rather_than_advertised() { fn metadata_roles_carries_only_the_ids_the_catalog_resolves() { let mut w = worker("w1", "GRVaddr"); w.roles = vec!["code-reviewer".to_string(), "deleted-role".to_string()]; - let payload = register_payload(&[w], &no_presence(), &crate::agents::default_templates()); + let payload = register_payload( + &[w], + &no_presence(), + &crate::agents::default_templates(), + &[], + ); assert_eq!( payload["agents"][0]["metadata"]["roles"], serde_json::json!(["code-reviewer"]) @@ -630,14 +648,59 @@ fn a_declared_agents_roles_and_placement_survive_into_the_advert() { assert_eq!(w.workspace_path(), Some("/srv/api")); assert_eq!(w.max_sessions, 1); - let payload = register_payload(&[w], &no_presence(), &crate::agents::default_templates()); + let payload = register_payload( + &[w], + &no_presence(), + &crate::agents::default_templates(), + &[], + ); let agent = &payload["agents"][0]; assert_eq!(agent["metadata"]["roles"][0], "code-reviewer"); + // Placement still rides `metadata.workspace` as a path — the `{path, type}` + // object is deferred, and the backend reads both anyway. assert_eq!(agent["metadata"]["workspace"], "/srv/api"); - // The advert keeps the shape it had: placement rides `metadata.workspace` as - // a path, and neither `hostId` nor `maxSessions` is emitted yet. + // A workspace-backed agent carries NO `hostId`. The library's contract for + // `AgentDescriptor.hostId` is that it names the host a *local* agent runs + // on and "must NEVER be set on a harness-backed agent", whose host is + // derived by walking up from its workspace. Emitting it here made the + // server take its `a supplied workspaceId or hostId always wins` early + // return and skip synthesizing a `workspaceId` from `metadata.workspace` — + // which orphaned every agent from the agent→workspace→harness→host chain: + // `host_list` still rendered them, but placement answered "no agent inside + // is available (none declared there)" and nothing could dispatch. + // The host still reaches the wire, once, in the `hosts[]` block. + assert!(agent.get("hostId").is_none()); assert!(agent["metadata"].get("hostId").is_none()); - assert!(agent["metadata"].get("maxSessions").is_none()); + assert_eq!(payload["hosts"][0]["hostId"], "this-device"); + assert_eq!(agent["metadata"]["maxSessions"], 1); +} + +/// The mirror of the rule above: an agent with no workspace has nothing to walk +/// up from, so `hostId` is the only thing that can place it — and it is exactly +/// the case the library reserves the field for. +#[test] +fn a_workspaceless_agent_still_carries_its_host_id() { + let spec = crate::hub::WorkerSpec { + id: "this-device-codex".to_string(), + host_id: "this-device".to_string(), + address: "this-device".to_string(), + name: "codex".to_string(), + description: "codex on this machine".to_string(), + harness: "codex".to_string(), + workspace: None, + roles: vec![], + max_sessions: 1, + }; + let w = super::super::roster::worker_from_spec(&spec); + let payload = register_payload( + &[w], + &no_presence(), + &crate::agents::default_templates(), + &[], + ); + let agent = &payload["agents"][0]; + assert!(agent["metadata"].get("workspace").is_none()); + assert_eq!(agent["hostId"], "this-device"); } /// A remembered roster row and an env-seeded one state no capacity at all. @@ -692,3 +755,239 @@ fn a_task_is_grouped_under_the_agent_it_named_not_the_first_at_that_address() { ); assert_eq!(super::super::roster::lane_id(&[], "nobody", None), ""); } + +// ------------------------------------------------------- the topology block --- + +/// One declared local host, as `local_hosts` resolves one. +fn declared(id: &str, name: &str) -> crate::config::LocalHostRef { + crate::config::LocalHostRef { + id: id.to_string(), + name: name.to_string(), + workspace: String::new(), + primary: false, + } +} + +/// A worker placed on `host_id`, reached at that host's address. +fn placed(id: &str, host_id: &str) -> HubWorker { + HubWorker { + host_id: host_id.to_string(), + address: host_id.to_string(), + ..worker(id, host_id) + } +} + +/// The whole point of the block: five machines behind one hub socket must read +/// as five hosts, not as one synthesized `host:${socketId}`. +#[test] +fn the_advert_names_every_host_its_agents_run_on() { + let workers = [ + placed("this-device", "this-device"), + placed("this-device-codex", "this-device"), + placed("api", "local-backend"), + ]; + let declared_hosts = [ + declared("this-device", "this device"), + declared("local-backend", "backend"), + ]; + + let payload = register_payload(&workers, &no_presence(), &[], &declared_hosts); + let hosts = payload["hosts"].as_array().expect("a hosts block"); + + assert_eq!( + hosts.len(), + 2, + "one entry per host, not per agent: {hosts:?}" + ); + assert_eq!(hosts[0]["hostId"], "this-device"); + assert_eq!(hosts[0]["name"], "this device"); + assert_eq!(hosts[0]["kind"], "local"); + assert_eq!(hosts[0]["address"], "this-device"); + assert_eq!(hosts[1]["hostId"], "local-backend"); + assert_eq!(hosts[1]["name"], "backend"); + assert_eq!(hosts[1]["kind"], "local"); + + // And every agent says which of them it runs on, which is what makes the + // backend prefer these ids over its own synthesis. + let agents = payload["agents"].as_array().expect("agents"); + assert_eq!(agents[0]["hostId"], "this-device"); + assert_eq!(agents[1]["hostId"], "this-device"); + assert_eq!(agents[2]["hostId"], "local-backend"); +} + +/// `kind` is decided by the declaration and nothing else — a host this machine +/// declares is local, and any other host an agent names is one this hub merely +/// fronts. Nothing is probed to establish it. +#[test] +fn a_host_this_machine_did_not_declare_is_advertised_as_remote() { + let workers = [ + placed("mine", "this-device"), + placed("theirs", "mac-studio"), + ]; + let payload = register_payload( + &workers, + &no_presence(), + &[], + &[declared("this-device", "this device")], + ); + let hosts = payload["hosts"].as_array().expect("a hosts block"); + + assert_eq!(hosts[0]["kind"], "local"); + assert_eq!(hosts[1]["hostId"], "mac-studio"); + assert_eq!(hosts[1]["kind"], "remote"); + assert!( + hosts[1].get("name").is_none(), + "a host learned from a placement has an id and nothing to call it" + ); + assert_eq!(hosts[1]["address"], "mac-studio"); +} + +/// The peer an operator added by address: this hub has no idea which machine it +/// is. Synthesizing a host for it would invent a fact, so the key is omitted and +/// the backend's own `host:${socketId}` fallback still applies to exactly those. +#[test] +fn an_unplaced_worker_carries_no_host_and_creates_none() { + let payload = register_payload(&[worker("peer", "GRVaddr")], &no_presence(), &[], &[]); + + assert!( + payload.get("hosts").is_none(), + "an empty block is a key that says nothing: {payload}" + ); + assert!(payload["agents"][0].get("hostId").is_none()); +} + +/// The two halves of one payload must agree: a host whose agents were all +/// withheld by the liveness filter is withheld too, so no entry ever describes a +/// host with nothing on it. +#[test] +fn a_host_whose_agents_are_all_offline_is_withheld_with_them() { + let workers = [placed("live", "this-device"), placed("dead", "mac-studio")]; + let online = std::collections::HashMap::from([("mac-studio".to_string(), false)]); + let payload = register_payload( + &workers, + &online, + &[], + &[declared("this-device", "this device")], + ); + + let hosts = payload["hosts"].as_array().expect("a hosts block"); + assert_eq!(hosts.len(), 1, "{hosts:?}"); + assert_eq!(hosts[0]["hostId"], "this-device"); + assert_eq!(payload["agents"].as_array().expect("agents").len(), 1); +} + +/// Never synthesised. The hub holds per-*worker* capability probes, not +/// host-level facts, and aggregating those into a resource claim would be +/// inventing a number for a model whose whole doctrine is "declared, never +/// probed". +#[test] +fn a_host_advertises_no_resources_it_did_not_measure() { + let payload = register_payload( + &[placed("mine", "this-device")], + &no_presence(), + &[], + &[declared("this-device", "this device")], + ); + + assert!(payload["hosts"][0].get("resources").is_none()); +} + +/// The per-agent concurrency contract. Deterministic placement reads it to +/// decide whether an agent has headroom; without it the library's demotion never +/// engages. +#[test] +fn an_agent_advertises_the_sessions_it_may_run_at_once() { + let payload = register_payload(&[worker("w1", "GRVaddr")], &no_presence(), &[], &[]); + assert_eq!( + payload["agents"][0]["metadata"]["maxSessions"], 1, + "the serial checkout default" + ); + + let mut parallel = worker("w2", "ADDR2"); + parallel.max_sessions = 4; + let payload = register_payload(&[parallel], &no_presence(), &[], &[]); + assert_eq!(payload["agents"][0]["metadata"]["maxSessions"], 4); + + // Zero is withheld rather than sent: a capacity of nothing reads as + // saturated, which is the opposite of what every other omission here means. + let mut unstated = worker("w3", "ADDR3"); + unstated.max_sessions = 0; + let payload = register_payload(&[unstated], &no_presence(), &[], &[]); + assert!(payload["agents"][0]["metadata"] + .get("maxSessions") + .is_none()); +} + +/// A worker nobody placed still advertises cleanly — no workspace key, no host +/// key, and everything the backend already read left where it was. +#[test] +fn an_agent_with_no_declared_workspace_still_serializes() { + let payload = register_payload(&[worker("peer", "GRVaddr")], &no_presence(), &[], &[]); + let agent = &payload["agents"][0]; + + assert!(agent["metadata"].get("workspace").is_none()); + assert!(agent.get("hostId").is_none()); + assert_eq!(agent["id"], "peer"); + assert_eq!(agent["availability"], "online"); + assert_eq!(agent["metadata"]["address"], "GRVaddr"); + assert_eq!(agent["metadata"]["harness"], "claude"); +} + +/// The whole metadata object, pinned — including what is **not** in it. +/// +/// Control state (`control`, `controlReason`, `controlSince`) and the handback +/// brief are per-*agent* keys describing a per-*session* fact, so a backend +/// folding them by `agentId` would mark every task on the agent as held when a +/// person took one session. They are therefore not advertised at all; see +/// `hub::tests::handoff_advert` for the full reasoning and the local state that +/// replaces them. The object is pinned rather than spot-checked because a +/// regression either way is silent — the advert still parses. +#[test] +fn the_advert_metadata_carries_placement_and_never_control() { + let mut held = placed("this-device", "this-device"); + held.workspace = Some(crate::runtime::WorkspaceRef::checkout("/repos/acme")); + held.control = super::super::HandoffControl::Operator; + held.control_reason = Some(" pairing on the migration ".to_string()); + held.control_since = Some(1_753_420_600_000); + + let payload = register_payload( + &[held], + &no_presence(), + &[], + &[declared("this-device", "this device")], + ); + + assert_eq!( + payload["agents"][0]["metadata"], + serde_json::json!({ + "address": "this-device", + "harness": "claude", + "maxSessions": 1, + "workspace": "/repos/acme", + }), + "address/harness/maxSessions/workspace must not move, gain a wrapper, or \ + change spelling — and control must not appear at any grain" + ); + + // Nor does the brief a handback produces: it names one session, this slot is + // one per agent, and it exists only because a person held something. + let mut handed_back = worker("w1", "GRVaddr"); + handed_back.handoff = Some(super::super::HarnessHandoff { + id: "w_3-1".to_string(), + at: 1_753_420_600_000, + session_id: "w_3".to_string(), + harness_session_id: None, + provider: "claude".to_string(), + workspace_path: "/repos/acme".to_string(), + branch: None, + project: None, + note: None, + transcript: "…pnpm test".to_string(), + transcript_truncated: false, + }); + let payload = register_payload(&[handed_back], &no_presence(), &[], &[]); + assert!( + payload["agents"][0]["metadata"].get("handoff").is_none(), + "a per-session brief has no honest home on a per-agent advert" + ); +} diff --git a/src/sdk/src/hub/tests/workflows.rs b/src/sdk/src/hub/tests/workflows.rs index 4923f7aa6..f2e2f6b43 100644 --- a/src/sdk/src/hub/tests/workflows.rs +++ b/src/sdk/src/hub/tests/workflows.rs @@ -68,6 +68,7 @@ impl crate::flow_engine::caps::dispatch::HarnessDispatch for StubHarness { output_tokens: 0, }, harness: None, + session_id: None, }) } } diff --git a/src/sdk/src/hub/types.rs b/src/sdk/src/hub/types.rs index 63b38c2db..166c382db 100644 --- a/src/sdk/src/hub/types.rs +++ b/src/sdk/src/hub/types.rs @@ -114,6 +114,19 @@ pub struct TaskOutcome { pub usage: TokenUsage, /// The provider that actually ran the task, when the worker reported it. pub harness: Option, + /// The harness session that served the task, when the worker reported one. + /// + /// Reported, never requested: the worker opens or resumes exactly one + /// session per task and is the only party that knows which, so this is the + /// sole path by which a session id travels back up. The hub forwards it to + /// the backend as `task_result.sessionId`, which is where a manager's task + /// ledger records *where* a piece of work happened. + /// + /// `None` for a worker that predates the key, for a failed dispatch (no + /// outcome to attach it to), and for a workflow run — a graph is not one + /// session, and claiming one of its nodes' sessions would name the wrong + /// place. + pub session_id: Option, } /// Why a dispatch failed. diff --git a/src/sdk/src/protocol/frames/decode.rs b/src/sdk/src/protocol/frames/decode.rs index ca2c96787..314ccd7a6 100644 --- a/src/sdk/src/protocol/frames/decode.rs +++ b/src/sdk/src/protocol/frames/decode.rs @@ -117,6 +117,18 @@ pub fn decode_task_frame(body: &str) -> Option { Some(serde_json::Value::Object(inputs)) => inputs.clone(), Some(_) => return None, }; + // Decoded so a *response* can report which session served the task. Nothing + // on the receiving side of a `task` frame reads it: honouring an inbound + // session id is session targeting, which is deliberately not implemented — + // one caller must not be able to run inside a session opened for another. + // Blank is treated as absent, so a sender that always writes the key does + // not report a session nothing can resume. + let session_id = obj + .get("sessionId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string); Some(TaskFrame { proto: MEDULLA_TASK_PROTO.to_string(), @@ -137,6 +149,7 @@ pub fn decode_task_frame(body: &str) -> Option { fleet_depth, usage, work, + session_id, }) } diff --git a/src/sdk/src/protocol/frames/encode.rs b/src/sdk/src/protocol/frames/encode.rs index 28f648c61..d1f2f287b 100644 --- a/src/sdk/src/protocol/frames/encode.rs +++ b/src/sdk/src/protocol/frames/encode.rs @@ -3,16 +3,23 @@ use crate::harness_work::WorkSnapshot; -use super::types::{EncodeFrameInput, TaskFrame, TokenUsage, MEDULLA_TASK_PROTO}; +use super::types::{EncodeFrameInput, FrameAttachments, TaskFrame, TokenUsage, MEDULLA_TASK_PROTO}; /// Build and serialize a task frame body. pub fn encode_task_frame(input: EncodeFrameInput) -> String { - build(input, None, None).encode() + build(input, FrameAttachments::default()).encode() } /// [`encode_task_frame`] with reported token usage (reply frames). pub fn encode_task_frame_with_usage(input: EncodeFrameInput, usage: Option) -> String { - build(input, usage, None).encode() + build( + input, + FrameAttachments { + usage, + ..Default::default() + }, + ) + .encode() } /// [`encode_task_frame`] with the child harness's work snapshot attached @@ -23,15 +30,33 @@ pub fn encode_task_frame_with_work( usage: Option, work: Option, ) -> String { - build(input, usage, work).encode() + build( + input, + FrameAttachments { + usage, + work, + session_id: None, + }, + ) + .encode() } -/// Assemble the frame from its input and optional attachments. -fn build( +/// [`encode_task_frame`] with everything a *response* may carry — usage, the +/// work snapshot, and the session that served the task. +/// +/// The entry point a worker daemon uses for its terminal frames. The others +/// remain because most senders attach nothing (a `task` frame) or only usage, +/// and naming what a frame carries at the call site is what keeps an inbound +/// request from accidentally claiming a session. +pub fn encode_task_frame_with_attachments( input: EncodeFrameInput, - usage: Option, - work: Option, -) -> TaskFrame { + attachments: FrameAttachments, +) -> String { + build(input, attachments).encode() +} + +/// Assemble the frame from its input and optional attachments. +fn build(input: EncodeFrameInput, attachments: FrameAttachments) -> TaskFrame { TaskFrame { proto: MEDULLA_TASK_PROTO.to_string(), kind: input.kind, @@ -49,9 +74,19 @@ fn build( workflow_inputs: input.workflow_inputs, conversation: input.conversation, fleet_depth: input.fleet_depth, - usage, + usage: attachments.usage, // An empty snapshot says nothing and would only cost bytes on every // status frame, so it is dropped rather than sent. - work: work.filter(|snapshot| !snapshot.is_empty()).map(Box::new), + work: attachments + .work + .filter(|snapshot| !snapshot.is_empty()) + .map(Box::new), + // Blank is not a session. A worker that never opened one must leave the + // key absent rather than claim `""`, which downstream would record as a + // session id nothing can resume. + session_id: attachments + .session_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()), } } diff --git a/src/sdk/src/protocol/frames/mod.rs b/src/sdk/src/protocol/frames/mod.rs index 4f9d16c21..618b125ef 100644 --- a/src/sdk/src/protocol/frames/mod.rs +++ b/src/sdk/src/protocol/frames/mod.rs @@ -22,9 +22,12 @@ mod types; mod tests; pub use decode::{decode_task_frame, parse_agent_capabilities}; -pub use encode::{encode_task_frame, encode_task_frame_with_usage, encode_task_frame_with_work}; +pub use encode::{ + encode_task_frame, encode_task_frame_with_attachments, encode_task_frame_with_usage, + encode_task_frame_with_work, +}; pub use types::{ AgentCapabilities, BudgetSource, BudgetWindow, CustomHarnessAdvert, EncodeFrameInput, - HarnessBudget, HarnessProvider, HarnessReadiness, TaskFrame, TaskFrameKind, TokenUsage, - WorkflowAdvert, WorkflowInputAdvert, MEDULLA_TASK_PROTO, + FrameAttachments, HarnessBudget, HarnessProvider, HarnessReadiness, TaskFrame, TaskFrameKind, + TokenUsage, WorkflowAdvert, WorkflowInputAdvert, MEDULLA_TASK_PROTO, }; diff --git a/src/sdk/src/protocol/frames/tests/codec.rs b/src/sdk/src/protocol/frames/tests/codec.rs index 13cbc613c..069e3e444 100644 --- a/src/sdk/src/protocol/frames/tests/codec.rs +++ b/src/sdk/src/protocol/frames/tests/codec.rs @@ -449,3 +449,97 @@ fn custom_harness_adverts_round_trip_without_execution_or_credential_details() { caps ); } + +/// A response says which session served the task. Reported, never requested: +/// this is the only path by which a session id travels back to a caller, which +/// otherwise has no way to name where its work happened. +#[test] +fn a_response_reports_the_session_that_served_the_task() { + let body = crate::protocol::encode_task_frame_with_attachments( + reply_input(), + crate::protocol::FrameAttachments { + session_id: Some("sess-42".to_string()), + ..Default::default() + }, + ); + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(value["sessionId"], "sess-42"); + + let decoded = decode_task_frame(&body).expect("a valid frame"); + assert_eq!(decoded.session_id.as_deref(), Some("sess-42")); +} + +/// Blank is not a session, in either direction: an encoder that always writes +/// the key must not claim `""`, and a peer that sends one must not have it +/// recorded as a session id nothing can resume. +#[test] +fn a_blank_session_id_is_absent_rather_than_empty() { + let body = crate::protocol::encode_task_frame_with_attachments( + reply_input(), + crate::protocol::FrameAttachments { + session_id: Some(" ".to_string()), + ..Default::default() + }, + ); + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(value.get("sessionId").is_none()); + + let sent_blank = json!({ + "proto": MEDULLA_TASK_PROTO, + "kind": "reply", + "taskId": "cycle-1", + "text": "done", + "ts": "2026-07-18T00:00:00.000Z", + "sessionId": " ", + }) + .to_string(); + assert_eq!(decode_task_frame(&sent_blank).unwrap().session_id, None); +} + +/// An ordinary outbound task never claims a session. Session *targeting* is a +/// separate feature with a separate trust story — one caller must not be able to +/// run inside a session opened for another — so the key stays off the request +/// side entirely. +#[test] +fn a_dispatched_task_names_no_session() { + let body = encode_task_frame(EncodeFrameInput { + kind: TaskFrameKind::Task, + task_id: "cycle-1".to_string(), + text: "do the thing".to_string(), + ts: "2026-07-18T00:00:00.000Z".to_string(), + correlation_id: None, + harness: None, + provider: None, + custom_harness: None, + model: None, + tool_mode: None, + workflow: None, + workflow_fingerprint: None, + workflow_inputs: Default::default(), + conversation: None, + fleet_depth: 0, + }); + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(value.get("sessionId").is_none()); +} + +/// A `reply` frame's inputs, for the session-reporting cases. +fn reply_input() -> EncodeFrameInput { + EncodeFrameInput { + kind: TaskFrameKind::Reply, + task_id: "cycle-1".to_string(), + text: "done".to_string(), + ts: "2026-07-18T00:00:00.000Z".to_string(), + correlation_id: None, + harness: None, + provider: None, + custom_harness: None, + model: None, + tool_mode: None, + workflow: None, + workflow_fingerprint: None, + workflow_inputs: Default::default(), + conversation: None, + fleet_depth: 0, + } +} diff --git a/src/sdk/src/protocol/frames/types.rs b/src/sdk/src/protocol/frames/types.rs index 5c7a8a27a..7183738c2 100644 --- a/src/sdk/src/protocol/frames/types.rs +++ b/src/sdk/src/protocol/frames/types.rs @@ -406,6 +406,25 @@ pub struct TaskFrame { /// Reported on `reply` frames when the child harness surfaced token counts. #[serde(skip_serializing_if = "Option::is_none", default)] pub usage: Option, + /// **Response-only**: the harness session that served this task. + /// + /// A worker opens (or resumes) exactly one session per task, and it is the + /// only party that knows which. Reporting it lets the caller record *where* + /// a piece of work happened — the hub forwards it to the backend as + /// `task_result.sessionId`, which is the slot a manager's task ledger keeps + /// for it. + /// + /// Deliberately **not** honoured inbound. Naming a session to run *in* is a + /// different feature with a different trust story (one caller must not be + /// able to resume a session opened for another), and a task frame's + /// continuity request is [`conversation`](Self::conversation), which the + /// worker scopes to the authenticated sender. A frame that carries this key + /// inbound is ignored, not obeyed. + /// + /// Additive and optional in both directions: a peer that predates it omits + /// the key, and a peer that does not understand it drops it. + #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none", default)] + pub session_id: Option, /// What the child harness is working on, as of this frame. /// /// Carried on `status` and `reply` frames so an orchestrator sees the @@ -428,6 +447,24 @@ impl TaskFrame { } } +/// What a response frame carries besides its text: the numbers, the picture, and +/// the session it came from. +/// +/// A struct rather than three trailing `Option` parameters, because they are all +/// optional and all attached at the same one call site — a positional list of +/// three would be a place where two of them get silently transposed. `Default` +/// is the frame kinds that attach nothing. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct FrameAttachments { + /// Token counts the child harness reported, on reply frames. + pub usage: Option, + /// What the child harness is working on as of this frame. + pub work: Option, + /// The harness session that served the task — see + /// [`TaskFrame::session_id`]. + pub session_id: Option, +} + /// Fields needed to build and serialize a task frame. `ts` is supplied by the /// caller (an ISO-8601 timestamp) so this crate stays free of a clock dependency. #[derive(Debug, Clone)] diff --git a/src/sdk/src/protocol/mod.rs b/src/sdk/src/protocol/mod.rs index 9c5b1af61..54caf8bc7 100644 --- a/src/sdk/src/protocol/mod.rs +++ b/src/sdk/src/protocol/mod.rs @@ -32,11 +32,11 @@ pub use control::{ HARNESS_CONTROL_VERSION, }; pub use frames::{ - decode_task_frame, encode_task_frame, encode_task_frame_with_usage, - encode_task_frame_with_work, parse_agent_capabilities, AgentCapabilities, BudgetSource, - BudgetWindow, CustomHarnessAdvert, EncodeFrameInput, HarnessBudget, HarnessProvider, - HarnessReadiness, TaskFrame, TaskFrameKind, TokenUsage, WorkflowAdvert, WorkflowInputAdvert, - MEDULLA_TASK_PROTO, + decode_task_frame, encode_task_frame, encode_task_frame_with_attachments, + encode_task_frame_with_usage, encode_task_frame_with_work, parse_agent_capabilities, + AgentCapabilities, BudgetSource, BudgetWindow, CustomHarnessAdvert, EncodeFrameInput, + FrameAttachments, HarnessBudget, HarnessProvider, HarnessReadiness, TaskFrame, TaskFrameKind, + TokenUsage, WorkflowAdvert, WorkflowInputAdvert, MEDULLA_TASK_PROTO, }; pub use screen::{ apply_frame, build_frame, changed_rows, coalesce_runs, encode_screen_message, diff --git a/src/sdk/src/sessions/tests/input_tests.rs b/src/sdk/src/sessions/tests/input_tests.rs index ec53106d9..604abccc3 100644 --- a/src/sdk/src/sessions/tests/input_tests.rs +++ b/src/sdk/src/sessions/tests/input_tests.rs @@ -30,6 +30,7 @@ fn task_frame(kind: TaskFrameKind, task_id: &str, text: &str) -> TaskFrame { workflow_inputs: Default::default(), conversation: None, fleet_depth: 0, + session_id: None, } } diff --git a/src/sdk/src/sessions/types.rs b/src/sdk/src/sessions/types.rs index f059356e6..a9a90e886 100644 --- a/src/sdk/src/sessions/types.rs +++ b/src/sdk/src/sessions/types.rs @@ -75,7 +75,7 @@ impl fmt::Display for SessionClass { /// # Origin is not ownership /// /// This is deliberately **not** "who may drive it now". That is *control* -/// (`HarnessControl` in the app crate, `owner` in the spec), it moves at runtime, +/// (`SessionControl` in the app crate, `owner` in the spec), it moves at runtime, /// and the two answer different questions: /// /// | | Origin | Control / owner | diff --git a/src/sdk/src/ui/README.md b/src/sdk/src/ui/README.md index 7848594b3..3b61e7aa3 100644 --- a/src/sdk/src/ui/README.md +++ b/src/sdk/src/ui/README.md @@ -7,7 +7,7 @@ UI-facing data surface shared with the terminal app: `events` (the folded event - [`agents/`](./agents/) — Pure view-model fold: turn the flat event stream into one lane per cognitive tier plus one lane per connected roster agent / anonymous task / peer session, with a row model for the Agents list and pre-wrapped transcript lines. A port of the TS `deriveAgentLanes` / `agentRowModel` / `laneLines` essentials. - [`chat_store/`](./chat_store/) — On-disk chat persistence for the Chat tab's thread trees. - [`command/`](./command/) — Slash-command parsing, the command catalog, and the `/copy` transcript helper. -- [`decisions/`](./decisions/) — Prepared operator decisions derived from harness escalations and pending worker questions. The fold is UI-agnostic so terminal and future hosts share stable ids, ordering, deduplication, and answer routing. +- [`decisions/`](./decisions/) — Prepared operator decisions derived from agent escalations and pending worker questions. The fold is UI-agnostic so terminal and future hosts share stable ids, ordering, deduplication, and answer routing. - [`events/`](./events/) — The TUI event vocabulary: every library `CycleEvent` plus the host-sourced rows (cycle framing, conversation turns, agent/session status, effects). `TuiEvent` deserializes any JSON `{kind, ...}` shape, keeping unknown kinds as a passthrough so a newer backend never drops rows on an older TUI. - [`fleet/`](./fleet/) — Pure view-model for the Fleet view: turn the declared capacity (`Host → Harness → Workspace → Agent`) and the agent-template catalog into a flattened row model plus pre-wrapped detail lines. - [`harness/`](./harness/) — Read-only view-model helpers for the agent-harness contract: a compact task board rendering for a `HarnessStatus` payload, and a one-line budget note for an agent's `AgentBudgetMetadata` seat stamp. Pure formatting only — the `medulla-tui` crate turns the returned `Line`s / strings into ratatui spans. diff --git a/src/sdk/src/ui/command/catalog.rs b/src/sdk/src/ui/command/catalog.rs index 1cbd4b31e..6ee8f87e8 100644 --- a/src/sdk/src/ui/command/catalog.rs +++ b/src/sdk/src/ui/command/catalog.rs @@ -71,22 +71,22 @@ pub const COMMANDS: &[CommandSpec] = &[ description: "Pick up an earlier saved session", }, CommandSpec { - name: "harness", - aliases: &[], - args: "[provider] [path]", - description: "Start a harness the orchestrator will not touch", + name: "session", + aliases: &["harness"], + args: "[harness] [path]", + description: "Start a session the orchestrator will not touch", }, CommandSpec { name: "takecontrol", aliases: &["take"], args: "", - description: "Take this harness from the orchestrator", + description: "Take this session from the orchestrator", }, CommandSpec { name: "handoff", aliases: &["hand"], args: "[note]", - description: "Give this harness back, with a note on what to continue", + description: "Give this session back, with a note on what to continue", }, CommandSpec { name: "abort", diff --git a/src/sdk/src/ui/command/mod.rs b/src/sdk/src/ui/command/mod.rs index 9f6a88dbe..6d265ea63 100644 --- a/src/sdk/src/ui/command/mod.rs +++ b/src/sdk/src/ui/command/mod.rs @@ -47,7 +47,7 @@ pub fn parse(input: &str) -> Option { "quit" | "q" | "exit" => SlashCommand::Quit, "new" => SlashCommand::NewSession, "resume" => SlashCommand::Resume, - "harness" => parse_harness(arg), + "session" | "harness" => parse_session(arg), "takecontrol" | "take" => SlashCommand::TakeControl, // `arg`, not `flag`: a note is prose the orchestrator reads, so its // capitalisation is the operator's to choose. @@ -82,21 +82,21 @@ pub fn copy_text(events: &[EventEnvelope], scope: CopyScope) -> String { } } -/// Parse the argument tail of `/harness` into its provider and path. +/// Parse the argument tail of `/session` into its harness type and path. /// -/// The shapes are `/harness`, `/harness `, and -/// `/harness `. A named provider is validated here, against the -/// same [`HarnessProvider::from_wire`] the wire uses, because "claud" should say -/// so rather than silently starting the default harness — that failure is -/// invisible until the wrong CLI is already running in the operator's workspace. +/// The shapes are `/session`, `/session `, and +/// `/session `. A named harness type is validated here, against +/// the same [`HarnessProvider::from_wire`] the wire uses, because "claud" should +/// say so rather than silently starting the default CLI — that failure is +/// invisible until the wrong one is already running in the operator's workspace. /// /// The path is not validated: only the front end knows the active workspace, and /// a bad path produces a far better message at spawn time than at parse time. -fn parse_harness(arg: &str) -> SlashCommand { - const USAGE: &str = "Usage: /harness [claude|codex|opencode] [path]"; +fn parse_session(arg: &str) -> SlashCommand { + const USAGE: &str = "Usage: /session [claude|codex|opencode] [path]"; let arg = arg.trim(); if arg.is_empty() { - return SlashCommand::NewHarness { + return SlashCommand::StartSession { provider: None, path: None, }; @@ -109,7 +109,7 @@ fn parse_harness(arg: &str) -> SlashCommand { if HarnessProvider::from_wire(&provider).is_none() { return SlashCommand::BadUsage(USAGE); } - SlashCommand::NewHarness { + SlashCommand::StartSession { provider: Some(provider), path, } diff --git a/src/sdk/src/ui/command/tests.rs b/src/sdk/src/ui/command/tests.rs index 2d3dc430e..822936fc2 100644 --- a/src/sdk/src/ui/command/tests.rs +++ b/src/sdk/src/ui/command/tests.rs @@ -139,47 +139,55 @@ fn usage_renders_the_argument_hint_only_when_there_is_one() { } #[test] -fn harness_takes_an_optional_provider_and_path() { +fn session_takes_an_optional_harness_and_path() { // Bare: the front end opens its picker rather than guessing. assert_eq!( - parse("/harness"), - Some(SlashCommand::NewHarness { + parse("/session"), + Some(SlashCommand::StartSession { provider: None, path: None, }) ); assert_eq!( - parse("/harness codex"), - Some(SlashCommand::NewHarness { + parse("/session codex"), + Some(SlashCommand::StartSession { provider: Some("codex".to_string()), path: None, }) ); assert_eq!( - parse("/harness Claude ~/work/foo"), - Some(SlashCommand::NewHarness { + parse("/session Claude ~/work/foo"), + Some(SlashCommand::StartSession { provider: Some("claude".to_string()), path: Some("~/work/foo".to_string()), }), - "the provider is matched case-insensitively, the path is left alone" + "the harness type is matched case-insensitively, the path is left alone" + ); + assert_eq!( + parse("/harness"), + Some(SlashCommand::StartSession { + provider: None, + path: None, + }), + "the old spelling still works — a rename must not break muscle memory" ); } #[test] -fn an_unknown_harness_provider_is_a_usage_error_not_a_default() { - // Silently falling back to the default provider would start the wrong CLI +fn an_unknown_harness_type_is_a_usage_error_not_a_default() { + // Silently falling back to the default would start the wrong CLI // in the operator's workspace, and they would not find out until it did // something. assert_eq!( parse("/harness claud"), Some(SlashCommand::BadUsage( - "Usage: /harness [claude|codex|opencode] [path]" + "Usage: /session [claude|codex|opencode] [path]" )) ); assert_eq!( - parse("/harness ~/work/foo"), + parse("/session ~/work/foo"), Some(SlashCommand::BadUsage( - "Usage: /harness [claude|codex|opencode] [path]" + "Usage: /session [claude|codex|opencode] [path]" )), "a bare path is ambiguous with a provider name, so it is refused" ); diff --git a/src/sdk/src/ui/command/types.rs b/src/sdk/src/ui/command/types.rs index 87a9add92..0ed7b154f 100644 --- a/src/sdk/src/ui/command/types.rs +++ b/src/sdk/src/ui/command/types.rs @@ -22,22 +22,22 @@ pub enum SlashCommand { NewSession, /// `/resume` — open the saved-chat picker. Resume, - /// `/harness [provider] [path]` — start a harness the orchestrator will not + /// `/session [harness] [path]` — start a session the orchestrator will not /// dispatch into. /// /// Both arguments are optional: with neither, the front end opens its /// picker. Parsing does not validate the path — only the front end knows /// what the active workspace is, and a path that does not exist is a /// spawn-time error with a much better message than a parse-time one. - NewHarness { - /// The harness CLI to run, lowercased, when one was named. + StartSession { + /// The harness type to run, lowercased, when one was named. provider: Option, /// The working directory to start it in, when one was given. path: Option, }, - /// `/takecontrol` — take the selected harness from the orchestrator. + /// `/takecontrol` — take the selected session from the orchestrator. TakeControl, - /// `/handoff [note]` — give the selected harness back to the orchestrator, + /// `/handoff [note]` — give the selected session back to the orchestrator, /// optionally saying what you were in the middle of. HandOff { /// What the operator wants continued, in their words. The single most diff --git a/src/sdk/src/ui/decisions/README.md b/src/sdk/src/ui/decisions/README.md index 316ac20a7..a7c754e69 100644 --- a/src/sdk/src/ui/decisions/README.md +++ b/src/sdk/src/ui/decisions/README.md @@ -1,11 +1,11 @@ # Decisions -Prepared operator decisions derived from harness escalations and pending worker questions. The fold is UI-agnostic so terminal and future hosts share stable ids, ordering, deduplication, and answer routing. +Prepared operator decisions derived from agent escalations and pending worker questions. The fold is UI-agnostic so terminal and future hosts share stable ids, ordering, deduplication, and answer routing. ## Contents - [`fold.rs`](./fold.rs) — Deterministic folding of current harness/lane state into prepared decisions. -- [`mod.rs`](./mod.rs) — Prepared operator decisions derived from harness escalations and pending worker questions. The fold is UI-agnostic so terminal and future hosts share stable ids, ordering, deduplication, and answer routing. +- [`mod.rs`](./mod.rs) — Prepared operator decisions derived from agent escalations and pending worker questions. The fold is UI-agnostic so terminal and future hosts share stable ids, ordering, deduplication, and answer routing. - [`tests.rs`](./tests.rs) — Decision-fold tests for ordering, dedupe, and answered-item removal. - [`types.rs`](./types.rs) — Data shapes for the prepared-decision queue. diff --git a/src/sdk/src/ui/decisions/fold.rs b/src/sdk/src/ui/decisions/fold.rs index 58a4cdd70..95a21c7e4 100644 --- a/src/sdk/src/ui/decisions/fold.rs +++ b/src/sdk/src/ui/decisions/fold.rs @@ -39,7 +39,7 @@ fn task_excerpt(task: Option<&TrackedTask>) -> Option { .or_else(|| Some(task.title.clone())) } -/// Fold harness escalations and pending lane questions into one stable queue. +/// Fold agent escalations and pending lane questions into one stable queue. /// /// Questions are ordered by lane/task order before free-form escalations. /// Duplicate escalation strings collapse to one item. A question disappears as @@ -86,7 +86,7 @@ pub fn decision_items(status: Option<&HarnessStatus>, lanes: &[AgentLane]) -> Ve id: format!("escalation:{:016x}", stable_hash(message)), kind: DecisionKind::Escalation, question: message.to_string(), - lane_context: "harness escalation".into(), + lane_context: "agent escalation".into(), contract_excerpt: None, answer_target: None, }); diff --git a/src/sdk/src/ui/decisions/mod.rs b/src/sdk/src/ui/decisions/mod.rs index 317d7a117..ae5b7b899 100644 --- a/src/sdk/src/ui/decisions/mod.rs +++ b/src/sdk/src/ui/decisions/mod.rs @@ -1,4 +1,4 @@ -//! Prepared operator decisions derived from harness escalations and pending +//! Prepared operator decisions derived from agent escalations and pending //! worker questions. The fold is UI-agnostic so terminal and future hosts share //! stable ids, ordering, deduplication, and answer routing. diff --git a/src/sdk/src/ui/hosts/mod.rs b/src/sdk/src/ui/hosts/mod.rs new file mode 100644 index 000000000..09cd5e141 --- /dev/null +++ b/src/sdk/src/ui/hosts/mod.rs @@ -0,0 +1,31 @@ +//! The Hosts surface: `Host → Agents`, the topology the advert is a projection +//! of (spec §2.4). +//! +//! The page used to render the worker roster flat and call each row a host. That +//! was true only while a machine advertised exactly one worker; now one machine +//! declares one entry per agent, so a flat roster is a list of *agents* with the +//! host level collapsed out of it. This module puts the level back: the hosts +//! this machine runs (always present, running or not), then every remote host +//! the roster reaches, each carrying the agents known to be on it. +//! +//! Two sources, deliberately not merged into one: +//! +//! - **declarations** (`[fleet].agentDeclarations`) are the truth for a local +//! host — an agent exists because it is written down, not because something is +//! running (spec §2.1); +//! - **the roster** is the truth for a remote host, because a remote host does +//! not yet share its declared agent list over the link (plan §D1). Until it +//! does, a remote host shows what the roster knows about it and says so, +//! rather than pretending this machine declared anything over there. +//! +//! The rows are [`types`]; folding the two sources into the tree is +//! [`projection`]. + +mod projection; +mod types; + +pub use projection::host_rows; +pub use types::{HostAgentRow, HostKind, HostRow}; + +#[cfg(test)] +mod tests; diff --git a/src/sdk/src/ui/hosts/projection.rs b/src/sdk/src/ui/hosts/projection.rs new file mode 100644 index 000000000..1922ef0ac --- /dev/null +++ b/src/sdk/src/ui/hosts/projection.rs @@ -0,0 +1,272 @@ +//! Folding the declarations and the live roster into the `Host → Agents` tree. +//! +//! The one place the two sources meet. Which of them is authoritative depends on +//! the host: a local host's agents come from what this machine wrote down, a +//! remote host's from what the roster reached (see [`super`]) — so the join is +//! stated once, here, rather than being re-derived by each tab that draws it. + +use super::{HostAgentRow, HostKind, HostRow}; +use crate::config::LocalHostRef; +use crate::runtime::{AgentDeclaration, WorkerInfo}; + +/// Build the `Host → Agents` tree. +/// +/// `locals` is every host this machine declares (see +/// [`local_hosts`](crate::config::local_hosts)); it leads the result in that +/// order, whether or not anything is running on it — a declared host with +/// nothing in the roster is still where its agents live, and hiding it would +/// make "the local host is always present" false exactly when the operator needs +/// to see why nothing is being dispatched. +/// +/// `workers` is the live roster. Entries whose address matches a local host fill +/// in what is running there; the rest are grouped by address into one remote +/// host each, in first-seen order. +/// +/// A declaration naming a host that `locals` does not contain still gets a host +/// row of its own, after the configured ones: it was declared on this machine, +/// so it is local and editable, and dropping it would hide agents the operator +/// wrote down. +pub fn host_rows( + workers: &[WorkerInfo], + declarations: &[AgentDeclaration], + locals: &[LocalHostRef], +) -> Vec { + let mut rows: Vec = Vec::new(); + let mut claimed: Vec<&str> = Vec::new(); + + // The first local host also claims the agents that name no host at all: an + // agent nothing places belongs to the machine looking at it. They are + // declared in *this* config, so the alternative is not "somewhere else" but + // "nowhere" — an agent written down and rendered by neither tab. + for (index, local) in locals.iter().enumerate() { + rows.push(local_row( + &local.id, + &local.name, + workers, + declarations, + index == 0, + &mut claimed, + )); + } + // Declared, but on a host id this config does not (or no longer) describes. + // An unplaced agent lands here only when there is no local host to claim it, + // which is the one arrangement where it would otherwise vanish. + for declaration in declarations { + let host_id = declaration.host_id.trim(); + if rows.iter().any(|row| row.id == host_id) { + continue; + } + if host_id.is_empty() && !locals.is_empty() { + continue; + } + let label = if host_id.is_empty() { + "this device" + } else { + host_id + }; + rows.push(local_row( + host_id, + label, + workers, + declarations, + host_id.is_empty(), + &mut claimed, + )); + } + // Everything left in the roster is somebody else's machine. + for worker in workers { + if claimed.contains(&worker.id.as_str()) { + continue; + } + let address = worker.address.trim(); + match rows + .iter_mut() + .find(|row| row.kind == HostKind::Remote && row.id == address) + { + Some(row) => { + // The host preview reads its capacity, readiness and budgets off + // this one entry, so it has to be an entry that has any: keeping + // whichever agent arrived first left the machine reading "not + // reported" while a probed sibling on the same address held the + // answer. A probed pick is never given up for a later one. + let probed = row + .detail_worker + .as_deref() + .and_then(|id| workers.iter().find(|held| held.id == id)) + .is_some_and(has_probe_details); + if row.detail_worker.is_none() || (!probed && has_probe_details(worker)) { + row.detail_worker = Some(worker.id.clone()); + } + // One agent per id: the same peer can reach the registry twice + // (added by hand, then advertised), and an agent listed twice is + // an agent an operator thinks they have two of. + if !row.agents.iter().any(|agent| agent.agent_id == worker.id) { + row.agents.push(agent_from_worker(worker, false)); + } + } + None => rows.push(HostRow { + id: address.to_string(), + label: remote_label(worker), + kind: HostKind::Remote, + agents: vec![agent_from_worker(worker, false)], + detail_worker: Some(worker.id.clone()), + }), + } + } + rows +} + +/// One local host: its declared agents first, then anything the roster has at +/// its address that no declaration claims. +/// +/// The undeclared tail is the migration seed (an install that predates +/// declarations advertises agents nobody wrote down) and it stays editable: +/// assigning it a role is what writes the declaration that makes the role stick. +/// +/// `unplaced` makes this row the home for declarations that name no host — see +/// [`host_rows`]. Exactly one row ever sets it, or an agent would be listed +/// under every local host at once. +fn local_row<'a>( + id: &str, + label: &str, + workers: &'a [WorkerInfo], + declarations: &[AgentDeclaration], + unplaced: bool, + claimed: &mut Vec<&'a str>, +) -> HostRow { + let mut agents: Vec = Vec::new(); + for declaration in declarations + .iter() + .filter(|d| d.on_host(id) || (unplaced && d.host_id.trim().is_empty())) + { + let worker = workers + .iter() + .find(|worker| worker.id.trim() == declaration.agent_id.trim()); + agents.push(agent_from_declaration(declaration, worker)); + } + let mut detail_worker = None; + for worker in workers.iter().filter(|worker| worker.address.trim() == id) { + if detail_worker.is_none() && has_probe_details(worker) { + detail_worker = Some(worker.id.clone()); + } + // Trimmed on both sides, because the claim above is: a declaration + // whose `agent_id` carries a stray space still matches the roster entry + // (`worker.id.trim() == declaration.agent_id.trim()`), so comparing the + // raw ids here failed to recognise the very worker that declaration had + // just claimed — and appended it a second time as an undeclared row, + // which is the duplicate this loop exists to prevent. + if agents + .iter() + .any(|agent| agent.agent_id.trim() == worker.id.trim()) + { + continue; + } + agents.push(agent_from_worker(worker, true)); + } + // Claim by agent id rather than by address: a declaration may name an agent + // whose roster entry is registered under another address, and it must not + // then be listed a second time as a remote host of its own. Trimmed for the + // same reason the two comparisons above are — a padded `agent_id` that + // claimed a worker but did not claim it *here* would leave that worker to + // the remote pass, which conjures a second host row for a machine already + // listed. + for agent in &agents { + if let Some(worker) = workers + .iter() + .find(|worker| worker.id.trim() == agent.agent_id.trim()) + { + claimed.push(worker.id.as_str()); + } + } + if detail_worker.is_none() { + detail_worker = agents + .iter() + .find(|agent| agent.live) + .map(|agent| agent.agent_id.clone()); + } + HostRow { + id: id.to_string(), + label: label.to_string(), + kind: HostKind::Local, + agents, + detail_worker, + } +} + +/// Whether a roster entry carries a capability probe worth previewing. +fn has_probe_details(worker: &WorkerInfo) -> bool { + worker.cpu_cores.is_some() + || worker.memory_total_bytes.is_some() + || worker.memory_available_bytes.is_some() + || worker.ip_address.is_some() + || !worker.readiness.is_empty() + || !worker.budgets.is_empty() +} + +/// An agent row from the declaration that defines it, with whatever the roster +/// knows about it folded in. +/// +/// Roles come from the declaration, not from the live entry: the declaration is +/// what survives a restart, so showing it is what makes the checkbox honest. +fn agent_from_declaration( + declaration: &AgentDeclaration, + worker: Option<&WorkerInfo>, +) -> HostAgentRow { + let workspace = declaration + .workspace + .path() + .map(str::to_string) + .or_else(|| worker.and_then(|worker| worker.workspace.clone())); + HostAgentRow { + agent_id: declaration.agent_id.clone(), + // A blank name is not a name: a declaration whose name field holds only + // spaces would otherwise render as a row with nothing on it. + label: declaration + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .or_else(|| worker.and_then(|worker| worker.label.clone())) + .unwrap_or_else(|| declaration.agent_id.clone()), + harness: Some(declaration.harness.clone()), + workspace, + roles: declaration.roles.clone(), + max_sessions: Some(declaration.max_sessions()), + declared: true, + editable: true, + live: worker.is_some(), + selected: worker.is_some_and(|worker| worker.selected), + } +} + +/// An agent row for a roster entry no declaration on this machine covers. +/// +/// `local` says whether it sits on a host this machine runs, which is the only +/// thing that decides whether its roles can be assigned here. +fn agent_from_worker(worker: &WorkerInfo, local: bool) -> HostAgentRow { + HostAgentRow { + agent_id: worker.id.clone(), + label: worker + .label + .clone() + .or_else(|| worker.handle.clone()) + .unwrap_or_else(|| worker.id.clone()), + harness: worker.harness.clone(), + workspace: worker.workspace.clone(), + roles: worker.roles.clone(), + max_sessions: None, + declared: false, + editable: local, + live: true, + selected: worker.selected, + } +} + +/// What to call a remote host: the operator's label, its handle, else the raw +/// address they typed. +fn remote_label(worker: &WorkerInfo) -> String { + worker + .label + .clone() + .or_else(|| worker.handle.clone()) + .unwrap_or_else(|| worker.address.clone()) +} diff --git a/src/sdk/src/ui/hosts/tests.rs b/src/sdk/src/ui/hosts/tests.rs new file mode 100644 index 000000000..d14525b3c --- /dev/null +++ b/src/sdk/src/ui/hosts/tests.rs @@ -0,0 +1,294 @@ +//! Unit tests for the `Host → Agents` tree. + +use super::{host_rows, HostKind}; +use crate::config::LocalHostRef; +use crate::runtime::{AgentDeclaration, WorkerInfo}; + +/// A roster entry with nothing probed — the shape an added peer has. +fn worker(id: &str, address: &str) -> WorkerInfo { + WorkerInfo { + id: id.into(), + address: address.into(), + handle: None, + label: None, + harness: Some("codex".into()), + workspace: None, + peer_id: None, + cpu_cores: None, + memory_total_bytes: None, + memory_available_bytes: None, + ip_address: None, + selected: false, + roles: Vec::new(), + budgets: Vec::new(), + readiness: Vec::new(), + } +} + +/// The device-local host, as `local_hosts` resolves it. +fn this_device() -> LocalHostRef { + LocalHostRef { + id: "this-device".into(), + name: "this device".into(), + workspace: "/w".into(), + primary: true, + } +} + +#[test] +fn the_local_host_is_present_with_no_agents_and_no_roster() { + let rows = host_rows(&[], &[], &[this_device()]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "this-device"); + assert_eq!(rows[0].kind, HostKind::Local); + assert!(rows[0].agents.is_empty()); + assert!(rows[0].accepts_new_agents()); + assert!(rows[0].detail_worker.is_none()); +} + +#[test] +fn declared_agents_hang_off_their_host_whether_or_not_they_are_running() { + let mut running = + AgentDeclaration::new("medulla-claude", "this-device", "claude", "/w/medulla"); + running.roles = vec!["coder".into()]; + let idle = AgentDeclaration::new("api-codex", "this-device", "codex", "/w/api"); + + let rows = host_rows( + &[worker("medulla-claude", "this-device")], + &[running, idle], + &[this_device()], + ); + + assert_eq!(rows.len(), 1, "one machine, one host row"); + let agents = &rows[0].agents; + assert_eq!(agents.len(), 2); + assert_eq!(agents[0].agent_id, "medulla-claude"); + assert_eq!(agents[0].harness.as_deref(), Some("claude")); + assert_eq!(agents[0].workspace.as_deref(), Some("/w/medulla")); + assert_eq!(agents[0].roles, vec!["coder".to_string()]); + assert_eq!(agents[0].max_sessions, Some(1), "checkout ⇒ serial"); + assert!(agents[0].declared && agents[0].editable && agents[0].live); + // Declared but with nothing in the roster: still an agent, not yet running. + assert!(agents[1].declared && agents[1].editable && !agents[1].live); +} + +#[test] +fn a_roster_entry_no_declaration_covers_is_still_listed_and_still_editable() { + // The migration seed: an install that predates declarations advertises + // agents nobody wrote down. They are this machine's, so a role can be + // assigned — which is what writes the declaration. + let rows = host_rows( + &[worker("this-device", "this-device")], + &[], + &[this_device()], + ); + let agent = &rows[0].agents[0]; + assert_eq!(agent.agent_id, "this-device"); + assert!(!agent.declared, "no declaration covers it"); + assert!(agent.editable, "but it is on this machine"); + assert!(agent.live); + assert_eq!(agent.max_sessions, None, "nothing declared a strategy"); +} + +#[test] +fn remote_peers_group_under_their_address_and_are_read_only() { + let mut labelled = worker("build-box", "7Kx9fQ"); + labelled.label = Some("build box".into()); + let sibling = worker("build-box-codex", "7Kx9fQ"); + + let rows = host_rows( + &[labelled, sibling, worker("other", "9zzz")], + &[], + &[this_device()], + ); + + assert_eq!(rows.len(), 3, "local + two remotes"); + assert_eq!(rows[1].id, "7Kx9fQ"); + assert_eq!(rows[1].label, "build box"); + assert_eq!(rows[1].kind, HostKind::Remote); + assert!(!rows[1].accepts_new_agents()); + assert_eq!(rows[1].agents.len(), 2, "both entries at that address"); + for agent in &rows[1].agents { + assert!(!agent.declared, "a remote's declarations live over there"); + assert!(!agent.editable, "and are not editable from here"); + assert!(agent.live); + } + assert_eq!(rows[2].id, "9zzz"); + assert_eq!( + rows[2].label, "9zzz", + "no label, no handle: the raw address" + ); +} + +#[test] +fn one_peer_registered_twice_is_one_agent() { + // The same peer reached the registry from two directions — added by hand and + // advertised. Listing it twice would read as two machines' worth of capacity. + let rows = host_rows( + &[worker("addr-1", "addr-1"), worker("addr-1", "addr-1")], + &[], + &[this_device()], + ); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].agents.len(), 1, "{rows:?}"); +} + +#[test] +fn the_preview_reads_capacity_from_whichever_entry_probed_the_machine() { + // Capacity belongs to the machine, so the host row points at the entry that + // reported it rather than at whichever agent happens to be first. + let bare = worker("agent-a", "this-device"); + let mut probed = worker("agent-b", "this-device"); + probed.cpu_cores = Some(8); + + let rows = host_rows(&[bare, probed], &[], &[this_device()]); + assert_eq!(rows[0].detail_worker.as_deref(), Some("agent-b")); +} + +#[test] +fn a_remote_hosts_preview_reads_capacity_from_its_probed_entry_too() { + // The remote branch used to keep whichever agent opened the row, so a + // machine whose first agent had never been probed read "not reported" while + // a sibling at the same address held the capacity. + let bare = worker("agent-a", "addr-1"); + let mut probed = worker("agent-b", "addr-1"); + probed.cpu_cores = Some(8); + + let rows = host_rows(&[bare, probed.clone()], &[], &[this_device()]); + let remote = rows + .iter() + .find(|row| row.kind == HostKind::Remote) + .expect("the peer is a remote host"); + assert_eq!(remote.agents.len(), 2, "both agents are listed: {remote:?}"); + assert_eq!(remote.detail_worker.as_deref(), Some("agent-b")); + + // And a probed pick is not given up for a later unprobed one. + let rows = host_rows( + &[probed, worker("agent-c", "addr-1")], + &[], + &[this_device()], + ); + let remote = rows + .iter() + .find(|row| row.kind == HostKind::Remote) + .expect("the peer is a remote host"); + assert_eq!(remote.detail_worker.as_deref(), Some("agent-b")); +} + +#[test] +fn a_declaration_naming_an_unconfigured_host_still_gets_a_local_row() { + // The `[[hosts]]` entry was removed, or the id was hand-written. The agents + // are still declared on this machine — hiding them would lose them. + let orphan = AgentDeclaration::new("ghost", "local-gone", "claude", "/w/gone"); + let rows = host_rows(&[], &[orphan], &[this_device()]); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].id, "local-gone"); + assert_eq!(rows[1].kind, HostKind::Local); + assert_eq!(rows[1].agents[0].agent_id, "ghost"); + assert!(rows[1].agents[0].editable); +} + +#[test] +fn an_agent_that_names_no_host_belongs_to_the_machine_looking_at_it() { + // Both tabs draw this tree, and the Agents rail has always drawn an unplaced + // agent beside the local ones. Dropping it here would mean the two lenses + // disagree about what exists — and on the Hosts tab it would simply vanish. + let unplaced = AgentDeclaration::new("drifter", "", "claude", "/w/drifter"); + let rows = host_rows(&[], &[unplaced], &[this_device()]); + + assert_eq!(rows.len(), 1, "no second machine is conjured: {rows:?}"); + assert_eq!(rows[0].agents[0].agent_id, "drifter"); + assert!(rows[0].agents[0].editable); +} + +#[test] +fn an_unplaced_agent_survives_a_config_with_no_local_host_at_all() { + let unplaced = AgentDeclaration::new("drifter", "", "claude", "/w/drifter"); + let rows = host_rows(&[], &[unplaced], &[]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "this device"); + assert_eq!(rows[0].kind, HostKind::Local); + assert_eq!(rows[0].agents[0].agent_id, "drifter"); +} + +#[test] +fn only_the_first_local_host_claims_the_unplaced() { + // Two local hosts and one agent naming neither: it must appear once, under + // the primary, not under every host on the machine. + let mut second = this_device(); + second.id = "local-api".into(); + second.name = "api".into(); + second.primary = false; + let unplaced = AgentDeclaration::new("drifter", "", "claude", "/w/drifter"); + + let rows = host_rows(&[], &[unplaced], &[this_device(), second]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].agents.len(), 1); + assert!(rows[1].agents.is_empty(), "claimed once: {rows:?}"); +} + +#[test] +fn a_blank_declared_name_falls_back_to_the_agent_id() { + let mut declared = AgentDeclaration::new("a1", "this-device", "claude", "/w"); + declared.name = Some(" ".into()); + let rows = host_rows(&[], &[declared], &[this_device()]); + assert_eq!(rows[0].agents[0].label, "a1"); +} + +#[test] +fn an_agent_named_by_a_declaration_is_never_also_a_remote_host() { + // The roster entry is registered under a different address than the host id + // its declaration names. It is still that host's agent; listing it again as + // a remote host would advertise one agent as two machines. + let declaration = AgentDeclaration::new("roamer", "this-device", "claude", "/w"); + let rows = host_rows( + &[worker("roamer", "somewhere-else")], + &[declaration], + &[this_device()], + ); + + assert_eq!(rows.len(), 1, "no phantom remote host: {rows:?}"); + assert!(rows[0].agents[0].live); +} + +#[test] +fn a_padded_declared_id_still_claims_its_worker_exactly_once() { + // `agent_id` is typed into a config file, and the declaration→roster claim + // is trimmed — so a stray space matched the worker there and then failed to + // recognise it in the undeclared pass, listing one agent twice: once as the + // declaration, once as a roster entry nobody had written down. + let declared = AgentDeclaration::new(" padded ", "this-device", "claude", "/w"); + let rows = host_rows( + &[worker("padded", "this-device")], + &[declared], + &[this_device()], + ); + + assert_eq!(rows.len(), 1, "no phantom second host: {rows:?}"); + assert_eq!(rows[0].agents.len(), 1, "one agent, listed once: {rows:?}"); + let agent = &rows[0].agents[0]; + assert!(agent.declared, "the declaration is what describes it"); + assert!(agent.live, "and the roster entry is folded into it"); +} + +#[test] +fn the_declared_name_and_roles_win_over_the_live_entry() { + // The declaration is what survives a restart, so it is what the row shows — + // a live entry whose roles were set but never written down must not read as + // the persisted answer. + let mut declared = AgentDeclaration::new("a1", "this-device", "claude", "/w"); + declared.name = Some("the good one".into()); + declared.roles = vec!["reviewer".into()]; + let mut live = worker("a1", "this-device"); + live.label = Some("stale label".into()); + live.roles = vec!["coder".into()]; + live.selected = true; + + let rows = host_rows(&[live], &[declared], &[this_device()]); + let agent = &rows[0].agents[0]; + assert_eq!(agent.label, "the good one"); + assert_eq!(agent.roles, vec!["reviewer".to_string()]); + assert!(agent.selected, "selection is live state, and is kept"); +} diff --git a/src/sdk/src/ui/hosts/types.rs b/src/sdk/src/ui/hosts/types.rs new file mode 100644 index 000000000..c30151c6c --- /dev/null +++ b/src/sdk/src/ui/hosts/types.rs @@ -0,0 +1,90 @@ +//! Data types for the Hosts surface: a host and the agents under it. + +/// Where a host is, and therefore what an operator may do to it from here. +/// +/// The v1 capability split (spec §2.4): agents are created and edited on the +/// machine that owns them, so a remote host's agents are read-only here. This is +/// about the *operator's* affordances only — the orchestrator dispatches to a +/// remote agent exactly as it does to a local one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKind { + /// A host this machine runs: `[host]` or one of the `[[hosts]]` entries. + /// Its agents are declared in this config, so they can be edited here. + Local, + /// Another machine, reached by its tiny.place address. Its agents are + /// declared over there. + Remote, +} + +impl HostKind { + /// Whether an operator may declare or edit agents on this host from here. + pub fn editable(self) -> bool { + matches!(self, HostKind::Local) + } +} + +/// One host in the Hosts tab, with the agents known to be on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRow { + /// The `hostId`: the bus address a local host binds, or the tiny.place + /// address a remote one is reached at. + pub id: String, + /// What to call it on screen. + pub label: String, + /// Local or remote — see [`HostKind`]. + pub kind: HostKind, + /// The agents under it, in declaration order for a local host and roster + /// order for a remote one. + pub agents: Vec, + /// The roster entry whose capability probe describes this *machine* + /// (capacity, readiness, budgets). `None` when nothing on this host is in + /// the roster — a host declared here but not running. + /// + /// Capacity is a property of the machine, not of one agent, so the preview + /// reads it from whichever entry reported it rather than repeating it under + /// every agent. + pub detail_worker: Option, +} + +impl HostRow { + /// Whether the operator may declare a new agent on this host from here. + pub fn accepts_new_agents(&self) -> bool { + self.kind.editable() + } +} + +/// One agent under a host. +/// +/// Two sources feed it, and the difference is what the tab must not blur. A +/// **declared** agent is written down in this machine's `[fleet]`: it is an +/// agent whether or not anything is running, and its roles are editable here. An +/// **undeclared** one is only known because the roster has an entry for it — +/// a migration seed on this machine, or a remote peer whose declarations this +/// hub cannot see until the host link learns to exchange them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostAgentRow { + /// The `agentId` a dispatch targets. Also the roster/worker id. + pub agent_id: String, + /// What to call it: its declared name, its roster label, else its id. + pub label: String, + /// The coding-agent CLI it runs, when known. + pub harness: Option, + /// The directory its sessions work in, when known. + pub workspace: Option, + /// Agent-template ids it is offered for. Empty means unspecified — a + /// general agent, not one excluded from every role. + pub roles: Vec, + /// Sessions it may run at once, derived from its declared strategy. `None` + /// for an agent this machine has not declared. + pub max_sessions: Option, + /// Whether this machine's config declares it. + pub declared: bool, + /// Whether its roles can be assigned here. True only on a local host: a + /// remote host's agents are declared on that machine. + pub editable: bool, + /// Whether the live roster carries an entry for it — i.e. whether the + /// orchestrator can dispatch to it right now. + pub live: bool, + /// Whether it is the operator's manually-selected default worker. + pub selected: bool, +} diff --git a/src/sdk/src/ui/mod.rs b/src/sdk/src/ui/mod.rs index 43b85c51c..41a858044 100644 --- a/src/sdk/src/ui/mod.rs +++ b/src/sdk/src/ui/mod.rs @@ -14,6 +14,7 @@ pub mod events; pub mod fleet; pub mod git_review; pub mod harness; +pub mod hosts; pub mod meters; #[cfg(test)] mod meters_tests; diff --git a/src/sdk/src/workflows/bridge_tests.rs b/src/sdk/src/workflows/bridge_tests.rs index f5bed33f5..bf44de708 100644 --- a/src/sdk/src/workflows/bridge_tests.rs +++ b/src/sdk/src/workflows/bridge_tests.rs @@ -58,6 +58,7 @@ impl HarnessDispatch for StubHarness { output_tokens: 0, }, harness: None, + session_id: None, }) } } diff --git a/src/sdk/src/workflows/copilot/support.rs b/src/sdk/src/workflows/copilot/support.rs index 83cda01f8..4546d174f 100644 --- a/src/sdk/src/workflows/copilot/support.rs +++ b/src/sdk/src/workflows/copilot/support.rs @@ -82,6 +82,7 @@ impl HarnessDispatch for StubHarness { output_tokens: 0, }, harness: None, + session_id: None, }) } diff --git a/src/sdk/src/workflows/evolve/tests/cases.rs b/src/sdk/src/workflows/evolve/tests/cases.rs index 8fdfecb4b..16348f569 100644 --- a/src/sdk/src/workflows/evolve/tests/cases.rs +++ b/src/sdk/src/workflows/evolve/tests/cases.rs @@ -45,6 +45,7 @@ impl HarnessDispatch for StubReviewer { output_tokens: 0, }, harness: None, + session_id: None, }) } } diff --git a/src/sdk/src/workflows/run/tests/cases/continuation.rs b/src/sdk/src/workflows/run/tests/cases/continuation.rs index fe765a3eb..8ab9fed9c 100644 --- a/src/sdk/src/workflows/run/tests/cases/continuation.rs +++ b/src/sdk/src/workflows/run/tests/cases/continuation.rs @@ -181,6 +181,7 @@ impl HarnessDispatch for ConcurrencyProbe { output_tokens: 0, }, harness: None, + session_id: None, }) } } diff --git a/src/sdk/src/workflows/run/tests/cases/mod.rs b/src/sdk/src/workflows/run/tests/cases/mod.rs index 552464514..2c93f6f16 100644 --- a/src/sdk/src/workflows/run/tests/cases/mod.rs +++ b/src/sdk/src/workflows/run/tests/cases/mod.rs @@ -40,6 +40,7 @@ impl HarnessDispatch for StubDispatch { output_tokens: 0, }, harness: None, + session_id: None, }) } } @@ -74,6 +75,7 @@ impl HarnessDispatch for ErrorThenHangDispatch { output_tokens: 0, }, harness: None, + session_id: None, }) } } diff --git a/src/sdk/tests/e2e_daemon/helpers.rs b/src/sdk/tests/e2e_daemon/helpers.rs index d85a25066..9e2c02e0f 100644 --- a/src/sdk/tests/e2e_daemon/helpers.rs +++ b/src/sdk/tests/e2e_daemon/helpers.rs @@ -119,6 +119,7 @@ pub fn frame( workflow_inputs: Default::default(), conversation: None, fleet_depth: 0, + session_id: None, } } diff --git a/src/sdk/tests/feature_mcp_fleet.rs b/src/sdk/tests/feature_mcp_fleet.rs index 041777e7b..3320362de 100644 --- a/src/sdk/tests/feature_mcp_fleet.rs +++ b/src/sdk/tests/feature_mcp_fleet.rs @@ -55,6 +55,7 @@ impl FleetOps for RecordingFleet { output_tokens: 22, }, harness: None, + session_id: None, }) } diff --git a/src/tui/examples/pty_load.rs b/src/tui/examples/pty_load.rs index 1128dd7a1..854dc4652 100644 --- a/src/tui/examples/pty_load.rs +++ b/src/tui/examples/pty_load.rs @@ -32,7 +32,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use medulla::protocol::HarnessProvider; -use medulla_tui::worker::pty::{HarnessControl, LaunchSpec, PtyManager}; +use medulla_tui::worker::pty::{LaunchSpec, PtyManager, SessionControl}; // ------------------------------------------------------------- allocator --- @@ -102,6 +102,7 @@ fn flooding(label: &str) -> LaunchSpec { // Codex takes no preset session id, so its interactive argv is empty and // the script below is the whole command. provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, @@ -121,7 +122,7 @@ fn flooding(label: &str) -> LaunchSpec { // The orchestrator's own sessions, as a task frame opens them: this // measures the dispatch path, and an operator-held session is one // `claim_idle` skips entirely. - control: HarnessControl::Orchestrator, + control: SessionControl::Orchestrator, origin: medulla_tui::worker::pty::SessionOrigin::Orchestrator, name: None, mcp_grant_session: None, diff --git a/src/tui/src/app_loop.rs b/src/tui/src/app_loop.rs index 2db4279d5..f443cc36d 100644 --- a/src/tui/src/app_loop.rs +++ b/src/tui/src/app_loop.rs @@ -528,8 +528,8 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> { (primary.workspace().to_string(), providers, presets) }); let started_hosts = std::sync::Arc::new(std::sync::Mutex::new(local_hosts)); - let local_harnesses = primary_defaults.map(|(workspace, providers, custom_harnesses)| { - medulla_tui::ui::harness_pane::LocalHarnesses { + let local_sessions = primary_defaults.map(|(workspace, providers, custom_harnesses)| { + medulla_tui::ui::harness_pane::LocalSessions { sessions: harness_sessions.clone(), runtimes: host_runtimes.clone(), hub_address: medulla::hub::DEFAULT_LOCAL_HUB_ADDRESS.to_string(), @@ -546,8 +546,8 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> { // Shared with the hub's roster filter and appended to by the spawner, so a // host added mid-session is recognised as device-local the next time the // roster is saved rather than being remembered as a remote peer. - let local_addresses = std::sync::Arc::new(std::sync::Mutex::new( - crate::local_host::all_host_addresses(&loaded.config.host, &loaded.config.hosts), + let declared_local_hosts = std::sync::Arc::new(std::sync::Mutex::new( + crate::local_host::all_local_hosts(&loaded.config.host, &loaded.config.hosts), )); // Only meaningful while this device hosts: with hosting off there is no bus // binding or session manager to hand a new host. @@ -574,7 +574,7 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> { env.clone(), host_runtimes.clone(), started_hosts.clone(), - local_addresses.clone(), + declared_local_hosts.clone(), loaded.config.fleet.agent_declarations.clone(), ) }) @@ -585,7 +585,7 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> { hub_address: medulla::hub::DEFAULT_LOCAL_HUB_ADDRESS.to_string(), // Always known, even with hosting off — it is what identifies a // remembered local roster entry that must not be inherited. - host_addresses: local_addresses, + local_hosts: declared_local_hosts, // Flattened: a host contributes one entry per agent declared on it, not // one entry standing in for the machine. hosts: started_hosts @@ -678,7 +678,7 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> { // one host, so extras are served and dispatchable but not yet // reflected there — a UI gap, not a hosting one. host: primary_observation.clone(), - harnesses: local_harnesses.clone(), + local_sessions: local_sessions.clone(), }, ) .await; diff --git a/src/tui/src/event_loop/cmd_dispatch/handoff.rs b/src/tui/src/event_loop/cmd_dispatch/handoff.rs index 9b4f39f6f..3d39d09f1 100644 --- a/src/tui/src/event_loop/cmd_dispatch/handoff.rs +++ b/src/tui/src/event_loop/cmd_dispatch/handoff.rs @@ -23,7 +23,7 @@ pub(super) fn run_handoff_cmd( msg_tx: &tokio::sync::mpsc::UnboundedSender, ) -> Option> { match cmd { - Cmd::HandOffHarness(brief) => { + Cmd::HandOffSession(brief) => { let rt = runtime.clone(); let tx = msg_tx.clone(); tokio::spawn(async move { @@ -52,7 +52,7 @@ pub(super) fn run_handoff_cmd( }); None } - Cmd::HoldHarness { workspace, reason } => { + Cmd::HoldSession { workspace, reason } => { let rt = runtime.clone(); let tx = msg_tx.clone(); tokio::spawn(async move { diff --git a/src/tui/src/event_loop/cmd_dispatch/mod.rs b/src/tui/src/event_loop/cmd_dispatch/mod.rs index 09b3af4fd..40b5253d9 100644 --- a/src/tui/src/event_loop/cmd_dispatch/mod.rs +++ b/src/tui/src/event_loop/cmd_dispatch/mod.rs @@ -87,7 +87,7 @@ pub(super) fn run_cmd( | Cmd::SubmitFeedback { .. } => { unreachable!("feedback commands return before main dispatch") } - Cmd::HandOffHarness(_) | Cmd::HoldHarness { .. } => { + Cmd::HandOffSession(_) | Cmd::HoldSession { .. } => { unreachable!("handoff commands return before main dispatch") } Cmd::Submit(input) => { diff --git a/src/tui/src/event_loop/mod.rs b/src/tui/src/event_loop/mod.rs index 14c04080b..c4d8dcfa5 100644 --- a/src/tui/src/event_loop/mod.rs +++ b/src/tui/src/event_loop/mod.rs @@ -52,7 +52,7 @@ pub(crate) async fn run( onboarding_path, link_obs, host, - harnesses, + local_sessions, } = wiring; let mut app = App::new(runtime.clone(), loaded); app.set_config_path(config_path); @@ -64,8 +64,8 @@ pub(crate) async fn run( if let Some(host) = host { app.set_host_observation(host); } - if let Some(harnesses) = harnesses { - app.set_local_harnesses(harnesses); + if let Some(sessions) = local_sessions { + app.set_local_sessions(sessions); } if let Some(status) = startup_status { app.set_status(status); diff --git a/src/tui/src/event_loop/types.rs b/src/tui/src/event_loop/types.rs index 368e2b24a..e568e4690 100644 --- a/src/tui/src/event_loop/types.rs +++ b/src/tui/src/event_loop/types.rs @@ -135,12 +135,12 @@ pub(crate) struct SessionWiring { /// A read-only view of the host running on this device, when one is. `None` /// means this machine orchestrates but does not run the work itself. pub host: Option, - /// The live harness sessions this device is running, and the state machine + /// The live sessions this device is running, and the state machine /// that says which task each one serves. /// - /// `None` when this machine does not host: there are no local harnesses to + /// `None` when this machine does not host: there are no local sessions to /// show, and the Agents tab falls back to a remote worker's streamed screen /// or to the transcript. Shared with the host's executor — the sessions it /// opens are the ones rendered here. - pub harnesses: Option, + pub local_sessions: Option, } diff --git a/src/tui/src/hub_relay/mod.rs b/src/tui/src/hub_relay/mod.rs index 95d5d561e..fd7fa619b 100644 --- a/src/tui/src/hub_relay/mod.rs +++ b/src/tui/src/hub_relay/mod.rs @@ -163,11 +163,16 @@ fn subscription_strategy_from_config(home: &Path) -> medulla::runtime::Subscript fn roster_sink( home: &Path, log: medulla::hub::HubLog, - local_addresses: std::sync::Arc>>, + local_hosts: medulla::hub::SharedLocalHosts, ) -> medulla::hub::RosterSink { let path = roster_path(home); Arc::new(move |workers: &[medulla::hub::HubWorker]| { - let local_addresses = local_addresses.lock().expect("host addresses").clone(); + let local_addresses: Vec = local_hosts + .lock() + .expect("local hosts") + .iter() + .map(|host| host.id.clone()) + .collect(); let rows: Vec = workers .iter() .filter(|w| !local_addresses.contains(&w.address)) @@ -390,8 +395,8 @@ fn build_hub_config_with_host_and_link( let (local_network, local_address) = match &local { Some(dispatch) => { { - let local_addresses = dispatch.host_addresses.lock().expect("host addresses"); - workers.retain(|worker| !local_addresses.contains(&worker.address)); + let local_hosts = dispatch.local_hosts.lock().expect("local hosts"); + workers.retain(|worker| !local_hosts.iter().any(|host| host.id == worker.address)); } // Inserted in declaration order, so the primary leads and the // extras follow it the way they read in the config. @@ -420,14 +425,16 @@ fn build_hub_config_with_host_and_link( .and_then(|s| s.parse().ok()) .unwrap_or(DEFAULT_POLL_MS); // The handle, not a copy of its contents: the sink reads it at save time, - // which is the only moment that knows which hosts this device is binding. - let persisted_local = local + // and the hub reads it at registration time, which are the only moments + // that know which hosts this device is binding. + let local_hosts = local .as_ref() - .map(|dispatch| dispatch.host_addresses.clone()) + .map(|dispatch| dispatch.local_hosts.clone()) .unwrap_or_default(); Some(HubConfig { agent_templates, - persist: Some(roster_sink(home, log.clone(), persisted_local)), + local_hosts: local_hosts.clone(), + persist: Some(roster_sink(home, log.clone(), local_hosts)), log, backend_url: creds.base_url, jwt: creds.jwt, diff --git a/src/tui/src/hub_relay/tests.rs b/src/tui/src/hub_relay/tests.rs index c5562e368..767d9469d 100644 --- a/src/tui/src/hub_relay/tests.rs +++ b/src/tui/src/hub_relay/tests.rs @@ -252,9 +252,23 @@ fn saving_over_a_config_leaves_its_other_sections_alone() { assert!(text.contains("addr"), "got: {text}"); } -/// The shared device-local address list the sink reads at save time. -fn shared(addresses: Vec) -> std::sync::Arc>> { - std::sync::Arc::new(std::sync::Mutex::new(addresses)) +/// The shared declared-host list the sink reads at save time, from bare bus +/// addresses — the only field either the save filter or the `hosts[]` advert +/// keys on. +fn shared(addresses: Vec) -> medulla::hub::SharedLocalHosts { + std::sync::Arc::new(std::sync::Mutex::new( + addresses.into_iter().map(local_host).collect(), + )) +} + +/// One declared local host at `address`, named after it. +fn local_host(address: String) -> medulla::config::LocalHostRef { + medulla::config::LocalHostRef { + name: address.clone(), + id: address, + workspace: String::new(), + primary: false, + } } #[test] @@ -316,7 +330,7 @@ fn a_roster_remembered_from_a_hosting_run_is_dropped_when_hosting_is_off() { Some(super::LocalDispatch { network: medulla::bridge::LocalBridgeNetwork::new(), hub_address: "medulla-orchestrator".to_string(), - host_addresses: shared(vec!["this-device".to_string()]), + local_hosts: shared(vec!["this-device".to_string()]), // Hosting is off: nothing is bound at `this-device` this run. hosts: Vec::new(), }), @@ -350,11 +364,11 @@ fn a_host_added_after_launch_is_not_remembered_as_a_remote_peer() { let addresses = shared(vec!["this-device".to_string()]); let sink = super::roster_sink(home, medulla::hub::stderr_log(), addresses.clone()); - // The spawner binds a second host and appends its address. + // The spawner binds a second host and appends it. addresses .lock() - .expect("host addresses") - .push("local-backend".to_string()); + .expect("local hosts") + .push(local_host("local-backend".to_string())); sink(&[ worker("this-device", "this-device", false), diff --git a/src/tui/src/hub_relay/types.rs b/src/tui/src/hub_relay/types.rs index df1cf5664..ce4c4caa2 100644 --- a/src/tui/src/hub_relay/types.rs +++ b/src/tui/src/hub_relay/types.rs @@ -22,19 +22,23 @@ pub(crate) struct LocalDispatch { pub(crate) network: medulla::bridge::LocalBridgeNetwork, /// The address the hub itself binds on that bus. pub(crate) hub_address: String, - /// Every address a host on this device binds, whether or not one is - /// running. + /// Every host this device declares — its bus address and what to call it — + /// whether or not one is running. /// - /// Known even when hosting is off, because it comes from `[host].address` - /// and the `[[hosts]]` entries rather than from a started host — and it is - /// needed in exactly that case, to recognise a remembered local entry and - /// drop it. + /// Known even when hosting is off, because it comes from `[host]` and the + /// `[[hosts]]` entries rather than from a started host — and it is needed in + /// exactly that case, to recognise a remembered local entry and drop it. /// /// Shared and appended to rather than a launch-time snapshot: the roster /// sink filters against it at *save* time, so a host added mid-session was /// absent from a captured list and got written into the remembered roster — /// a device-local entry that survives to a run where nothing binds it. - pub(crate) host_addresses: std::sync::Arc>>, + /// + /// One list, two readers. The hub also advertises it as the `hosts[]` block + /// of `medulla:register_agents`, which is what marks its agents `local`; + /// keeping a second list for that would let "device-local for saving" and + /// "device-local on the wire" drift apart. + pub(crate) local_hosts: medulla::hub::SharedLocalHosts, /// The hosts running on this device, as roster entries. Empty when hosting /// is switched off — the bus is still shared, so hosts can appear later. pub(crate) hosts: Vec, diff --git a/src/tui/src/local_host/mod.rs b/src/tui/src/local_host/mod.rs index 3a49a5b86..5de9e92aa 100644 --- a/src/tui/src/local_host/mod.rs +++ b/src/tui/src/local_host/mod.rs @@ -63,84 +63,36 @@ pub(crate) fn host_address(config: &HostSection) -> String { config.effective_address() } -/// Every device-local address a host could bind, running or not. +/// Every host this device declares, running or not — its bus address and its +/// name. /// /// Known without starting anything, because it comes from the config rather /// than from a started host — and it is needed in exactly the case where none /// started, to recognise remembered local roster entries and drop them. -pub(crate) fn all_host_addresses(primary: &HostSection, extras: &[HostSection]) -> Vec { - std::iter::once(host_address(primary)) - .chain( - extras - .iter() - .enumerate() - .map(|(index, extra)| extra_host_address(extra, index)), - ) - .collect() -} - -/// The bus address for an extra host, derived from its name when it declared -/// none of its own. /// -/// Two hosts cannot share an address — the second `bind` fails — so an operator -/// who adds `[[hosts]]` without thinking about addressing would otherwise get -/// one working host and one startup error. Deriving from the name means the -/// field is optional in the common case and explicit when it matters. -fn extra_host_address(config: &HostSection, fallback_index: usize) -> String { - // The section default counts as unchosen, not as a choice. `[[hosts]]` - // shares `HostSection`, so an entry that names no address inherits the - // primary's — and two hosts on one address means the second never binds. - // An operator who *typed* the primary's address has made the same mistake, - // so both are treated the same way. - let chosen = config.address.trim(); - let chosen = if chosen == HostSection::default().address { - "" - } else { - chosen - }; - match chosen { - "" => { - let slug = slug_of(&config.name); - if slug.is_empty() { - format!("local-host-{}", fallback_index + 1) - } else { - format!("local-{slug}") - } - } - value => value.to_string(), - } +/// The name rides along because the hub advertises this same list as the +/// `hosts[]` block, where a host with an id and nothing to call it reads as a +/// machine nobody named. +pub(crate) fn all_local_hosts( + primary: &HostSection, + extras: &[HostSection], +) -> Vec { + medulla::config::local_hosts(primary, extras) } -/// A lowercase, hyphenated form of `name`, safe to use as a bus address. -fn slug_of(name: &str) -> String { - let mut out = String::new(); - let mut hyphen = false; - for ch in name.trim().chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - hyphen = false; - } else if !out.is_empty() && !hyphen { - out.push('-'); - hyphen = true; - } - } - out.trim_end_matches('-').to_string() +/// The bus address for an extra host — see +/// [`local_host_address`](medulla::config::local_host_address), which the Hosts +/// tab reads too so the list and the binder cannot disagree about which address +/// a section will bind. +fn extra_host_address(config: &HostSection, fallback_index: usize) -> String { + medulla::config::local_host_address(config, fallback_index) } -/// What to call a host that named itself nothing. -/// -/// The primary is "this device" — it is the machine the operator is looking at. -/// An extra is named for the directory it works in, because that is the only -/// thing distinguishing it from the primary. +/// What to call a host that named itself nothing — see +/// [`local_host_name`](medulla::config::local_host_name). Shared with the Hosts +/// tab, which names the same hosts before any of them has started. pub(crate) fn display_name(config: &HostSection, workspace: &str, primary: bool) -> String { - match config.name.trim() { - "" if primary => "this device".to_string(), - "" => std::path::Path::new(workspace) - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| workspace.to_string()), - value => value.to_string(), - } + medulla::config::local_host_name(config, workspace, primary) } /// Translate the `[host]` section into the SDK's start-up options. @@ -550,10 +502,11 @@ pub(crate) struct LocalHostSpawner { /// from. Carried rather than re-read so a host started now and one started /// at launch are built from the same list. declared: Vec, - /// Every device-local address, shared with the hub's roster filter. A host - /// bound here must be appended or the roster sink will persist it as a - /// remote entry. - addresses: std::sync::Arc>>, + /// Every host this device declares, shared with the hub's roster filter and + /// its `hosts[]` advert. A host bound here must be appended or the roster + /// sink will persist it as a remote entry — and the hub will advertise its + /// agents as running on somebody else's machine. + local_hosts: medulla::hub::SharedLocalHosts, } impl LocalHostSpawner { @@ -570,7 +523,7 @@ impl LocalHostSpawner { env: HashMap, runtimes: std::sync::Arc>>, started: std::sync::Arc>>, - addresses: std::sync::Arc>>, + local_hosts: medulla::hub::SharedLocalHosts, declared: Vec, ) -> Self { Self { @@ -580,7 +533,7 @@ impl LocalHostSpawner { env, runtimes, started, - addresses, + local_hosts, declared, } } @@ -590,7 +543,7 @@ impl LocalHostSpawner { /// rather than as one entry standing in for all of them. /// /// `index` is the entry's position within `[[hosts]]`, which is the basis - /// [`all_host_addresses`] and [`start_all`] derive an unnamed host's address + /// [`all_local_hosts`] and [`start_all`] derive an unnamed host's address /// from. It is passed in rather than counted here for exactly that reason: /// counting *started* hosts includes the primary, so a first unnamed extra /// bound `local-host-2` this run and `local-host-1` on the next launch — @@ -611,12 +564,18 @@ impl LocalHostSpawner { &self.declared, )?; let specs = host.specs().to_vec(); - // Before the roster entry exists, so the hub's save filter already knows - // this address is device-local by the time registration triggers one. - self.addresses + // Before the roster entry exists, so the hub's save filter and its + // `hosts[]` advert both already know this address is device-local by the + // time registration triggers one. + self.local_hosts .lock() - .expect("host addresses") - .push(host.address().to_string()); + .expect("local hosts") + .push(medulla::config::LocalHostRef { + id: host.address().to_string(), + name: display_name(config, host.workspace(), false), + workspace: host.workspace().to_string(), + primary: false, + }); self.runtimes .lock() .expect("local harness runtimes") diff --git a/src/tui/src/local_host/tests/extras.rs b/src/tui/src/local_host/tests/extras.rs index ff970a4ff..49bad1e1a 100644 --- a/src/tui/src/local_host/tests/extras.rs +++ b/src/tui/src/local_host/tests/extras.rs @@ -8,7 +8,7 @@ use medulla::protocol::HarnessProvider; use medulla_tui::worker::pty::PtyManager; use crate::local_host::{ - all_host_addresses, display_name, extra_host_address, extra_options, host_address, + all_local_hosts, display_name, extra_host_address, extra_options, host_address, options_from_config, start_all, }; @@ -38,6 +38,12 @@ fn each_extra_host_gets_its_own_bus_address() { assert_eq!(extra_host_address(&explicit, 0), "chosen-by-hand"); } +/// The bus addresses of a declared-host list, which is what the roster filter +/// keys on. +fn addresses_of(hosts: &[medulla::config::LocalHostRef]) -> Vec { + hosts.iter().map(|host| host.id.clone()).collect() +} + #[test] fn every_declared_address_is_known_without_starting_anything() { // Needed in exactly the case where none started: a roster saved while @@ -54,7 +60,7 @@ fn every_declared_address_is_known_without_starting_anything() { }, ]; assert_eq!( - all_host_addresses(&primary, &extras), + addresses_of(&all_local_hosts(&primary, &extras)), vec![ HostSection::default().address, "local-backend".to_string(), @@ -207,7 +213,7 @@ fn an_unnamed_extras_address_is_derived_from_its_config_index() { assert_eq!(extra_host_address(&unnamed, 0), "local-host-1"); assert_eq!( - all_host_addresses(&primary, std::slice::from_ref(&unnamed)), + addresses_of(&all_local_hosts(&primary, std::slice::from_ref(&unnamed))), vec![host_address(&primary), "local-host-1".to_string()], ); } diff --git a/src/tui/src/ui/app/agent_control.rs b/src/tui/src/ui/app/agent_control.rs new file mode 100644 index 000000000..406c25d97 --- /dev/null +++ b/src/tui/src/ui/app/agent_control.rs @@ -0,0 +1,307 @@ +//! Declaring agents, and opening sessions under them. +//! +//! The two writes the Agents tab makes. **Declaring** is `harness × workspace` +//! written to `[fleet].agentDeclarations` — it starts nothing, and the agent it +//! produces has a rail row from that moment whether or not anything ever runs in +//! it. **Opening a session** is the opposite: it starts a process and writes +//! nothing, inheriting the harness and the directory from the declaration rather +//! than asking again. +//! +//! Both reuse the picker in [`session_control`](super::session_control): picking +//! a CLI and a directory is the same two steps either way, and the intent it +//! carries ([`PickerPurpose`]) is what decides which of these two ends it lands +//! in. +//! +//! Persistence goes through [`declare_agent`](medulla::config::declare_agent), +//! which writes the file and hands back the list *as written*. That list is +//! assigned straight into the loaded config, so a failed write leaves the rail +//! showing exactly what is on disk rather than an agent that will not survive a +//! restart. + +use medulla::config::{declare_agent, declared_agent_ids}; +use medulla::runtime::{suggest_agent_id, AgentDeclaration, WorkspaceRef, WorkspaceStrategy}; + +use crate::ui::harness_pane::HarnessChoice; + +use super::types::{tab_pos, AgentPicker, AgentPickerStep, App, PickerPurpose, Prompt, PromptKind}; + +impl App { + /// Open the create-agent flow: harness type, then workspace, then a name. + /// + /// Refuses on a device that is not hosting rather than opening a picker with + /// nothing in it: an agent is declared *on a host*, and there is none here to + /// declare it on. + pub(in crate::ui::app) fn open_new_agent_picker(&mut self) { + let Some(harnesses) = self.local_sessions.clone() else { + self.set_status("This device is not hosting, so it has no agents to declare"); + return; + }; + let choices = harnesses.choices(); + if choices.is_empty() { + self.set_status("No harness CLIs found on this device"); + return; + } + self.agent_picker = Some(AgentPicker { + purpose: PickerPurpose::DeclareAgent, + choices, + index: 0, + step: AgentPickerStep::Harness, + cwd: harnesses.workspace.clone(), + workspace_query: String::new(), + workspace_choices: Vec::new(), + workspace_index: 0, + workspace_picked: false, + managed: true, + }); + self.set_status("New agent · pick a harness type · Enter workspace · Esc cancel"); + } + + /// Ask what to call the agent about to be declared for this pair. + /// + /// A single-line prompt rather than a third picker step: the answer is + /// optional, and everything the operator needs to decide it — the CLI and the + /// directory — is in the question. + pub(in crate::ui::app) fn prompt_agent_name(&mut self, harness: &str, workspace: &str) { + let folder = std::path::Path::new(workspace) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| workspace.to_string()); + self.prompt = Some(Prompt { + title: format!("Name this agent — {harness} × {folder} (blank keeps the default)"), + draft: Default::default(), + kind: PromptKind::AgentName { + harness: harness.to_string(), + workspace: workspace.to_string(), + }, + }); + } + + /// Declare `harness × workspace` on this host and adopt the written list. + /// + /// The id is minted rather than typed: it is the dispatch target, and a + /// collision would silently route work to the wrong agent, so + /// [`suggest_agent_id`] is given every id already declared *anywhere* in the + /// fleet — not only this host's. + pub(in crate::ui::app) fn declare_new_agent( + &mut self, + harness: &str, + workspace: &str, + name: &str, + ) { + let Some(path) = self.config_path.clone() else { + self.set_status("No config file to declare an agent in"); + return; + }; + let current = self.agent_declarations().to_vec(); + let taken = declared_agent_ids(¤t); + let agent_id = suggest_agent_id(workspace, harness, &taken); + let name = name.trim(); + let incoming = AgentDeclaration { + agent_id: agent_id.clone(), + host_id: self.local_host_id(), + harness: harness.to_string(), + workspace: WorkspaceRef::checkout(workspace), + name: (!name.is_empty()).then(|| name.to_string()), + roles: Vec::new(), + // The only strategy an operator may choose in v1; a picker that + // offered `worktree` would declare parallel sessions that all land in + // one directory, because nothing provisions a worktree yet. + strategy: WorkspaceStrategy::Checkout, + }; + match declare_agent(&path, ¤t, incoming) { + Ok(declarations) => { + self.loaded.config.fleet.agent_declarations = declarations; + self.select_agent_row(&agent_id); + self.set_status(format!( + "Declared {agent_id} · {harness} in {workspace} · ^T opens a session" + )); + } + // Nothing was applied — `declare_agent` writes before it returns, so + // an error means the file is unchanged and so is the rail. + Err(error) => self.set_status(format!("Could not declare the agent: {error}")), + } + } + + /// Offer to declare an agent for a session started somewhere undeclared. + /// + /// The quick path survives — the session is already running — but it always + /// leaves a declared agent behind if the operator wants one, which is what + /// keeps "declared, never discovered" true without making the fast door + /// slower. Silent when the directory is already declared, and when this + /// device is not hosting. + pub(in crate::ui::app) fn offer_agent_declaration(&mut self, harness: &str, workspace: &str) { + if self.local_sessions.is_none() { + return; + } + let declared = self.local_agent_declarations().into_iter().any(|held| { + held.harness.trim().eq_ignore_ascii_case(harness.trim()) + && held.workspace.path.trim().trim_end_matches('/') + == workspace.trim().trim_end_matches('/') + }); + if declared { + return; + } + self.prompt_agent_name(harness, workspace); + } + + /// The agent the rail cursor is on, or the one owning the session it is on. + pub(in crate::ui::app) fn selected_agent_id(&self) -> Option { + let rows = self.rail_rows(); + rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + .and_then(|row| row.agent_id()) + .map(str::to_string) + } + + /// Ask what to call a new session under `agent_id`, then open it. + /// + /// Managed-versus-unmanaged is ownership at birth and is asked first, in the + /// same sentence, because it is the fact that decides whether the + /// orchestrator may dispatch into what the operator is about to open. + pub(in crate::ui::app) fn open_new_session(&mut self, agent_id: &str) { + let Some(declaration) = self.declaration_for(agent_id) else { + self.refuse_absent_declaration(agent_id); + return; + }; + let Some(workspace) = declaration.workspace.path() else { + self.set_status(format!( + "{agent_id} declares no workspace to open a session in" + )); + return; + }; + self.prompt = Some(Prompt { + title: format!( + "Name this session — {} in {} (blank leaves it unnamed)", + declaration.harness, workspace + ), + draft: Default::default(), + kind: PromptKind::SessionName { + agent_id: agent_id.to_string(), + // Born to the operator: they asked for it and are about to type + // into it, so the orchestrator does not get it until it is handed + // over. The same default the manual picker offers. + managed: false, + }, + }); + } + + /// Open a session for `agent_id` with the name the operator gave it. + /// + /// The harness and the directory come from the declaration, never from the + /// caller: a session that ran a different CLI, or in a different folder, from + /// the agent it is filed under would put the rail's own grouping rule wrong. + pub(in crate::ui::app) fn start_agent_session( + &mut self, + agent_id: &str, + name: &str, + managed: bool, + ) { + let Some(harnesses) = self.local_sessions.clone() else { + self.set_status("This device is not hosting, so it has no sessions to open"); + return; + }; + let Some(declaration) = self.declaration_for(agent_id) else { + self.refuse_absent_declaration(agent_id); + return; + }; + let Some(choice) = harness_choice(&harnesses.choices(), &declaration.harness) else { + self.set_status(format!( + "{} is not installed on this device — {agent_id} cannot open a session", + declaration.harness + )); + return; + }; + let workspace = declaration.workspace.path().unwrap_or_default().to_string(); + let name = name.trim(); + let name = (!name.is_empty()).then(|| name.to_string()); + let skip = self.harness_skip_permissions; + match harnesses.open_unmanaged_named(&choice, &workspace, skip, name.clone()) { + Ok(id) => { + self.tab_index = tab_pos("Agents"); + self.select_session_row(&id); + let label = name.unwrap_or_else(|| choice.display_name().to_string()); + if managed { + self.hand_back_session(&id, None); + } + self.set_status(format!( + "Opened {label} under {agent_id} · {}", + if managed { + "managed, the orchestrator may dispatch into it" + } else { + "yours, the orchestrator will not dispatch into it" + } + )); + } + Err(error) => self.set_status(format!("Could not open a session: {error}")), + } + } + + /// The declaration for `agent_id` **on this host**, if there is one. + /// + /// Scoped to the local host on purpose: a session is a process started on + /// this machine, so opening one from a declaration that names another + /// machine would file a running session under an agent that lives somewhere + /// else — and the rail, which resolves sessions against the *local* + /// declarations, would then list it as an orphan. The rail already limits + /// its `+ new session` row to local agents; `^T` reads whatever row the + /// cursor is on, so the filter has to live here too. + fn declaration_for(&self, agent_id: &str) -> Option { + self.local_agent_declarations() + .into_iter() + .find(|declaration| declaration.agent_id.trim() == agent_id.trim()) + } + + /// Say why there is no declaration here to open a session from. + /// + /// Two different answers, because they call for two different next steps: an + /// agent nobody declared anywhere is one to declare, and an agent declared + /// on another machine is one to start a session on *that* machine. Naming + /// the host is the whole of the difference, so the refusal carries it. + fn refuse_absent_declaration(&mut self, agent_id: &str) { + let elsewhere = medulla::config::agent_declaration(self.agent_declarations(), agent_id) + .map(|declaration| declaration.host_id.trim().to_string()) + .filter(|host_id| !host_id.is_empty()); + self.set_status(match elsewhere { + Some(host_id) => { + format!("{agent_id} is declared on {host_id} — open its sessions on that machine") + } + None => format!("No agent \"{agent_id}\" is declared on this device"), + }); + } + + /// Put the rail cursor on `agent_id`'s row, if it has one. + pub(in crate::ui::app) fn select_agent_row(&mut self, agent_id: &str) { + if let Some(index) = self.rail_rows().iter().position( + |row| matches!(row, super::rail::RailRow::Agent(agent) if agent.agent_id == agent_id), + ) { + self.agent_index = index; + } + } + + /// Put the rail cursor on the row for `session_id`, if it has one. + /// + /// Selecting the new row matters more than it sounds: a session that appears + /// somewhere below the fold, with the pane still showing whatever was + /// selected before, reads as "nothing happened". + pub(in crate::ui::app) fn select_session_row(&mut self, session_id: &str) { + if let Some(index) = self + .rail_rows() + .iter() + .position(|row| row.session_id() == Some(session_id)) + { + self.agent_index = index; + } + } +} + +/// The installed choice implementing `harness`, matched on its stable id. +/// +/// The id is what a declaration records — a native CLI's wire name, or a custom +/// preset's own id — so this resolves both without the caller knowing which it +/// has. +fn harness_choice(choices: &[HarnessChoice], harness: &str) -> Option { + let wanted = harness.trim(); + choices + .iter() + .find(|choice| choice.id().eq_ignore_ascii_case(wanted)) + .cloned() +} diff --git a/src/tui/src/ui/app/agent_control_tests.rs b/src/tui/src/ui/app/agent_control_tests.rs new file mode 100644 index 000000000..653731692 --- /dev/null +++ b/src/tui/src/ui/app/agent_control_tests.rs @@ -0,0 +1,302 @@ +//! The two writes the Agents tab makes: declaring an agent, and opening a +//! session under one. + +use medulla::config::load_agent_declarations; +use medulla::protocol::HarnessProvider; +use medulla::runtime::AgentDeclaration; + +use super::rail::tests::{hosting_app, shell_harnesses}; +use super::rail::RailRow; +use super::types::{PickerPurpose, PromptKind}; +use crate::worker::pty::PtyManager; + +/// A hosting app whose config lives in `dir`, so a declaration can be read back. +fn app_with_config(dir: &std::path::Path) -> super::types::App { + let mut app = hosting_app(); + app.set_config_path(dir.join("config.toml")); + app +} + +#[test] +fn declaring_an_agent_writes_it_and_shows_it_on_the_rail() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut app = app_with_config(dir.path()); + + app.declare_new_agent("codex", "/work/api", "API"); + + let written = load_agent_declarations(&dir.path().join("config.toml")); + assert_eq!(written.len(), 1, "one agent is on disk: {written:?}"); + let declaration = &written[0]; + // The id is minted from the folder, not typed: it is the dispatch target. + assert_eq!(declaration.agent_id, "api-codex"); + assert_eq!(declaration.harness, "codex"); + assert_eq!(declaration.workspace.path(), Some("/work/api")); + assert_eq!(declaration.name.as_deref(), Some("API")); + // `checkout` is the only strategy a picker may offer in v1, so one session + // writes at a time. + assert_eq!(declaration.max_sessions(), 1); + + // The written list is adopted in memory too, so the very next frame has the + // row rather than waiting for a restart. + assert!( + app.rail_rows() + .iter() + .any(|row| matches!(row, RailRow::Agent(agent) if agent.agent_id == "api-codex")), + "the declared agent is on the rail" + ); +} + +#[test] +fn a_blank_name_keeps_the_minted_id_and_a_second_agent_does_not_collide() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut app = app_with_config(dir.path()); + + app.declare_new_agent("codex", "/work/api", " "); + app.declare_new_agent("codex", "/other/api", ""); + + let written = load_agent_declarations(&dir.path().join("config.toml")); + let ids: Vec<&str> = written + .iter() + .map(|declaration| declaration.agent_id.as_str()) + .collect(); + assert_eq!(ids, vec!["api-codex", "api-codex-2"], "ids never collide"); + assert!( + written.iter().all(|declaration| declaration.name.is_none()), + "blank is not a name" + ); +} + +#[test] +fn a_failed_write_leaves_the_rail_showing_what_is_on_disk() { + // No config path at all: nothing can be written, so nothing is adopted — + // the alternative is a rail showing an agent that will not survive a + // restart. + let mut app = hosting_app(); + app.declare_new_agent("codex", "/work/api", "API"); + assert!(app.agent_declarations().is_empty()); + assert!(app.status().contains("No config file"), "{}", app.status()); +} + +#[test] +fn the_new_agent_row_opens_the_picker_in_declare_mode() { + let mut app = hosting_app(); + let index = app + .rail_rows() + .iter() + .position(RailRow::is_new_agent) + .expect("the create action is on the rail"); + app.agent_index = index; + assert!(app.on_new_agent_row()); + + app.open_new_agent_picker(); + let picker = app.agent_picker.as_ref().expect("the picker opened"); + assert_eq!(picker.purpose, PickerPurpose::DeclareAgent); +} + +#[test] +fn a_session_started_somewhere_undeclared_offers_an_agent_for_it() { + let mut app = hosting_app(); + app.offer_agent_declaration("codex", "/work/loose"); + assert!(app.prompt_state().is_some(), "the offer is a name prompt"); + let kind = app.prompt.as_ref().map(|prompt| &prompt.kind); + assert!( + matches!(kind, Some(PromptKind::AgentName { harness, workspace }) + if harness == "codex" && workspace == "/work/loose"), + "the offer carries the pair it would declare" + ); +} + +#[test] +fn a_directory_that_is_already_declared_is_not_offered_again() { + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("api", "", "codex", "/work/api")]; + app.offer_agent_declaration("codex", "/work/api/"); + assert!(app.prompt_state().is_none(), "nothing left to declare"); +} + +#[test] +fn a_new_session_under_an_agent_asks_for_a_name_first() { + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("shell", "", "codex", "/")]; + + app.open_new_session("shell"); + + let kind = app.prompt.as_ref().map(|prompt| &prompt.kind); + assert!( + matches!(kind, Some(PromptKind::SessionName { agent_id, managed }) + if agent_id == "shell" && !managed), + "a session a person spins up is theirs at birth" + ); +} + +// Unix-only: starts a real child on a real pseudo-terminal via `/bin/sh`, +// which Windows has no equivalent of. The row model under test is +// portable; only this way of standing a session up is not. +#[cfg(unix)] +#[test] +fn a_named_session_opens_in_the_agents_own_harness_and_workspace() { + let sessions = PtyManager::new(); + let mut app = hosting_app(); + app.set_local_sessions(shell_harnesses(sessions.clone())); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("shell", "", "codex", "/")]; + + app.start_agent_session("shell", "debug login", false); + + let rows = sessions.rows(); + let row = rows.first().expect("a session opened").clone(); + assert_eq!(row.name.as_deref(), Some("debug login")); + assert_eq!(row.provider, HarnessProvider::Codex, "the agent's harness"); + assert_eq!(row.cwd, "/", "the agent's workspace, not the caller's"); + assert!(row.origin.is_user(), "the operator started it"); + // The cursor moves onto the new row: a session that appears below the fold + // with the pane unchanged reads as "nothing happened". + assert_eq!( + app.rail_rows() + .get(app.agent_index()) + .and_then(RailRow::session_id), + Some(row.id.as_str()) + ); + sessions.shutdown(); +} + +#[test] +fn every_agent_carries_a_new_session_row_that_opens_the_flow() { + // `open_new_session` shipped with the tree and was bound only to `^T`, so an + // operator who did not already know the chord could not reach it. The row is + // how it becomes visible — and selecting it must land in the same flow. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![ + AgentDeclaration::new("api-codex", "", "codex", "/work/api"), + AgentDeclaration::new("web-codex", "", "codex", "/work/web"), + ]; + + let rows = app.rail_rows(); + for agent_id in ["api-codex", "web-codex"] { + let agent = rows + .iter() + .position(|row| matches!(row, RailRow::Agent(agent) if agent.agent_id == agent_id)) + .unwrap_or_else(|| panic!("{agent_id} has a row")); + let action = rows + .iter() + .position(|row| row.new_session_agent() == Some(agent_id)) + .unwrap_or_else(|| panic!("{agent_id} offers a session: {rows:?}")); + assert!(action > agent, "the action sits under its agent"); + // …and under that agent's own sessions: nothing between them belongs to + // anybody else. + assert!( + rows[agent + 1..action] + .iter() + .all(|row| matches!(row, RailRow::Session(_) | RailRow::Lane(_))), + "the action closes the agent's group: {rows:?}" + ); + assert!(rows[action].selectable(), "and the cursor can reach it"); + } + + let index = rows + .iter() + .position(|row| row.new_session_agent() == Some("api-codex")) + .expect("the action row"); + app.agent_index = index; + assert_eq!(app.on_new_session_row().as_deref(), Some("api-codex")); + + app.open_new_session("api-codex"); + let kind = app.prompt.as_ref().map(|prompt| &prompt.kind); + assert!( + matches!(kind, Some(PromptKind::SessionName { agent_id, managed }) + if agent_id == "api-codex" && !managed), + "selecting it opens the named, user-owned flow" + ); +} + +#[test] +fn an_agent_this_machine_does_not_declare_is_not_offered_a_session() { + // A session is started by the host that owns the agent, and the flow reads + // the declaration for the harness and the directory. An action row on a + // remote host's agent would be a button that refuses. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-codex", + "studio", + "codex", + "/work", + )]; + assert!( + app.rail_rows() + .iter() + .all(|row| row.new_session_agent().is_none()), + "nothing local to start it on" + ); +} + +#[test] +fn opening_a_session_for_an_undeclared_agent_says_so() { + let mut app = hosting_app(); + app.open_new_session("nobody"); + assert!(app.status().contains("nobody"), "{}", app.status()); + assert!(app.prompt_state().is_none()); +} + +#[test] +fn a_remote_agent_cannot_be_given_a_local_session() { + // `^T` acts on whatever agent row the cursor is on, and a remote host's + // agents have rows. Starting a process here for one of them would file a + // local session under an agent that runs on another machine — where the + // rail, which resolves sessions against the *local* declarations, would + // then show it loose in the orphan list. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-codex", + "studio", + "codex", + "/work", + )]; + // The cursor can reach it: the agent has a row even though the action does + // not, which is exactly how the chord gets there. + let index = app + .rail_rows() + .iter() + .position(|row| matches!(row, RailRow::Agent(agent) if agent.agent_id == "studio-codex")) + .expect("a remote agent still has a rail row"); + app.agent_index = index; + assert_eq!(app.selected_agent_id().as_deref(), Some("studio-codex")); + + app.open_new_session("studio-codex"); + + assert!(app.prompt_state().is_none(), "no session flow opened"); + assert!( + app.status().contains("studio"), + "the refusal names the host that owns it: {}", + app.status() + ); +} + +#[test] +fn starting_a_session_for_a_remote_agent_refuses_before_it_spawns() { + // The same guard on the other door: `start_agent_session` is reached from + // the name prompt, so it must not trust that `open_new_session` vetted the + // agent for it. + // `hosting_app` already carries the local harnesses, so the refusal has to + // come from the declaration and not from having nothing to launch with. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-codex", + "studio", + "codex", + "/", + )]; + + app.start_agent_session("studio-codex", "anything", false); + + assert!( + app.status().contains("studio"), + "the refusal names the host: {}", + app.status() + ); + assert!( + app.rail_rows().iter().all(|row| row.session_id().is_none()), + "nothing was started" + ); +} diff --git a/src/tui/src/ui/app/changes/baseline.rs b/src/tui/src/ui/app/changes/baseline.rs index 649cc8d35..fa27ee802 100644 --- a/src/tui/src/ui/app/changes/baseline.rs +++ b/src/tui/src/ui/app/changes/baseline.rs @@ -1,4 +1,4 @@ -//! Resolves immutable launch baselines for live harness sessions. +//! Resolves immutable launch baselines for live agent sessions. use std::path::Path; @@ -29,7 +29,7 @@ pub(super) fn launch_baseline( } } -/// Resolve the selected harness without silently substituting another +/// Resolve the selected session without silently substituting another /// repository. The newest eligible harness is only a default when no live /// preferred row exists. pub(super) fn select_harness_baseline( @@ -44,7 +44,7 @@ pub(super) fn select_harness_baseline( row.launch_commit.as_deref(), row.launch_checkout_identity.as_deref(), ) - .ok_or_else(|| format!("Selected harness {} is not in a Git repository", row.label))?; + .ok_or_else(|| format!("Selected session {} is not in a Git repository", row.label))?; return Ok(Some((row, commit))); } } diff --git a/src/tui/src/ui/app/changes/baseline_tests.rs b/src/tui/src/ui/app/changes/baseline_tests.rs index ff6d16f29..849a6db4a 100644 --- a/src/tui/src/ui/app/changes/baseline_tests.rs +++ b/src/tui/src/ui/app/changes/baseline_tests.rs @@ -56,7 +56,7 @@ fn choosing_a_harness_baseline_clears_comments_when_repository_changes() { "first repository", ); - state.choose_harness_baseline().expect("switch repository"); + state.choose_session_baseline().expect("switch repository"); assert_eq!(state.root.as_deref(), Some(second_root.as_path())); assert_eq!(state.baseline.as_deref(), Some(second_baseline.as_str())); @@ -70,7 +70,7 @@ fn following_a_new_launch_commit_in_the_same_repository_preserves_comments() { let first = output(directory.path(), &["rev-parse", "HEAD"]); let mut state = GitChangesState::default(); let identity = crate::worker::pty::checkout::capture(directory.path()).expect("identity"); - state.follow_harness(directory.path(), &first, &identity); + state.follow_session(directory.path(), &first, &identity); state .comments .upsert(Path::new("src/main.rs"), CommentAnchor::File, "keep this"); @@ -80,7 +80,7 @@ fn following_a_new_launch_commit_in_the_same_repository_preserves_comments() { ); let second = output(directory.path(), &["rev-parse", "HEAD"]); - state.follow_harness(directory.path(), &second, &identity); + state.follow_session(directory.path(), &second, &identity); assert_eq!(state.baseline.as_deref(), Some(second.as_str())); assert_eq!(state.comments.count_for(Path::new("src/main.rs")), 1); @@ -98,18 +98,18 @@ fn choosing_harness_launch_replaces_an_operator_selected_baseline() { let manual = output(directory.path(), &["rev-parse", "HEAD"]); let identity = crate::worker::pty::checkout::capture(directory.path()).expect("identity"); let mut state = GitChangesState::default(); - state.follow_harness(directory.path(), &launch, &identity); + state.follow_session(directory.path(), &launch, &identity); state .choose_baseline(&manual, BaselineSource::Manual) .expect("manual baseline"); // Opening a selected harness with `d` uses this explicit activation after // following it; an ordinary refresh deliberately preserves Manual mode. - state.follow_harness(directory.path(), &launch, &identity); - state.choose_harness_baseline().expect("harness baseline"); + state.follow_session(directory.path(), &launch, &identity); + state.choose_session_baseline().expect("harness baseline"); assert_eq!(state.baseline.as_deref(), Some(launch.as_str())); - assert_eq!(state.baseline_source, BaselineSource::HarnessLaunch); + assert_eq!(state.baseline_source, BaselineSource::SessionLaunch); } #[test] @@ -119,33 +119,33 @@ fn returning_from_a_non_git_harness_preserves_same_repository_comments() { let launch = output(directory.path(), &["rev-parse", "HEAD"]); let identity = crate::worker::pty::checkout::capture(directory.path()).expect("identity"); let mut state = GitChangesState::default(); - state.follow_harness(directory.path(), &launch, &identity); + state.follow_session(directory.path(), &launch, &identity); state .comments .upsert(Path::new("src/main.rs"), CommentAnchor::File, "keep this"); - state.clear_repository("selected harness is outside Git".to_owned()); - state.follow_harness(directory.path(), &launch, &identity); + state.clear_repository("selected session is outside Git".to_owned()); + state.follow_session(directory.path(), &launch, &identity); assert_eq!(state.comments.count_for(Path::new("src/main.rs")), 1); } #[test] -fn applying_harness_launch_revalidates_the_checkout_marker() { +fn applying_session_launch_revalidates_the_checkout_marker() { let directory = tempdir().expect("repository"); init_repo(directory.path()); let launch = output(directory.path(), &["rev-parse", "HEAD"]); let identity = crate::worker::pty::checkout::capture(directory.path()).expect("identity"); let mut state = GitChangesState::default(); - state.follow_harness(directory.path(), &launch, &identity); + state.follow_session(directory.path(), &launch, &identity); fs::remove_dir_all(directory.path().join(".git")).expect("remove checkout metadata"); git(directory.path(), &["init"]); let error = state - .choose_harness_baseline() + .choose_session_baseline() .expect_err("replacement checkout must be rejected"); - assert_eq!(error, "Harness Git checkout changed since launch"); + assert_eq!(error, "Session Git checkout changed since launch"); } #[test] @@ -251,12 +251,12 @@ fn a_valid_harness_recovers_after_a_non_git_selection_clears_manual_state() { ..GitChangesState::default() }; - state.clear_repository("selected harness is outside Git".to_owned()); + state.clear_repository("selected session is outside Git".to_owned()); let identity = crate::worker::pty::checkout::capture(directory.path()).expect("identity"); - state.follow_harness(directory.path(), &launch, &identity); + state.follow_session(directory.path(), &launch, &identity); state.refresh(); - assert_eq!(state.baseline_source, BaselineSource::HarnessLaunch); + assert_eq!(state.baseline_source, BaselineSource::SessionLaunch); assert_eq!(state.baseline.as_deref(), Some(launch.as_str())); assert_eq!(state.error, None); } @@ -268,11 +268,12 @@ fn row( launch_commit: Option, started_at: i64, ) -> crate::worker::pty::SessionRow { - use crate::worker::pty::{HarnessControl, PtyState, SessionRow}; + use crate::worker::pty::{PtyState, SessionControl, SessionRow}; SessionRow { id: id.to_owned(), label: label.to_owned(), provider: medulla::protocol::HarnessProvider::Codex, + preset: None, state: PtyState::Running, cwd: cwd.to_string_lossy().into_owned(), branch: None, @@ -287,7 +288,7 @@ fn row( last_output_at: started_at, last_error: None, busy: false, - control: HarnessControl::User, + control: SessionControl::User, origin: crate::worker::pty::SessionOrigin::User, name: None, attention: None, diff --git a/src/tui/src/ui/app/changes/mod.rs b/src/tui/src/ui/app/changes/mod.rs index a5c2b9421..cc16502ba 100644 --- a/src/tui/src/ui/app/changes/mod.rs +++ b/src/tui/src/ui/app/changes/mod.rs @@ -37,8 +37,8 @@ impl App { /// replaces an operator-selected commit or manual baseline: `d` means the /// immutable launch diff for the harness under the cursor. pub(super) fn open_selected_harness_changes(&mut self) -> Option { - let session = self.harness_pane_session.clone()?; - self.selected_harness_session = Some(session); + let session = self.pane_session.clone()?; + self.rail_session = Some(session); self.tab_index = TABS .iter() .position(|tab| *tab == "Changes") @@ -61,16 +61,16 @@ impl App { /// Reload Changes, optionally overriding an operator-selected baseline. fn refresh_changes_with_harness(&mut self, activate_harness: bool) { let preferred_id = self - .attached_harness() + .attached_session() .map(str::to_owned) - .or_else(|| self.selected_harness_session.clone()); - let selected = self.harnesses.as_ref().map(|harnesses| { + .or_else(|| self.rail_session.clone()); + let selected = self.local_sessions.as_ref().map(|harnesses| { select_harness_baseline(harnesses.sessions.rows(), preferred_id.as_deref()) }); match selected { Some(Ok(Some((row, commit)))) => { let root = row.launch_root.as_deref().unwrap_or(&row.cwd); - self.changes.follow_harness( + self.changes.follow_session( Path::new(root), &commit, row.launch_checkout_identity @@ -78,7 +78,7 @@ impl App { .expect("validated harness identity"), ); if activate_harness { - if let Err(error) = self.changes.choose_harness_baseline() { + if let Err(error) = self.changes.choose_session_baseline() { self.set_status(error); } else { self.set_status(self.changes.status_message()); diff --git a/src/tui/src/ui/app/changes/tests.rs b/src/tui/src/ui/app/changes/tests.rs index 17b2d7907..153ee2709 100644 --- a/src/tui/src/ui/app/changes/tests.rs +++ b/src/tui/src/ui/app/changes/tests.rs @@ -399,7 +399,7 @@ fn following_a_harness_uses_its_launch_commit_until_operator_selects_another() { .0; let identity = crate::worker::pty::checkout::capture(directory.path()).expect("identity"); - state.follow_harness(directory.path(), &launch, &identity); + state.follow_session(directory.path(), &launch, &identity); state.refresh(); assert_eq!(state.root.as_deref(), Some(expected_root.as_path())); diff --git a/src/tui/src/ui/app/changes/types.rs b/src/tui/src/ui/app/changes/types.rs index bb2020e07..0c0d74246 100644 --- a/src/tui/src/ui/app/changes/types.rs +++ b/src/tui/src/ui/app/changes/types.rs @@ -36,11 +36,11 @@ pub(crate) type LoadedChanges = (Vec, Vec, Vec); /// How the active comparison baseline was chosen. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) enum BaselineSource { - /// The app-start snapshot used until a harness becomes available. + /// The app-start snapshot used until a session becomes available. #[default] AppLaunch, - /// The commit captured immediately before the selected harness was spawned. - HarnessLaunch, + /// The commit captured immediately before the selected session was spawned. + SessionLaunch, /// A commit chosen from repository history. Commit, /// A revision entered by the operator. @@ -148,7 +148,7 @@ impl GitChangesState { } } - /// Clear repository-backed content when the selected harness cannot be + /// Clear repository-backed content when the selected session cannot be /// reviewed, preventing stale changes from another repository remaining on /// screen beneath the error. pub(crate) fn clear_repository(&mut self, error: String) { @@ -172,10 +172,10 @@ impl GitChangesState { self.error = Some(error); } - /// Follow a harness's immutable launch snapshot while launch mode is active. + /// Follow a session's immutable launch snapshot while launch mode is active. /// Review comments survive baseline changes within the same repository and /// are cleared only when the repository root changes. - pub(crate) fn follow_harness( + pub(crate) fn follow_session( &mut self, cwd: &Path, launch_commit: &str, @@ -189,7 +189,7 @@ impl GitChangesState { self.harness_checkout_identity = Some(checkout_identity.to_owned()); if matches!( self.baseline_source, - BaselineSource::AppLaunch | BaselineSource::HarnessLaunch + BaselineSource::AppLaunch | BaselineSource::SessionLaunch ) && (self.root.as_ref() != Some(&root) || self.baseline.as_deref() != Some(launch_commit)) { @@ -200,7 +200,7 @@ impl GitChangesState { self.comments_root = Some(root.clone()); self.root = Some(root); self.baseline = Some(launch_commit.to_owned()); - self.baseline_source = BaselineSource::HarnessLaunch; + self.baseline_source = BaselineSource::SessionLaunch; self.selected = 0; self.cursor = 0; self.scroll = 0; @@ -228,22 +228,22 @@ impl GitChangesState { Ok(()) } - /// Return to the selected harness's repository and captured launch commit. - pub(crate) fn choose_harness_baseline(&mut self) -> Result<(), String> { + /// Return to the selected session's repository and captured launch commit. + pub(crate) fn choose_session_baseline(&mut self) -> Result<(), String> { let root = self .harness_root .clone() - .ok_or_else(|| "No harness Git repository is available".to_owned())?; + .ok_or_else(|| "No session Git repository is available".to_owned())?; let baseline = self .harness_baseline .clone() - .ok_or_else(|| "No harness launch snapshot is available".to_owned())?; + .ok_or_else(|| "No session launch snapshot is available".to_owned())?; let identity = self .harness_checkout_identity .as_deref() - .ok_or_else(|| "No harness checkout identity is available".to_owned())?; + .ok_or_else(|| "No session checkout identity is available".to_owned())?; if !crate::worker::pty::checkout::matches(&root, identity) { - return Err("Harness Git checkout changed since launch".to_owned()); + return Err("Session Git checkout changed since launch".to_owned()); } let comments_root = self.comments_root.as_ref().or(self.root.as_ref()); if comments_root != Some(&root) { @@ -252,7 +252,7 @@ impl GitChangesState { self.comments_root = Some(root.clone()); self.root = Some(root); self.baseline = Some(baseline); - self.baseline_source = BaselineSource::HarnessLaunch; + self.baseline_source = BaselineSource::SessionLaunch; self.picking_baseline = false; self.selected = 0; self.cursor = 0; @@ -265,7 +265,7 @@ impl GitChangesState { pub(crate) fn baseline_label(&self) -> String { let source = match self.baseline_source { BaselineSource::AppLaunch => "app launch", - BaselineSource::HarnessLaunch => "harness launch", + BaselineSource::SessionLaunch => "session launch", BaselineSource::Commit => "commit", BaselineSource::Manual => "manual", }; diff --git a/src/tui/src/ui/app/commands/changes.rs b/src/tui/src/ui/app/commands/changes.rs index ecbc8ec94..04e62ba08 100644 --- a/src/tui/src/ui/app/commands/changes.rs +++ b/src/tui/src/ui/app/commands/changes.rs @@ -18,7 +18,7 @@ impl App { pub(crate) fn apply_change_baseline_selection(&mut self) { let index = self.changes.baseline_index; if index == 0 { - match self.changes.choose_harness_baseline() { + match self.changes.choose_session_baseline() { Ok(()) => self.set_status(self.changes.status_message()), Err(error) => self.set_status(error), } diff --git a/src/tui/src/ui/app/commands/dispatch.rs b/src/tui/src/ui/app/commands/dispatch.rs index cfaaef219..99c534764 100644 --- a/src/tui/src/ui/app/commands/dispatch.rs +++ b/src/tui/src/ui/app/commands/dispatch.rs @@ -1,10 +1,10 @@ //! Runtime, prompt, clipboard, slash-command, and settings command dispatch. -use crate::ui::agents::{AgentRow, TaskState}; +use crate::ui::agents::TaskState; use crate::ui::clipboard::{copy_for_operator, copy_to_clipboard, current_platform, OSC_52}; use crate::ui::command::{self, CopyScope, SlashCommand}; use crate::ui::composer::Draft; -use medulla::runtime::{WorkerInfo, WorkerOp}; +use medulla::runtime::WorkerOp; use super::super::types::{ tab_pos, App, Cmd, Prompt, PromptKind, SETTINGS_SUBPAGES, SP_APPEARANCE, SP_CONFIG, SP_HELP, @@ -12,15 +12,6 @@ use super::super::types::{ }; impl App { - /// The worker under the Workers-list cursor, if the fleet is non-empty. - pub(in crate::ui::app) fn selected_host(&self) -> Option { - let ws = self.runtime.workers(); - if ws.is_empty() { - return None; - } - ws.get(self.host_index.min(ws.len() - 1)).cloned() - } - /// The task under the Agents-list cursor, when a `Sub` (task) row is selected. /// /// Indexes the rail's rows, which is what `agent_index` counts — the lane @@ -28,12 +19,9 @@ impl App { /// whichever row happened to share the offset. pub(in crate::ui::app) fn selected_agent_task(&self) -> Option { let rows = self.rail_rows(); - match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { - Some(super::super::rail::RailRow::Agent(AgentRow::Sub { task, .. })) => { - Some(task.clone()) - } - _ => None, - } + rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + .and_then(|row| row.task()) + .cloned() } /// Request cancellation of the selected running task, or note why it cannot. @@ -152,6 +140,16 @@ impl App { self.add_workspace(&text); None } + // Blank is an answer here, not a cancellation: the id minted from + // the directory is the name most agents keep. + PromptKind::AgentName { harness, workspace } => { + self.declare_new_agent(&harness, &workspace, &text); + None + } + PromptKind::SessionName { agent_id, managed } => { + self.start_agent_session(&agent_id, &text, managed); + None + } PromptKind::CustomHarnessAdd => { self.save_custom_harness(None, &text); None @@ -178,8 +176,16 @@ impl App { } PromptKind::HostEditLabel(id) => { let mut patch = serde_json::Map::new(); - patch.insert("label".into(), serde_json::Value::String(text)); + patch.insert("label".into(), serde_json::Value::String(text.clone())); + // Set first so the persist can overrule it: renaming has two + // outcomes worth more than "Updating label…" — a write that + // failed, and an install with no config file, where the new name + // lasts one run and the operator has to be told so. self.set_status("Updating label…"); + // The roster label is this run's; the declaration's name is the + // one that comes back after a restart, so an agent this machine + // declared is renamed in both places or the edit half-survives. + self.persist_agent_name(&id, &text); Some(Cmd::WorkerOp(WorkerOp::Update { id, patch })) } PromptKind::AnswerQuestion { @@ -438,11 +444,11 @@ impl App { self.new_thread(); } SlashCommand::Resume => return Some(Cmd::ListChats), - SlashCommand::NewHarness { provider, path } => { - self.start_harness_command(provider.as_deref(), path.as_deref()); + SlashCommand::StartSession { provider, path } => { + self.start_session_command(provider.as_deref(), path.as_deref()); } - SlashCommand::TakeControl => self.take_harness_control(), - SlashCommand::HandOff { note } => self.hand_harness_back(note), + SlashCommand::TakeControl => self.take_session_control(), + SlashCommand::HandOff { note } => self.hand_session_back(note), SlashCommand::Abort => { self.runtime.abort(); self.set_status("Abort requested"); diff --git a/src/tui/src/ui/app/harness_control_tests.rs b/src/tui/src/ui/app/harness_control_tests.rs deleted file mode 100644 index 4b08bb0ba..000000000 --- a/src/tui/src/ui/app/harness_control_tests.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Focused tests for harness-picker keyboard classification. - -use crossterm::event::KeyModifiers; - -use super::harness_control::is_text_input; - -#[test] -fn workspace_text_accepts_altgr_but_rejects_control_shortcuts() { - assert!(is_text_input(KeyModifiers::NONE)); - assert!(is_text_input(KeyModifiers::SHIFT)); - assert!(is_text_input(KeyModifiers::CONTROL | KeyModifiers::ALT)); - assert!(!is_text_input(KeyModifiers::CONTROL)); - assert!(!is_text_input(KeyModifiers::ALT)); -} diff --git a/src/tui/src/ui/app/harness_workspace.rs b/src/tui/src/ui/app/harness_workspace.rs index 6621cf6ed..0a89b68c7 100644 --- a/src/tui/src/ui/app/harness_workspace.rs +++ b/src/tui/src/ui/app/harness_workspace.rs @@ -4,7 +4,7 @@ use std::collections::{BinaryHeap, HashSet}; use std::path::Path; -use super::types::{App, HarnessPickerStep, WorkspaceChoice}; +use super::types::{AgentPickerStep, App, WorkspaceChoice}; use crate::ui::composer::flatten_paste; const MAX_WORKSPACE_CHOICES: usize = 10; @@ -16,12 +16,12 @@ impl App { /// Advance the launcher to its workspace step and populate the first list. pub(super) fn open_harness_workspace_step(&mut self, edit_default: bool) { let default = self - .harness_picker + .agent_picker .as_ref() .map(|picker| picker.cwd.clone()) .unwrap_or_default(); - if let Some(picker) = &mut self.harness_picker { - picker.step = HarnessPickerStep::Workspace; + if let Some(picker) = &mut self.agent_picker { + picker.step = AgentPickerStep::Workspace; picker.workspace_query = if edit_default { default } else { String::new() }; picker.workspace_index = 0; picker.workspace_picked = false; @@ -44,13 +44,13 @@ impl App { /// what the query is: a single-line box with no caret, edited by the same /// `push`/`pop` that typing uses. A path copied with a trailing newline /// therefore lands as the path plus a space, which - /// [`resolve_workspace`](crate::ui::harness_pane::LocalHarnesses::resolve_workspace) + /// [`resolve_workspace`](crate::ui::harness_pane::LocalSessions::resolve_workspace) /// trims before it is used. pub(super) fn paste_into_harness_workspace(&mut self, text: &str) { - let Some(picker) = &mut self.harness_picker else { + let Some(picker) = &mut self.agent_picker else { return; }; - if picker.step != HarnessPickerStep::Workspace { + if picker.step != AgentPickerStep::Workspace { return; } picker.workspace_query.push_str(&flatten_paste(text)); @@ -63,12 +63,12 @@ impl App { /// Recompute cached completions after the query changes. pub(super) fn refresh_harness_workspace_choices(&mut self) { - let Some(picker) = &self.harness_picker else { + let Some(picker) = &self.agent_picker else { return; }; let query = picker.workspace_query.clone(); let choices = self.workspace_choices(&query); - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.workspace_choices = choices; picker.workspace_index = picker .workspace_index @@ -90,10 +90,10 @@ impl App { /// copied out of a file manager takes — filled the list with its own /// children, and Enter silently started the harness in the first of them /// rather than in the directory that was asked for. - pub(super) fn selected_harness_workspace(&self) -> Option { - let picker = self.harness_picker.as_ref()?; + pub(super) fn selected_picker_workspace(&self) -> Option { + let picker = self.agent_picker.as_ref()?; let resolved = self - .harnesses + .local_sessions .as_ref() .map(|harnesses| harnesses.resolve_workspace(&picker.workspace_query)); // Blank means "the default", which is what the completions already rank @@ -111,13 +111,13 @@ impl App { /// Make the highlighted completion the editable query. pub(super) fn complete_harness_workspace(&mut self) { - let selected = self.harness_picker.as_ref().and_then(|picker| { + let selected = self.agent_picker.as_ref().and_then(|picker| { picker .workspace_choices .get(picker.workspace_index) .map(|choice| choice.path.clone()) }); - if let (Some(picker), Some(selected)) = (&mut self.harness_picker, selected) { + if let (Some(picker), Some(selected)) = (&mut self.agent_picker, selected) { picker.workspace_query = selected; picker.workspace_index = 0; // Completing *is* entering it: the query now names the directory, @@ -147,7 +147,7 @@ impl App { /// Rank recent, configured, and filesystem-derived workspace suggestions. fn workspace_choices(&self, query: &str) -> Vec { - let Some(harnesses) = &self.harnesses else { + let Some(harnesses) = &self.local_sessions else { return Vec::new(); }; let base = Path::new(&harnesses.workspace); diff --git a/src/tui/src/ui/app/harness_workspace_tests.rs b/src/tui/src/ui/app/harness_workspace_tests.rs index d5ab77be4..52bdc1b19 100644 --- a/src/tui/src/ui/app/harness_workspace_tests.rs +++ b/src/tui/src/ui/app/harness_workspace_tests.rs @@ -76,7 +76,7 @@ fn folder_completion_keeps_only_the_best_bounded_set() { /// An Agents-tab app with a picker parked on its workspace step, whose default /// workspace is `workspace`. fn picker_on_workspace_step(workspace: &std::path::Path) -> super::types::App { - use super::types::{App, HarnessPicker, HarnessPickerStep}; + use super::types::{AgentPicker, AgentPickerStep, App}; let mut loaded = medulla::config::LoadedConfig::defaults("medulla.tui.json".into()); loaded.config.link = Some(medulla::config::LinkConfig::default()); @@ -84,7 +84,7 @@ fn picker_on_workspace_step(workspace: &std::path::Path) -> super::types::App { std::sync::Arc::new(medulla::runtime::mock::MockRuntime::empty()), loaded, ); - app.set_local_harnesses(crate::ui::harness_pane::LocalHarnesses { + app.set_local_sessions(crate::ui::harness_pane::LocalSessions { hooks: medulla::harness_hooks::HooksConfig::default(), log: None, sessions: crate::worker::pty::PtyManager::new(), @@ -97,10 +97,11 @@ fn picker_on_workspace_step(workspace: &std::path::Path) -> super::types::App { router: None, attribution: true, }); - app.harness_picker = Some(HarnessPicker { + app.agent_picker = Some(AgentPicker { + purpose: super::types::PickerPurpose::Spawn, choices: Vec::new(), index: 0, - step: HarnessPickerStep::Workspace, + step: AgentPickerStep::Workspace, cwd: workspace.to_string_lossy().into_owned(), workspace_query: String::new(), workspace_choices: Vec::new(), @@ -126,7 +127,7 @@ fn a_pasted_directory_starts_there_rather_than_in_its_first_child() { app.on_event(crossterm::event::Event::Paste(pasted)); assert!( - !app.harness_picker + !app.agent_picker .as_ref() .unwrap() .workspace_choices @@ -134,7 +135,7 @@ fn a_pasted_directory_starts_there_rather_than_in_its_first_child() { "the children are still offered as completions" ); assert_eq!( - app.selected_harness_workspace() + app.selected_picker_workspace() .map(std::path::PathBuf::from), Some(root.path().to_path_buf()), "but Enter starts in the directory that was actually pasted" @@ -159,7 +160,7 @@ fn arrowing_onto_a_completion_still_wins_over_the_typed_query() { )); assert_eq!( - app.selected_harness_workspace() + app.selected_picker_workspace() .map(std::path::PathBuf::from), Some(root.path().join("alpha")), "a deliberately chosen completion is still what Enter uses" diff --git a/src/tui/src/ui/app/hosts/edit.rs b/src/tui/src/ui/app/hosts/edit.rs new file mode 100644 index 000000000..13dc6976f --- /dev/null +++ b/src/tui/src/ui/app/hosts/edit.rs @@ -0,0 +1,228 @@ +//! Editing an agent from the Hosts page: role assignment, undeclaring, and +//! renaming — each written to `[fleet].agentDeclarations` rather than to the +//! live roster alone. +//! +//! The distinction this file exists for: [`HubHandle::set_roles`] moves the +//! roles on the roster this process is holding, and the roster is rebuilt from +//! the declarations at every launch. Assigning a role that way is a change the +//! operator watches take effect and then loses. So every edit here writes the +//! declaration *first* and mutates the live roster second — and when the write +//! fails, the live change is not made either, because a UI showing a role the +//! file does not have is worse than one that refused. +//! +//! **The no-config-file path.** An install with nowhere to write — no +//! `--config`, no discovered file — still has a live roster in front of the +//! operator, so an edit is not refused there. It applies to *this run*, in both +//! places at once (the in-memory declaration list and the roster), and the +//! status says how long it lasts. That is one rule for all three edits here, and +//! it is the same one `save_workspaces` follows for `[host].workspaces`. +//! +//! It is deliberately not the same thing as a write *failure*. A failed write +//! means the file exists and disagrees, so nothing is applied at all — see +//! [`persist_agent_roles`](App::persist_agent_roles). Having no file to +//! disagree with is not a failure, and refusing there would leave the operator +//! unable to touch a roster they can see. What is never allowed is the third +//! option: applying the edit and saying nothing, which reads as "saved". +//! +//! [`HubHandle::set_roles`]: medulla::hub::HubHandle::set_roles + +use medulla::runtime::{AgentDeclaration, WorkerOp}; +use medulla::ui::hosts::{HostAgentRow, HostRow}; + +use super::super::types::{App, Cmd}; + +impl App { + /// Toggle `role` on the agent under the cursor, persisting the result. + /// + /// Returns the roster mutation to apply on top of the write, so the + /// orchestrator starts (or stops) routing that role here without waiting for + /// a restart. `None` when nothing was changed, when the agent is not in the + /// live roster, or when the write failed — in every one of those cases the + /// status line says why. + pub(in crate::ui::app) fn toggle_selected_agent_role(&mut self, role: &str) -> Option { + let host = self.selected_host_row()?; + let agent = self.selected_host_agent()?; + if !agent.editable { + self.set_status(format!( + "{} is declared on {} — assign its roles there", + agent.agent_id, host.label + )); + return None; + } + let mut roles = agent.roles.clone(); + let assigned = match roles.iter().position(|held| held == role) { + Some(at) => { + roles.remove(at); + false + } + None => { + roles.push(role.to_string()); + true + } + }; + let outcome = if assigned { + format!("{} now offered for {role}", agent.agent_id) + } else { + format!("{} no longer offered for {role}", agent.agent_id) + }; + if !self.persist_agent_roles(&host, &agent, roles.clone(), outcome) { + return None; + } + // Only a live agent has a roster entry to move. A declared one that is + // not running has nothing to re-register; its roles ride the declaration + // into the roster the next time it starts. + agent.live.then(|| { + Cmd::WorkerOp(WorkerOp::SetRoles { + id: agent.agent_id.clone(), + roles, + }) + }) + } + + /// Write `roles` onto the agent's declaration, narrating either outcome. + /// + /// Returns whether the caller may go on to move the live roster. An agent + /// the roster knows but no declaration covers — the migration seed — is + /// *declared here*, from what the roster reports, because a role assigned to + /// something nobody wrote down has nowhere to persist to. + fn persist_agent_roles( + &mut self, + host: &HostRow, + agent: &HostAgentRow, + roles: Vec, + outcome: String, + ) -> bool { + let current = self.loaded.config.fleet.agent_declarations.clone(); + let mut declaration = match medulla::config::agent_declaration(¤t, &agent.agent_id) { + Some(held) => held.clone(), + None => { + let Some(harness) = agent.harness.clone().filter(|h| !h.trim().is_empty()) else { + // Nothing to write down faithfully: a declaration invents an + // agent, and inventing one with no harness would advertise a + // placement that cannot run. + self.set_status(format!( + "{} reports no harness type, so its roles cannot be saved", + agent.agent_id + )); + return false; + }; + let Some(workspace) = agent.workspace.clone().filter(|w| !w.trim().is_empty()) + else { + // The same reason as the harness, one step further on: an + // agent is `harness × workspace`, and a declaration with no + // directory is one `open_new_session` then refuses with + // "declares no workspace". Saving the role would leave the + // operator looking at an agent they cannot use. + self.set_status(format!( + "{} reports no workspace, so its roles cannot be saved", + agent.agent_id + )); + return false; + }; + AgentDeclaration::new(agent.agent_id.clone(), host.id.clone(), harness, workspace) + } + }; + declaration.roles = roles; + let Some(path) = self.config_path.clone() else { + // Nowhere to write, so the edit is this run's. It has to land on the + // declaration list as well as the roster: the Hosts tree reads a + // declared agent's roles from the declaration, so updating only the + // roster would redraw the row with the roles it had before while + // `SetRoles` carried the new ones. + medulla::config::upsert_agent_declaration( + &mut self.loaded.config.fleet.agent_declarations, + declaration, + ); + self.set_status(format!("{outcome} (this run only — no config file)")); + return true; + }; + match medulla::config::declare_agent(&path, ¤t, declaration) { + Ok(declarations) => { + self.loaded.config.fleet.agent_declarations = declarations; + self.set_status(outcome); + true + } + Err(error) => { + self.set_status(format!("Roles were not saved: {error}")); + false + } + } + } + + /// Undeclare the agent under the cursor, if this machine declared it. + /// + /// Removing only undeclares: the workspace directory is left alone, because + /// the operator asked the orchestrator to stop placing work there, not to + /// lose their checkout. Returns whether the declaration went, so the caller + /// can decide what to do about the live roster entry. + pub(in crate::ui::app) fn undeclare_selected_agent(&mut self) -> bool { + let Some(agent) = self.selected_host_agent() else { + return false; + }; + if !agent.declared || !agent.editable { + return false; + } + let Some(path) = self.config_path.clone() else { + // Same rule as a role edit: nowhere to write is not a refusal, it is + // an edit that lasts one run — and the status is what keeps the + // agent's return at the next launch from being a surprise. + medulla::config::remove_agent_declaration( + &mut self.loaded.config.fleet.agent_declarations, + &agent.agent_id, + ); + self.set_status(format!( + "Undeclared {} (this run only — no config file)", + agent.agent_id + )); + return true; + }; + let current = self.loaded.config.fleet.agent_declarations.clone(); + match medulla::config::undeclare_agent(&path, ¤t, &agent.agent_id) { + Ok(declarations) => { + self.loaded.config.fleet.agent_declarations = declarations; + self.set_status(format!( + "Undeclared {} · its files are untouched", + agent.agent_id + )); + true + } + Err(error) => { + self.set_status(format!("{} was not undeclared: {error}", agent.agent_id)); + false + } + } + } + + /// Persist a renamed agent, when the label just edited belongs to one this + /// machine declares. + /// + /// The roster label is this run's; the declaration's `name` is the one that + /// comes back. A blank name clears it, which returns the agent to being + /// named by the renderer rather than by a label nobody typed. + pub(in crate::ui::app) fn persist_agent_name(&mut self, agent_id: &str, name: &str) { + let current = self.loaded.config.fleet.agent_declarations.clone(); + let Some(declaration) = medulla::config::agent_declaration(¤t, agent_id) else { + return; + }; + let mut declaration = declaration.clone(); + declaration.name = (!name.trim().is_empty()).then(|| name.trim().to_string()); + let Some(path) = self.config_path.clone() else { + // The roster label has already changed by the time this runs, so a + // silent return is the one outcome the module forbids: the operator + // would read the new name off the row and have nothing telling them + // it goes away at the next launch. + medulla::config::upsert_agent_declaration( + &mut self.loaded.config.fleet.agent_declarations, + declaration, + ); + self.set_status(format!( + "Renamed {agent_id} (this run only — no config file)" + )); + return; + }; + match medulla::config::declare_agent(&path, ¤t, declaration) { + Ok(declarations) => self.loaded.config.fleet.agent_declarations = declarations, + Err(error) => self.set_status(format!("The new name was not saved: {error}")), + } + } +} diff --git a/src/tui/src/ui/app/hosts/mod.rs b/src/tui/src/ui/app/hosts/mod.rs new file mode 100644 index 000000000..fa8a22eb2 --- /dev/null +++ b/src/tui/src/ui/app/hosts/mod.rs @@ -0,0 +1,184 @@ +//! The Hosts page's state: the `Host → Agents` tree, the cursor over it, and +//! what the operator may do to the row under it. +//! +//! The tree itself is built in the SDK ([`medulla::ui::hosts`]); everything here +//! is the app-side half — where the local hosts come from, how the flat cursor +//! maps onto a two-level tree, and how an edit reaches disk +//! ([`edit`](self::edit)). +//! +//! The page lists hosts, not workers. That distinction is the whole point: one +//! machine now declares one agent per `harness × workspace`, so the flat roster +//! the page used to render was a list of agents with the host level collapsed +//! out of it — and a fleet you cannot see the shape of is one you cannot manage. + +use medulla::config::LocalHostRef; +use medulla::ui::hosts::{host_rows, HostAgentRow, HostKind, HostRow}; + +use super::types::App; + +mod edit; + +#[cfg(test)] +mod tests; + +/// One line on the Hosts page: a host header or one agent under it. +/// +/// Both are selectable, because both answer different questions — a host row +/// previews the *machine* (capacity, readiness, budgets), an agent row previews +/// the agent and owns its role toggles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::ui::app) struct HostsRow { + /// Index into the host tree. + pub host: usize, + /// Index into that host's agents, or `None` for the host's own header row. + pub agent: Option, +} + +/// Flatten the tree into the rows the list draws and the cursor walks. +pub(in crate::ui::app) fn flatten(tree: &[HostRow]) -> Vec { + let mut rows = Vec::new(); + for (host, entry) in tree.iter().enumerate() { + rows.push(HostsRow { host, agent: None }); + rows.extend((0..entry.agents.len()).map(|agent| HostsRow { + host, + agent: Some(agent), + })); + } + rows +} + +impl App { + /// The hosts this machine runs, as the tab lists them. + /// + /// Config is the source, so a host that is declared but not running is still + /// present — that is the state where the operator most needs to see it. A + /// *running* primary overrides its own identity from the live observation: + /// `[host].workspace` is usually blank ("wherever medulla was launched") and + /// only the running host has resolved it. + pub(in crate::ui::app) fn local_host_refs(&self) -> Vec { + let mut hosts = + medulla::config::local_hosts(&self.loaded.config.host, &self.loaded.config.hosts); + if let (Some(observation), Some(primary)) = (self.host_obs.as_ref(), hosts.first_mut()) { + primary.id = observation.address().to_string(); + primary.workspace = observation.workspace().to_string(); + primary.name = medulla::config::local_host_name( + &self.loaded.config.host, + observation.workspace(), + true, + ); + } + // A host this device is not serving is not a host. It stays listed only + // while it still holds something — declared agents, or roster entries + // that outlived the switch — because dropping those would hide agents + // the operator wrote down rather than tidying the page. + let sections = std::iter::once(&self.loaded.config.host).chain(&self.loaded.config.hosts); + let hosting = self.host_obs.is_some(); + hosts + .into_iter() + .zip(sections) + .filter(|(host, section)| { + section.enabled + || (host.primary && hosting) + || self.declares_agent_on(&host.id) + || self + .runtime + .workers() + .iter() + .any(|worker| worker.address.trim() == host.id) + }) + .map(|(host, _)| host) + .collect() + } + + /// Whether this machine's config declares any agent on `host_id`. + fn declares_agent_on(&self, host_id: &str) -> bool { + self.loaded + .config + .fleet + .agent_declarations + .iter() + .any(|declaration| declaration.on_host(host_id)) + } + + /// The `Host → Agents` tree the page renders. + pub(in crate::ui::app) fn host_tree(&self) -> Vec { + host_rows( + &self.runtime.workers(), + &self.loaded.config.fleet.agent_declarations, + &self.local_host_refs(), + ) + } + + /// The flattened rows, with the cursor clamped into range. + /// + /// Returned together because every caller needs both and clamping against a + /// stale length is how a cursor ends up pointing at a row that is no longer + /// there. + pub(in crate::ui::app) fn hosts_view(&self) -> (Vec, Vec, usize) { + let tree = self.host_tree(); + let rows = flatten(&tree); + let selected = self.host_index.min(rows.len().saturating_sub(1)); + (tree, rows, selected) + } + + /// How many rows the page lists. Test/inspection seam. + pub fn hosts_row_count(&self) -> usize { + flatten(&self.host_tree()).len() + } + + /// The host under the cursor — the header itself, or the host of the agent + /// row the cursor is on. + pub(in crate::ui::app) fn selected_host_row(&self) -> Option { + let (tree, rows, selected) = self.hosts_view(); + tree.get(rows.get(selected)?.host).cloned() + } + + /// The agent under the cursor, when the cursor is on an agent row. + /// Also a test/inspection seam. + pub fn selected_host_agent(&self) -> Option { + let (tree, rows, selected) = self.hosts_view(); + let row = rows.get(selected)?; + tree.get(row.host)?.agents.get(row.agent?).cloned() + } + + /// Whether the cursor is on a host header rather than an agent. + /// Test/inspection seam. + pub fn hosts_cursor_on_host(&self) -> bool { + let (_, rows, selected) = self.hosts_view(); + rows.get(selected).is_some_and(|row| row.agent.is_none()) + } + + /// The roster entry the cursor's row acts on: the agent itself, or the entry + /// that probed the machine when the cursor is on a host header. + /// + /// This is what `Enter`, `d`, `e` and `r` target — every one of them is a + /// mutation of a *roster* entry, and a host that has none (declared here, + /// nothing running) has nothing for them to act on. + pub(in crate::ui::app) fn selected_host(&self) -> Option { + let (tree, rows, selected) = self.hosts_view(); + let row = rows.get(selected)?; + let host = tree.get(row.host)?; + let id = match row.agent { + Some(agent) => { + let agent = host.agents.get(agent)?; + agent.live.then(|| agent.agent_id.clone())? + } + None => host.detail_worker.clone()?, + }; + self.runtime + .workers() + .into_iter() + .find(|worker| worker.id == id) + } + + /// Whether the row under the cursor may be edited from here. + /// + /// The v1 capability split: a remote host's agents are declared on that + /// machine, so this end is a viewer. Orchestrator dispatch is untouched by + /// it — only what the *operator* can change from this terminal. + /// Test/inspection seam. + pub fn selected_host_is_local(&self) -> bool { + self.selected_host_row() + .is_some_and(|host| host.kind == HostKind::Local) + } +} diff --git a/src/tui/src/ui/app/hosts/tests.rs b/src/tui/src/ui/app/hosts/tests.rs new file mode 100644 index 000000000..919f37992 --- /dev/null +++ b/src/tui/src/ui/app/hosts/tests.rs @@ -0,0 +1,502 @@ +//! Unit tests for the Hosts page's tree, its cursor, and its edits. + +use std::sync::Arc; + +use medulla::config::LoadedConfig; +use medulla::runtime::mock::MockRuntime; +use medulla::runtime::{AgentDeclaration, WorkerInfo}; +use medulla::ui::hosts::HostKind; + +use super::super::types::{App, Cmd}; + +/// A roster entry at `address`, with no capability probe. +fn worker(id: &str, address: &str) -> WorkerInfo { + WorkerInfo { + id: id.into(), + address: address.into(), + handle: None, + label: None, + harness: Some("claude".into()), + workspace: Some("/w/checkout".into()), + peer_id: None, + cpu_cores: None, + memory_total_bytes: None, + memory_available_bytes: None, + ip_address: None, + selected: false, + roles: Vec::new(), + budgets: Vec::new(), + readiness: Vec::new(), + } +} + +/// An app over `workers` and `declarations`, writing to a real config file so +/// the persistence path is exercised rather than stubbed. +fn app_with( + workers: Vec, + declarations: Vec, +) -> (App, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("medulla.tui.json"); + std::fs::write(&path, "{}").unwrap(); + let runtime = MockRuntime::empty(); + runtime.set_workers(workers); + let mut loaded = LoadedConfig::defaults(path.to_string_lossy().into_owned()); + loaded.config.fleet.agent_declarations = declarations; + let mut app = App::new(Arc::new(runtime), loaded); + app.set_config_path(path); + (app, dir) +} + +/// Move the cursor to the row `steps` below the top of the list. +fn cursor_to(app: &mut App, steps: usize) { + app.focus_routing_subpage("Hosts"); + for _ in 0..steps { + let _ = app.on_event(crossterm::event::Event::Key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + ), + )); + } +} + +#[test] +fn the_local_host_leads_the_tree_with_its_agents_under_it() { + let (app, _dir) = app_with( + vec![ + worker("medulla-claude", "this-device"), + worker("peer", "7Kx"), + ], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + + let tree = app.host_tree(); + assert_eq!(tree.len(), 2, "the local host and one remote: {tree:?}"); + assert_eq!(tree[0].id, "this-device"); + assert_eq!(tree[0].kind, HostKind::Local); + assert_eq!(tree[0].agents.len(), 1); + assert_eq!(tree[1].id, "7Kx"); + assert_eq!(tree[1].kind, HostKind::Remote); + // Four rows: two headers, two agents. + assert_eq!(app.hosts_row_count(), 4); +} + +#[test] +fn the_cursor_walks_hosts_and_the_agents_under_them() { + let (mut app, _dir) = app_with(vec![worker("peer", "7Kx")], Vec::new()); + cursor_to(&mut app, 0); + + // Row 0 is the local host header, and it is where a new agent may go. + assert!(app.hosts_cursor_on_host()); + assert!(app.selected_host_is_local()); + assert!(app.selected_host_agent().is_none()); + + // Row 1 is the remote host, row 2 the agent the roster knows on it. + cursor_to(&mut app, 1); + assert!(app.hosts_cursor_on_host()); + assert!(!app.selected_host_is_local()); + cursor_to(&mut app, 1); + assert_eq!( + app.selected_host_agent().map(|agent| agent.agent_id), + Some("peer".to_string()) + ); +} + +#[test] +fn a_role_toggle_is_written_to_the_declaration_and_survives_a_reload() { + let declaration = + AgentDeclaration::new("medulla-claude", "this-device", "claude", "/w/medulla"); + let (mut app, dir) = app_with( + vec![worker("medulla-claude", "this-device")], + vec![declaration], + ); + cursor_to(&mut app, 1); // the local host's only agent + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + let cmd = app.toggle_selected_agent_role(&role); + + // The live roster moves too, so the orchestrator starts routing the role + // here without waiting for a restart. + match cmd { + Some(Cmd::WorkerOp(medulla::runtime::WorkerOp::SetRoles { id, roles })) => { + assert_eq!(id, "medulla-claude"); + assert_eq!(roles, vec![role.clone()]); + } + other => panic!("expected a SetRoles op, got {other:?}"), + } + // And — the point of the whole exercise — the file has it. + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert_eq!(written.len(), 1); + assert_eq!(written[0].roles, vec![role.clone()]); + assert_eq!(written[0].harness, "claude"); + + // Toggling back sends an empty list and clears it on disk, so the checkbox + // is not one-way. + let cmd = app.toggle_selected_agent_role(&role); + match cmd { + Some(Cmd::WorkerOp(medulla::runtime::WorkerOp::SetRoles { roles, .. })) => { + assert!(roles.is_empty(), "removal sends an empty list: {roles:?}") + } + other => panic!("expected a SetRoles op, got {other:?}"), + } + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert!(written[0].roles.is_empty()); +} + +#[test] +fn giving_a_seeded_agent_a_role_declares_it() { + // The migration case: the roster has an agent nobody wrote down. A role must + // still persist, which means the toggle declares it from what the roster + // reports rather than refusing. + let (mut app, dir) = app_with(vec![worker("this-device", "this-device")], Vec::new()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_some()); + + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert_eq!(written.len(), 1, "the seed became a declaration"); + assert_eq!(written[0].agent_id, "this-device"); + assert_eq!(written[0].host_id, "this-device"); + assert_eq!(written[0].harness, "claude"); + assert_eq!(written[0].workspace.path, "/w/checkout"); + assert_eq!(written[0].roles, vec![role]); + // The in-memory config agrees with the file, so the row redraws assigned. + assert_eq!(app.loaded.config.fleet.agent_declarations.len(), 1); +} + +#[test] +fn the_agent_preview_never_draws_past_the_rows_it_was_given() { + // The role list is windowed to whatever the fixed agent details left over. + // A zero budget used to still force one checkbox through, so the block ran + // one row past the bottom of the pane on a short terminal — and the row that + // fell off was the one carrying the role cursor. + let (mut app, _dir) = app_with( + vec![worker("medulla-claude", "this-device")], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + cursor_to(&mut app, 1); + let (tree, rows, selected) = app.hosts_view(); + let row = rows[selected]; + assert!(row.agent.is_some(), "the cursor is on the agent row"); + + // The fixed identity block is the agent, so it is always drawn; what the + // budget governs is the role list hung under it. + let details = app.preview_height_within(&tree, row, 0); + assert!(details > 1, "the identity block is several rows: {details}"); + for budget in 0..details { + assert_eq!( + app.preview_height_within(&tree, row, budget), + details, + "a {budget}-row pane has no room for roles at all" + ); + } + // One row to spare buys the summary — the sentence saying what the agent is + // offered for — rather than one checkbox out of a dozen, which reads as the + // whole list. + assert_eq!( + app.preview_height_within(&tree, row, details + 1), + details + 1 + ); + for budget in details..=details + 12 { + assert!( + app.preview_height_within(&tree, row, budget) <= budget, + "a {budget}-row pane drew {} rows", + app.preview_height_within(&tree, row, budget) + ); + } +} + +#[test] +fn a_remote_agent_is_read_only() { + let (mut app, dir) = app_with(vec![worker("peer", "7Kx")], Vec::new()); + cursor_to(&mut app, 2); // local header, remote header, remote agent + + let agent = app.selected_host_agent().expect("a remote agent row"); + assert!(!agent.editable); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!( + app.toggle_selected_agent_role(&role).is_none(), + "a remote agent's roles are that machine's to assign" + ); + assert!( + app.status().contains("declared on"), + "and the refusal says why: {}", + app.status() + ); + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert!(written.is_empty(), "nothing is declared for a remote host"); +} + +#[test] +fn a_failed_write_changes_nothing_at_all() { + // A UI showing a role the file does not have is worse than one that refused: + // the operator would believe the assignment survived the restart it will not. + let (mut app, dir) = app_with( + vec![worker("medulla-claude", "this-device")], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + // A directory cannot be written as a config file. + app.set_config_path(dir.path().to_path_buf()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_none()); + assert!( + app.status().starts_with("Roles were not saved"), + "status: {}", + app.status() + ); + assert!( + app.loaded.config.fleet.agent_declarations[0] + .roles + .is_empty(), + "the in-memory list must not drift from the file" + ); +} + +/// The same app with no config file at all, which is the "this run only" path. +fn app_without_config(workers: Vec, declarations: Vec) -> App { + let runtime = MockRuntime::empty(); + runtime.set_workers(workers); + let mut loaded = LoadedConfig::defaults("medulla.tui.json".into()); + loaded.config.fleet.agent_declarations = declarations; + App::new(Arc::new(runtime), loaded) +} + +#[test] +fn with_no_config_file_an_edit_lasts_the_run_and_says_so() { + // Nowhere to write is not a refusal — the roster is in front of the + // operator and has to stay editable — but it must not read as saved + // either. So the edit lands in *both* places for this run: the roster op + // the caller sends, and the declaration list the tree redraws from. Only + // updating the roster left the row showing the roles it had before. + let mut app = app_without_config( + vec![worker("medulla-claude", "this-device")], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_some()); + assert!( + app.status().contains("this run only"), + "the operator is told how long it lasts: {}", + app.status() + ); + assert_eq!( + app.loaded.config.fleet.agent_declarations[0].roles, + vec![role.clone()], + "the declaration the tree reads from moved too" + ); + assert_eq!( + app.selected_host_agent().map(|agent| agent.roles), + Some(vec![role.clone()]), + "so the row redraws assigned rather than reverting" + ); + + // And the toggle is not one-way: the second press sees the role it just + // wrote and takes it back off. + assert!(app.toggle_selected_agent_role(&role).is_some()); + assert!(app.loaded.config.fleet.agent_declarations[0] + .roles + .is_empty()); +} + +#[test] +fn with_no_config_file_a_rename_lasts_the_run_and_says_so() { + // The roster label has already changed by the time the persist runs, so a + // silent return would leave the operator reading a name that disappears at + // the next launch with nothing having said so. + let mut app = app_without_config( + Vec::new(), + vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )], + ); + + app.persist_agent_name("api-codex", "Backend"); + + assert!( + app.status().contains("this run only"), + "status: {}", + app.status() + ); + assert_eq!( + app.loaded.config.fleet.agent_declarations[0] + .name + .as_deref(), + Some("Backend"), + "the name is the run's, in the same place the saved one would be" + ); +} + +#[test] +fn with_no_config_file_undeclaring_lasts_the_run_and_says_so() { + // One rule for the whole path: removing is an edit like the others, so it + // applies for this run rather than being refused — and the status is what + // stops the agent's return at the next launch being a surprise. + let mut app = app_without_config( + Vec::new(), + vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )], + ); + cursor_to(&mut app, 1); + + assert!(app.undeclare_selected_agent()); + assert!(app.loaded.config.fleet.agent_declarations.is_empty()); + assert!( + app.status().contains("this run only"), + "status: {}", + app.status() + ); +} + +#[test] +fn an_agent_with_no_workspace_cannot_be_declared_by_a_role_toggle() { + // An agent is `harness × workspace`. Seeding one with no directory writes a + // declaration no session can be opened from — `open_new_session` refuses it + // with "declares no workspace" — so the role toggle says so up front rather + // than saving a row the operator then cannot use. + let mut bare = worker("mystery", "this-device"); + bare.workspace = None; + let (mut app, dir) = app_with(vec![bare], Vec::new()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_none()); + assert!(app.status().contains("no workspace"), "{}", app.status()); + assert!( + medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")).is_empty() + ); +} + +#[test] +fn an_agent_with_no_harness_cannot_be_declared_by_a_role_toggle() { + // Declaring an agent with no harness would advertise a placement that cannot + // run, so the toggle says so instead of inventing one. + let mut bare = worker("mystery", "this-device"); + bare.harness = None; + let (mut app, dir) = app_with(vec![bare], Vec::new()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_none()); + assert!(app.status().contains("no harness"), "{}", app.status()); + assert!( + medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")).is_empty() + ); +} + +#[test] +fn undeclaring_an_agent_removes_it_from_the_file_only() { + let (mut app, dir) = app_with( + Vec::new(), + vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )], + ); + cursor_to(&mut app, 1); + + assert!(app.undeclare_selected_agent()); + assert!( + medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")).is_empty() + ); + assert!(app.loaded.config.fleet.agent_declarations.is_empty()); + // An agent the roster owns but this machine never declared is not ours to + // undeclare — that path removes the roster entry instead. + let (mut remote, _dir) = app_with(vec![worker("peer", "7Kx")], Vec::new()); + cursor_to(&mut remote, 2); + assert!(!remote.undeclare_selected_agent()); +} + +#[test] +fn a_host_this_device_does_not_serve_drops_out_unless_it_still_holds_something() { + let (mut app, _dir) = app_with(Vec::new(), Vec::new()); + assert_eq!(app.host_tree().len(), 1, "hosting is on by default"); + + app.loaded.config.host.enabled = false; + assert!( + app.host_tree().is_empty(), + "nothing declared, nothing running, not hosting" + ); + + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )]; + assert_eq!( + app.host_tree().len(), + 1, + "declared agents keep their host listed" + ); +} diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 1473fafc1..6d40fc51b 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -46,7 +46,7 @@ impl App { /// Handle scroll and left-click mouse events for the active tab. pub(in crate::ui::app) fn on_mouse(&mut self, m: crossterm::event::MouseEvent) -> Option { if self.kill_armed.take().is_some() { - self.set_status("Harness kill cancelled"); + self.set_status("Session kill cancelled"); } // Ahead of every other rule, including the modal one below: a button // that went down in a harness has to come back up in it. The grab is @@ -81,10 +81,10 @@ impl App { // A modal swallows the mouse, the same way it swallows the keyboard. // Pickers and the hand-back question are modal: a click that navigated // the rail behind one would leave an overlay describing a row nobody - // was pointing at. In particular, do not let a second harness click + // was pointing at. In particular, do not let a second session click // replace the session named by an already-visible hand-back prompt. if self.resume_picker.is_some() - || self.harness_picker.is_some() + || self.agent_picker.is_some() || self.handback_prompt.is_some() { return None; @@ -105,7 +105,7 @@ impl App { MouseEventKind::Down(MouseButton::Left) => { if let Some(session) = self.harness_focus.attached_to().map(str::to_string) { let inside_attached_pane = - self.hit_harness.as_ref().is_some_and(|(rect, id)| { + self.hit_session.as_ref().is_some_and(|(rect, id)| { id == &session && rect.contains((m.column, m.row).into()) }); // Only the attached harness's own rail row is not a @@ -128,10 +128,10 @@ impl App { // focus as Ctrl-]. Settle the configured hand-back policy // before changing the selected tab or rail row; otherwise // an Ask prompt would refer to a pane already hidden. - if !self.begin_harness_release(&session) { + if !self.begin_session_release(&session) { return None; } - self.release_harness(); + self.release_session(); } } self.drag_anchor = Some((m.column, m.row)); @@ -180,7 +180,7 @@ impl App { /// of where the pointer is inside the screen it believes it owns — a release /// at a negative offset would wrap to the far side of the pane instead. fn deliver_pointer_grab(&mut self, m: &crossterm::event::MouseEvent) -> bool { - let Some(grab) = self.harness_pointer_grab.clone() else { + let Some(grab) = self.pointer_grab.clone() else { return false; }; let Some((button, motion)) = pointer_report(m.kind) else { @@ -194,9 +194,9 @@ impl App { return false; } if motion == Motion::Release { - self.harness_pointer_grab = None; + self.pointer_grab = None; } - let Some(harnesses) = self.harnesses.clone() else { + let Some(harnesses) = self.local_sessions.clone() else { return true; }; let rect = grab.rect; @@ -236,7 +236,7 @@ impl App { let Some(session) = self.harness_focus.attached_to().map(str::to_string) else { return false; }; - let Some((rect, id)) = self.hit_harness.clone() else { + let Some((rect, id)) = self.hit_session.clone() else { return false; }; if id != session || !rect.contains((m.column, m.row).into()) { @@ -245,7 +245,7 @@ impl App { let Some((button, motion)) = pointer_report(m.kind) else { return false; }; - let Some(harnesses) = self.harnesses.clone() else { + let Some(harnesses) = self.local_sessions.clone() else { return false; }; if !harnesses.takes_mouse(&session) { @@ -257,20 +257,20 @@ impl App { harnesses.mouse_button(&session, m.column - rect.x, m.row - rect.y, button, motion); // The press opens the grab that owns the rest of the gesture. Recorded // even for a child in press-only mode, whose release - // [`mouse_button`](crate::ui::harness_pane::LocalHarnesses::mouse_button) + // [`mouse_button`](crate::ui::harness_pane::LocalSessions::mouse_button) // will drop: the grab is about *routing*, and a release routed to the // harness and then dropped by its protocol is right, while the same // release re-routed to our own drag-selection is not. match motion { crate::ui::harness_pane::mouse::Motion::Press => { - self.harness_pointer_grab = Some(PointerGrab { + self.pointer_grab = Some(PointerGrab { session, button, rect, }); } crate::ui::harness_pane::mouse::Motion::Release => { - self.harness_pointer_grab = None; + self.pointer_grab = None; } crate::ui::harness_pane::mouse::Motion::Drag => {} } @@ -289,9 +289,9 @@ impl App { // being attached: reading back through a harness's output is the most // common thing to want from one, and making it cost a chord first would // be the wrapper getting in the way. - if let Some((rect, session)) = self.hit_harness.clone() { + if let Some((rect, session)) = self.hit_session.clone() { if rect.contains((x, y).into()) { - if let Some(harnesses) = self.harnesses.clone() { + if let Some(harnesses) = self.local_sessions.clone() { // Pane-relative: the child believes its screen starts at its // own origin, and reporting our absolute position would put // the event somewhere else entirely on it. @@ -423,6 +423,23 @@ impl App { return None; } if tab == "Agents" { + // §A7: clicking an entry of the orchestrator's "sessions started" + // block opens that session — the rail selection follows, which is + // what makes the pane show its conversation. Checked before the rail + // because the block lives in the pane beside it. + if let Some((rect, tasks)) = self.hit_started_sessions.clone() { + if rect.contains((x, y).into()) { + // Only the rows that *are* entries answer; a click on the + // conversation between them falls through to the rail's own + // hit test, exactly as a click above the block used to. + if let Some(task_id) = tasks.get((y - rect.y) as usize).and_then(Option::as_ref) + { + let task_id = task_id.clone(); + self.focus_session_for_task(&task_id); + return self.retarget_watch(); + } + } + } // The rail stacks two hit boxes — threads above lanes — so both are // tried; an `else if` here would leave the strip unclickable. if let Some((rect, window_start)) = self.hit_threads { @@ -460,9 +477,22 @@ impl App { // requiring a second keystroke to confirm what was // already aimed at is the friction it exists to // remove. - if row.is_new_harness() { - self.open_harness_picker(); - return None; + // + // Both action branches return the retarget rather + // than nothing, for the same reason the overflow and + // harness branches below do: an action row watches + // no task, so a click arriving from one that did has + // to stop that stream — and neither open method + // clears `watching` on its own. + if row.is_new_agent() { + self.open_new_agent_picker(); + return self.retarget_watch(); + } + // Same rule for the per-agent action: a click on + // `+ new session` opens the flow it names. + if let Some(agent_id) = row.new_session_agent().map(str::to_string) { + self.open_new_session(&agent_id); + return self.retarget_watch(); } // So is a lane's `+N more`: the click that lands on // it is the request to see what it is counting. @@ -483,20 +513,20 @@ impl App { // over the pane the operator was mid-sentence // in, whose only useful answer was Esc. if self.harness_focus.is_attached_to(session) { - self.harness_pane_session = Some(session.to_string()); + self.pane_session = Some(session.to_string()); return None; } // Point the prompt at the row that was clicked, // not at whatever the last render left behind. - // `harness_pane_session` is written during the + // `pane_session` is written during the // draw, and no draw happens between the cursor // move above and this call — so without this the // prompt would offer to hand over the previously // visible harness, and confirming it would // transfer control of one the operator never // pointed at. - self.harness_pane_session = Some(session.to_string()); - self.open_harness_enter_prompt(); + self.pane_session = Some(session.to_string()); + self.open_session_enter_prompt(); // Drop whatever task the previous row was // watching, exactly as the fall-through below // does for every other row. This branch returns @@ -516,12 +546,12 @@ impl App { // A click inside the embedded terminal means "type here", the same // as `Ctrl-]`. Checked after the rail so a click that changes rows // is a navigation, not an attach to whatever the last frame showed. - if let Some((rect, session)) = self.hit_harness.clone() { + if let Some((rect, session)) = self.hit_session.clone() { if rect.contains((x, y).into()) - && self.harness_pane_session.as_deref() == Some(session.as_str()) + && self.pane_session.as_deref() == Some(session.as_str()) && !self.harness_focus.is_attached_to(&session) { - self.attach_to_pane_harness(); + self.attach_to_pane_session(); } } } else if tab == "Settings" && self.settings_subpage() == "Context" { diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 74e8f33b1..b38c3f1a5 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -41,8 +41,11 @@ impl App { /// cursor, so the rows just revealed are the ones on screen. pub(in crate::ui::app) fn page_subtasks(&mut self) -> bool { let rows = self.rail_rows(); - let Some(RailRow::Agent(AgentRow::More { lane_index, hidden })) = - rows.get(self.agent_index) + // The overflow row is a fold row, not an agent row: under the + // `Host → Agent → Session` taxonomy an agent's own row is the declared + // identity, and everything the fold still owns — the orchestrator lane, + // the `── functions ──` divider, this counter — arrives as `Lane`. + let Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(self.agent_index) else { return false; }; @@ -97,12 +100,11 @@ impl App { let found = rows .iter() .position(|row| { - matches!(row, RailRow::Agent(AgentRow::More { lane_index: l, .. }) if *l == lane_index) + matches!(row, RailRow::Lane(AgentRow::More { lane_index: l, .. }) if *l == lane_index) }) .or_else(|| { - rows.iter().position(|row| { - matches!(row, RailRow::Agent(AgentRow::Lane { lane_index: l }) if *l == lane_index) - }) + rows.iter() + .position(|row| matches!(row, RailRow::Agent(agent) if agent.lane_index == Some(lane_index))) }); self.agent_index = found.unwrap_or_else(|| self.agent_index.min(rows.len().saturating_sub(1))); @@ -240,14 +242,12 @@ impl App { } let rows = self.rail_rows(); let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?; - let RailRow::Agent(AgentRow::Sub { - task, lane_index, .. - }) = row - else { + let RailRow::Session(session) = row else { return None; }; + let task = session.task.as_ref()?; let lanes = self.lanes(); - let lane = lanes.get(*lane_index)?; + let lane = lanes.get(session.lane_index?)?; // `Agent` is main's name for a roster agent / delegated task / peer // session — the tiers above it (orchestrator, reasoning, compress) run // no watchable harness. @@ -272,9 +272,7 @@ impl App { pub(in crate::ui::app) fn kill_target(&self) -> Option<(String, String)> { let rows = self.rail_rows(); let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?; - let RailRow::Agent(AgentRow::Sub { task, .. }) = row else { - return None; - }; + let task = row.task()?; (task.status == TaskStatus::Running) .then(|| self.watch_target()) .flatten() diff --git a/src/tui/src/ui/app/input/paste.rs b/src/tui/src/ui/app/input/paste.rs index 177d7dc21..4f2c5c4ae 100644 --- a/src/tui/src/ui/app/input/paste.rs +++ b/src/tui/src/ui/app/input/paste.rs @@ -34,7 +34,7 @@ impl App { /// keystrokes it happens to spell; /// 3. an open inline prompt, flattened to one line because that is all it /// can draw; - /// 4. the harness picker, whose workspace step is a path box and whose + /// 4. the session picker, whose workspace step is a path box and whose /// harness step is a list; /// 5. any remaining modal, before anything per-tab — one can be raised over /// a tab the operator has since moved to; @@ -57,7 +57,7 @@ impl App { // it out let a paste land in a composer while the question stayed armed // for whatever key came next. if self.kill_armed.take().is_some() { - self.set_status("Harness kill cancelled"); + self.set_status("Session kill cancelled"); return; } // The hand-back question is asked while still attached, so it outranks @@ -75,10 +75,10 @@ impl App { prompt.paste(text); return; } - // The harness picker is two overlays in one: a provider list with no + // The session picker is two overlays in one: a harness-type list with no // field, then a visible path box. Routed as one call so the distinction // stays with the picker rather than being re-derived here. - if self.harness_picker.is_some() { + if self.agent_picker.is_some() { self.paste_into_harness_workspace(text); return; } diff --git a/src/tui/src/ui/app/input/tests.rs b/src/tui/src/ui/app/input/tests.rs index c715c1e79..4227d67fc 100644 --- a/src/tui/src/ui/app/input/tests.rs +++ b/src/tui/src/ui/app/input/tests.rs @@ -58,7 +58,7 @@ fn select_overflow_row(app: &mut App) { let index = app .rail_rows() .iter() - .position(|row| matches!(row, RailRow::Agent(AgentRow::More { .. }))) + .position(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))) .expect("a lane with hidden sublanes has an overflow row"); app.agent_index = index; } @@ -67,7 +67,7 @@ fn select_overflow_row(app: &mut App) { fn on_overflow_row(app: &App) -> bool { matches!( app.rail_rows().get(app.agent_index), - Some(RailRow::Agent(AgentRow::More { .. })) + Some(RailRow::Lane(AgentRow::More { .. })) ) } @@ -87,7 +87,7 @@ fn click_overflow_row(app: &mut App) -> Option { let overflow = app .rail_rows() .iter() - .position(|row| matches!(row, RailRow::Agent(AgentRow::More { .. }))) + .position(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))) .expect("a lane with hidden sublanes has an overflow row"); let (rect, owners) = app.hit_agents.clone().expect("the rail was drawn"); let line = owners @@ -186,6 +186,55 @@ fn clicking_the_overflow_row_stops_a_task_stream_it_left_behind() { assert_eq!(app.watching, None); } +/// Click the first row `want` accepts, resolved through the rendered hit map. +fn click_row(app: &mut App, want: impl Fn(&RailRow) -> bool) -> Option { + draw(app); + let index = app + .rail_rows() + .iter() + .position(want) + .expect("the rail has the row under test"); + let (rect, owners) = app.hit_agents.clone().expect("the rail was drawn"); + let line = owners + .iter() + .position(|owner| *owner == index) + .expect("the row is on screen"); + app.handle_click(rect.x, rect.y + line as u16) +} + +#[test] +fn clicking_an_action_row_stops_a_task_stream_it_left_behind() { + // `+ New agent` and `+ new session` are controls, not conversations: neither + // watches a task, and neither open method clears `watching`. A click that + // arrives from a task row therefore has to release that stream on its way, + // or a worker keeps sampling and sending a screen nobody is looking at. + for want in [ + &(|row: &RailRow| row.is_new_agent()) as &dyn Fn(&RailRow) -> bool, + &|row: &RailRow| row.new_session_agent().is_some(), + ] { + let mut app = app_with_tasks("dev", 3); + app.set_local_sessions(super::super::rail::tests::shell_harnesses( + crate::worker::pty::PtyManager::new(), + )); + app.loaded.config.fleet.agent_declarations = vec![medulla::runtime::AgentDeclaration::new( + "dev", + "", + "codex", + "/work/dev", + )]; + let watched = ("worker-1".to_string(), "dev-t1".to_string()); + app.watching = Some(watched.clone()); + + let cmd = click_row(&mut app, want); + + assert!( + matches!(&cmd, Some(Cmd::WatchTask { stop, start: None }) if stop.as_ref() == Some(&watched)), + "expected the click to stop the previous watch, got {cmd:?}" + ); + assert_eq!(app.watching, None); + } +} + #[test] fn an_expansion_follows_its_lane_when_the_rows_move() { let mut app = app_with_tasks("dev", 25); diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index faeeb5542..46ebe58bb 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -37,7 +37,7 @@ impl App { /// [`agents_panes`](crate::ui::app::render) lays out from, so the keyboard /// and the screen cannot disagree about whether there is somewhere to type. pub fn agents_composer_shown(&self) -> bool { - self.on_orchestrator_lane() && self.harness_pane_session.is_none() + self.on_orchestrator_lane() && self.pane_session.is_none() } /// Whether the Agents rail currently holds the keyboard. @@ -51,11 +51,19 @@ impl App { self.agents_focus == AgentsFocus::Rail || !self.agents_composer_shown() } - /// Whether the rail cursor sits on the `+ New session` action row. - pub(in crate::ui::app) fn on_new_harness_row(&self) -> bool { + /// Whether the rail cursor sits on the `+ New agent` action row. + pub(in crate::ui::app) fn on_new_agent_row(&self) -> bool { let rows = self.rail_rows(); rows.get(self.agent_index.min(rows.len().saturating_sub(1))) - .is_some_and(|row| row.is_new_harness()) + .is_some_and(|row| row.is_new_agent()) + } + + /// The agent whose `+ new session` action row the cursor sits on, if it does. + pub(in crate::ui::app) fn on_new_session_row(&self) -> Option { + let rows = self.rail_rows(); + rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + .and_then(|row| row.new_session_agent()) + .map(str::to_string) } /// Move the keyboard to the rail. Nothing else about the draft changes, so @@ -86,14 +94,14 @@ impl App { // its diff rather than making the operator tab across and rely on // the Changes view to remember which of several harnesses they had // selected. When no harness is shown, `d` remains ordinary typing. - KeyCode::Char('d') if !ctrl && !alt && self.harness_pane_session.is_some() => { + KeyCode::Char('d') if !ctrl && !alt && self.pane_session.is_some() => { AgentsKey::Handled(self.open_selected_harness_changes()) } KeyCode::Char('K') => { if let Some(target) = self.kill_target() { self.arm_kill(target); } else { - self.set_status("Select a running harness task first"); + self.set_status("Select a running session first"); } AgentsKey::Handled(None) } @@ -106,12 +114,19 @@ impl App { AgentsKey::Handled(self.retarget_watch()) } // Enter is "I have found the row I wanted; let me type" — except on - // the rows that are themselves an action: the harness starter, and a - // lane's `+N more`, where it pages the hidden sublanes into view. A - // visible harness consumes it earlier and takes the keyboard instead. + // the rows that are themselves an action: `+ New agent`, an agent's + // `+ new session`, and a lane's `+N more`, where it pages the hidden + // sessions into view. A visible session pane consumes it earlier and + // takes the keyboard instead. KeyCode::Enter => { - if self.on_new_harness_row() { - self.open_harness_picker(); + if self.on_new_agent_row() { + self.open_new_agent_picker(); + } else if let Some(agent_id) = self.on_new_session_row() { + // The same flow `Ctrl-T` opens on an agent row: the name + // prompt, then a session in that agent's declared harness + // and directory. Enter is how a row that *is* an action is + // taken, so the two doors lead to one place. + self.open_new_session(&agent_id); } else if !self.page_subtasks() { self.focus_agents_composer(); } diff --git a/src/tui/src/ui/app/keys/harness.rs b/src/tui/src/ui/app/keys/harness.rs index af3fce2bd..afefb4f12 100644 --- a/src/tui/src/ui/app/keys/harness.rs +++ b/src/tui/src/ui/app/keys/harness.rs @@ -22,7 +22,7 @@ use crate::ui::harness_pane::{ HarnessFocus, FOCUS_CHORD_LABEL, }; use crate::worker::pty::launch::bracket_paste; -use crate::worker::pty::HarnessControl; +use crate::worker::pty::SessionControl; use super::super::types::{AgentsFocus, App}; @@ -31,13 +31,14 @@ impl App { /// /// Safe to call when nothing is attached; that is the common case on the /// render path, which calls this whenever the selection moves. - pub(crate) fn release_harness(&mut self) { + pub(crate) fn release_session(&mut self) { // The operator has been looking at and handling this pane. Consume any // completion bell observed while it was attached so detaching cannot // reveal a stale alert that the hidden rail deliberately suppressed. - if let (Some(session), Some(harnesses)) = - (self.harness_focus.attached_to(), self.harnesses.as_ref()) - { + if let (Some(session), Some(harnesses)) = ( + self.harness_focus.attached_to(), + self.local_sessions.as_ref(), + ) { harnesses.sessions.acknowledge(session); } self.harness_focus = HarnessFocus::Chrome; @@ -54,13 +55,13 @@ impl App { if let Some(session) = self.harness_focus.attached_to().map(str::to_string) { if is_focus_chord(key) { // Releasing the keyboard is also the moment to settle who holds - // the harness. `begin_harness_release` answers `false` when it + // the harness. `begin_session_release` answers `false` when it // opened a prompt about that, and the keyboard must stay put // until it is answered — moving it out from under the question // would leave the operator answering about a pane they can no // longer see the state of. - if self.begin_harness_release(&session) { - self.release_harness(); + if self.begin_session_release(&session) { + self.release_session(); // The keyboard has to land somewhere it can be seen. The // cursor is on a harness row, which draws no composer, so // the rail is the only half of the tab that can answer a @@ -68,7 +69,7 @@ impl App { // every key after a release look like a dead terminal. self.focus_agents_rail(); self.set_status(format!( - "Released the harness · {FOCUS_CHORD_LABEL} to type again" + "Released the session · {FOCUS_CHORD_LABEL} to type again" )); } return true; @@ -79,27 +80,27 @@ impl App { // *Attaching* is a chrome binding, not a mode, so it yields to whatever // overlay is on top of the chrome. The pane behind an open picker is // still drawn — and so still resolves a harness session — which is how - // Enter in the "start a harness" modal used to attach to the harness + // Enter in the "start a session" modal used to attach to the session // already selected underneath it instead of launching the chosen one. if self.overlay_owns_keys() { return false; } let enter_on_harness = key.code == KeyCode::Enter && key.modifiers == KeyModifiers::NONE - && self.harness_pane_session.is_some() + && self.pane_session.is_some() && self.agents_focus == AgentsFocus::Rail; // Enter asks first. It is a navigation key, and walking the rail onto a // managed harness must not silently take it away from the orchestrator; // the chord below is the deliberate spelling and still attaches outright. if enter_on_harness { - self.open_harness_enter_prompt(); + self.open_session_enter_prompt(); // Consumed either way: Enter reaches this branch only when the // visible pane resolved to a harness, so it must not submit a // hidden composer or return focus to one. return true; } if is_focus_chord(key) { - self.attach_to_pane_harness(); + self.attach_to_pane_session(); return true; } false @@ -115,17 +116,17 @@ impl App { /// terminal is what every other terminal on the machine means by "type /// here", and requiring a chord to do it made the embedded pane the one /// exception. - pub(in crate::ui::app) fn attach_to_pane_harness(&mut self) { - let Some(session) = self.harness_pane_session.clone() else { - self.set_status("No harness on this row — select a running task to type into one"); + pub(in crate::ui::app) fn attach_to_pane_session(&mut self) { + let Some(session) = self.pane_session.clone() else { + self.set_status("No session on this row — select a running one to type into"); return; }; let running = self - .harnesses + .local_sessions .as_ref() .is_some_and(|harnesses| harnesses.is_running(&session)); if !running { - self.set_status("That harness has exited — its last screen is all that is left"); + self.set_status("That session has exited — its last screen is all that is left"); return; } // Focusing in *is* taking over. Keyboard ownership without control is @@ -134,27 +135,34 @@ impl App { // in, and a harness serves one turn at a time, so the two prompts come // back as one confidently wrong answer rather than as an error. let took = self - .harnesses + .local_sessions .as_ref() .and_then(|harnesses| harnesses.control(&session)) - == Some(HarnessControl::Orchestrator); + == Some(SessionControl::Orchestrator); if took { - if let Some(harnesses) = self.harnesses.clone() { - harnesses.set_control(&session, HarnessControl::User); + if let Some(harnesses) = self.local_sessions.clone() { + harnesses.set_control(&session, SessionControl::User); } - self.harness_took_control = true; } + // Set from `took` on *every* attachment, not only the ones that took + // something. The flag is about the session now being attached, and + // nothing else clears it — `release_session` leaves the keyboard without + // touching who holds what — so a `true` left by an earlier attachment + // survived into the next one. A write failure then read it and handed + // back a session the operator had already been holding before they + // focused in. + self.took_control_by_attach = took; // Attaching answers whatever the harness was blinking about: the // operator is now looking at the screen that was asking. A named prompt // that is still up returns on the next refresh, so nothing is lost by // clearing it here — and a rail that keeps blinking at the pane you are // already typing in is how an indicator becomes furniture. - if let Some(harnesses) = self.harnesses.as_ref() { + if let Some(harnesses) = self.local_sessions.as_ref() { harnesses.sessions.acknowledge(&session); } self.harness_focus = HarnessFocus::Attached(session); self.set_status(format!( - "Typing into the harness · you have control · {FOCUS_CHORD_LABEL} to release" + "Typing into the session · you have control · {FOCUS_CHORD_LABEL} to release" )); } @@ -188,7 +196,7 @@ impl App { /// line discipline is raw and reads carriage return as the end of a line. pub(in crate::ui::app) fn paste_into_harness(&mut self, session: &str, text: &str) { let bracketed = self - .harnesses + .local_sessions .as_ref() .and_then(|harnesses| harnesses.sessions.bracketed_paste(session)) .unwrap_or(false); @@ -203,8 +211,8 @@ impl App { /// Write already-encoded bytes to the attached harness, detaching if the /// child has stopped listening. fn write_to_harness(&mut self, session: &str, bytes: &[u8]) { - let Some(harnesses) = self.harnesses.clone() else { - self.release_harness(); + let Some(harnesses) = self.local_sessions.clone() else { + self.release_session(); return; }; if let Err(err) = harnesses.write(session, bytes) { @@ -212,12 +220,12 @@ impl App { // the session back on the way out: a dead harness left under user // control is a slot nothing can ever reclaim, and there is nobody // left to answer a hand-back prompt about it. - if self.harness_took_control { - harnesses.set_control(session, HarnessControl::Orchestrator); - self.harness_took_control = false; + if self.took_control_by_attach { + harnesses.set_control(session, SessionControl::Orchestrator); + self.took_control_by_attach = false; } - self.release_harness(); - self.set_status(format!("Harness stopped listening ({err})")); + self.release_session(); + self.set_status(format!("Session stopped listening ({err})")); return; } // Typing means "I am here now". A pane left scrolled back would keep diff --git a/src/tui/src/ui/app/keys/mod.rs b/src/tui/src/ui/app/keys/mod.rs index 0cbdc2bef..8524f49bb 100644 --- a/src/tui/src/ui/app/keys/mod.rs +++ b/src/tui/src/ui/app/keys/mod.rs @@ -48,10 +48,10 @@ impl App { // owns exactly one keypress: only a deliberate `y` proceeds. if let Some((worker, task_id)) = self.kill_armed.take() { if k.code == KeyCode::Char('y') && k.modifiers.is_empty() { - self.set_status(format!("Killing harness for {task_id}…")); + self.set_status(format!("Killing the session for {task_id}…")); return Some(Cmd::KillTask { worker, task_id }); } - self.set_status("Harness kill cancelled"); + self.set_status("Session kill cancelled"); return None; } @@ -94,11 +94,11 @@ impl App { } // The harness picker owns navigation while open. - if self.harness_picker.is_some() { + if self.agent_picker.is_some() { if ctrl && k.code == KeyCode::Char('c') { self.should_quit = true; } else { - self.handle_harness_picker_key(k); + self.handle_agent_picker_key(k); } return None; } @@ -149,6 +149,21 @@ impl App { self.should_quit = true; return None; } + // On the Agents tab, *away from the orchestrator*, this is the + // way back to the conversation after clicking through to a + // session (§A7): the rail cursor returns to the orchestrator and + // the composer takes the keyboard. + // + // Scoped to "not already there" rather than to the tab, because + // the chord's other job — releasing the mouse for native + // drag-select — is wanted most while reading that very + // transcript. So it returns you first and toggles the mouse once + // you have arrived, and `/mouse` reaches the toggle from + // anywhere either way. + KeyCode::Char('o') if tab == "Agents" && !self.on_orchestrator_lane() => { + self.focus_orchestrator(); + return None; + } KeyCode::Char('o') => { self.toggle_mouse(); return None; @@ -174,17 +189,24 @@ impl App { self.new_thread(); return None; } - // Start a harness of your own. `Ctrl-T` for terminal; `Ctrl-N` - // is already a new thread, which is the thing it would - // otherwise be confused with. + // Open a session. `Ctrl-T` for terminal; `Ctrl-N` is already a + // new thread, which is the thing it would otherwise be confused + // with. On a row that names an agent it opens a session *of that + // agent* — its declared harness in its declared workspace, named + // by the operator — because that is the whole point of having + // declared one. Anywhere else it falls back to the free-form + // picker, which declares nothing. KeyCode::Char('t') => { - self.open_harness_picker(); + match self.selected_agent_id().filter(|_| tab == "Agents") { + Some(agent_id) => self.open_new_session(&agent_id), + None => self.open_session_picker(), + } return None; } // Grab or give: one chord for both directions, because the rail // row and the pane title both say which way it will go. KeyCode::Char('g') => { - self.toggle_harness_control(); + self.toggle_session_control(); return None; } // Walk the open threads. The bare arrows belong to the composer, diff --git a/src/tui/src/ui/app/keys/routing/add_host.rs b/src/tui/src/ui/app/keys/routing/add_host.rs index 911846397..8a69c6973 100644 --- a/src/tui/src/ui/app/keys/routing/add_host.rs +++ b/src/tui/src/ui/app/keys/routing/add_host.rs @@ -27,7 +27,7 @@ impl App { // the arrows belong to the live step. Letting them keep driving // the kind list meant confirming Remote and arrowing to Local // carried the confirmation across, so the next Enter skipped - // "Choose a harness" and asked for a directory for a harness + // "Choose a harness type" and asked for a directory for a harness type // nobody had picked. Esc is how you go back a step. match (self.add_host_selected_kind(), self.add_host_kind_chosen) { (AddHostKind::Local, true) => { @@ -74,7 +74,7 @@ impl App { // would mean the arrows never reached the harness list. AddHostKind::Local if !self.add_host_kind_chosen => { self.add_host_kind_chosen = true; - self.set_status("Choose a harness · Enter to set the directory"); + self.set_status("Choose a harness type · Enter to set the directory"); } AddHostKind::Local => { let providers = self.add_host_providers(); diff --git a/src/tui/src/ui/app/keys/routing/mod.rs b/src/tui/src/ui/app/keys/routing/mod.rs index c4210a7a9..86d42c34a 100644 --- a/src/tui/src/ui/app/keys/routing/mod.rs +++ b/src/tui/src/ui/app/keys/routing/mod.rs @@ -72,7 +72,11 @@ impl App { } } - /// Browse and mutate the registered host roster. + /// Browse the `Host → Agents` tree and mutate the row under the cursor. + /// + /// The cursor walks hosts *and* the agents under them, because they answer + /// different questions: a host row is the machine (its capacity, and where a + /// new agent would go), an agent row is the thing a dispatch targets. fn hosts_key(&mut self, code: KeyCode) -> RoutingKey { // The preview's role toggles are a second cursor on the same page, so // they claim the arrows while focused. `→` drills in and `←` backs out, @@ -85,32 +89,17 @@ impl App { } match code { KeyCode::Up | KeyCode::Char('k') => { - self.host_index = crate::ui::selection::moved( - self.host_index, - self.runtime.workers().len(), - true, - ); + self.host_index = + crate::ui::selection::moved(self.host_index, self.hosts_row_count(), true); RoutingKey::Handled(None) } KeyCode::Down | KeyCode::Char('j') => { - self.host_index = crate::ui::selection::moved( - self.host_index, - self.runtime.workers().len(), - false, - ); + self.host_index = + crate::ui::selection::moved(self.host_index, self.hosts_row_count(), false); RoutingKey::Handled(None) } KeyCode::Right | KeyCode::Char('l') => { - if self.selected_host().is_none() { - return RoutingKey::Handled(None); - } - if self.agent_templates().is_empty() { - self.set_status("No agent templates are declared — nothing to assign"); - return RoutingKey::Handled(None); - } - self.host_roles_focus = true; - self.host_role_index = 0; - self.set_status("Roles · Space toggles · ← back to the host list"); + self.open_agent_roles(); RoutingKey::Handled(None) } KeyCode::Char('a') => { @@ -118,6 +107,10 @@ impl App { self.open_add_host_prompt(); RoutingKey::Handled(None) } + KeyCode::Char('n') => { + self.new_agent_from_hosts(); + RoutingKey::Handled(None) + } KeyCode::Char('s') | KeyCode::Enter => { let cmd = self.selected_host().map(|worker| { self.set_status(format!( @@ -128,16 +121,7 @@ impl App { }); RoutingKey::Handled(cmd) } - KeyCode::Char('d') | KeyCode::Char('x') => { - let cmd = self.selected_host().map(|worker| { - self.set_status(format!( - "Removing {}", - worker.label.as_deref().unwrap_or(&worker.address) - )); - Cmd::WorkerOp(WorkerOp::Remove { id: worker.id }) - }); - RoutingKey::Handled(cmd) - } + KeyCode::Char('d') | KeyCode::Char('x') => RoutingKey::Handled(self.remove_host_row()), KeyCode::Char('e') => { if let Some(worker) = self.selected_host() { let mut draft = Draft::new(); @@ -164,11 +148,103 @@ impl App { } } - /// Drive the selected host's role toggles. + /// Give the arrows to the selected agent's role toggles, or say why not. /// - /// `None` means the key was not a role-list key and the host roster should - /// see it — so `a`, `r`, `d` and the rest keep working without leaving the - /// preview first. + /// Roles belong to an *agent*, not to a machine — a laptop is not "the + /// reviewer", the agent working in the reviewed checkout is. So the toggles + /// open on an agent row only, and only where the declaration behind them can + /// be written: on a remote host they are that machine's to assign. + fn open_agent_roles(&mut self) { + let Some(agent) = self.selected_host_agent() else { + if self.selected_host_row().is_some() { + self.set_status("Select an agent (↑↓) to assign its roles"); + } + return; + }; + if !agent.editable { + let host = self + .selected_host_row() + .map(|host| host.label) + .unwrap_or_else(|| "that machine".into()); + self.set_status(format!( + "{} is declared on {host} — assign its roles there", + agent.agent_id + )); + return; + } + if self.agent_templates().is_empty() { + self.set_status("No agent templates are declared — nothing to assign"); + return; + } + self.host_roles_focus = true; + self.host_role_index = 0; + self.set_status("Roles · Space toggles · ← back to the list"); + } + + /// Point the operator at where an agent is created — and, on a remote host, + /// explain why it cannot be created from here. + /// + /// Declaring an agent is the Agents tab's flow (it needs the harness picker + /// and a workspace); this page owns the *capability*, which is why the key + /// answers on both kinds of host rather than being silently inert on one. + fn new_agent_from_hosts(&mut self) { + let Some(host) = self.selected_host_row() else { + return; + }; + if !host.accepts_new_agents() { + self.set_status(format!( + "Agents are declared on {} itself — this end is read-only", + host.label + )); + return; + } + match crate::ui::app::TABS.iter().position(|tab| *tab == "Agents") { + Some(index) => { + self.tab_index = index; + self.set_status(format!("New agent on {} · pick a harness type", host.label)); + } + None => self.set_status("Declare a new agent from the Agents tab"), + } + } + + /// Remove what the cursor is on: an agent, or a whole remote host. + /// + /// An agent this machine declared is *undeclared* first — dropping only the + /// roster entry would leave the declaration behind to re-create it at the + /// next launch, which reads as a removal that did not take. + fn remove_host_row(&mut self) -> Option { + // Both reads happen before the undeclare: removing a declaration + // reshapes the tree under the cursor, and resolving the roster entry + // afterwards would answer for whichever row slid into its place. + let agent = self.selected_host_agent(); + let worker = self.selected_host(); + // The removal key is reachable while the role toggles hold the arrows — + // `host_roles_key` passes `d`/`x` through. Leaving the focus on would + // point the next arrow at the roles of whichever row slid up into the + // cursor, which is not the agent whose toggles were open. + self.host_roles_focus = false; + let undeclared = self.undeclare_selected_agent(); + match (agent, worker) { + // Declared, not running: the declaration was the whole of it. + (Some(_), None) => None, + (_, Some(worker)) => { + if !undeclared { + self.set_status(format!( + "Removing {}", + worker.label.as_deref().unwrap_or(&worker.address) + )); + } + Some(Cmd::WorkerOp(WorkerOp::Remove { id: worker.id })) + } + (None, None) => None, + } + } + + /// Drive the selected agent's role toggles. + /// + /// `None` means the key was not a role-list key and the list should see it — + /// so `a`, `r`, `d` and the rest keep working without leaving the preview + /// first. fn host_roles_key(&mut self, code: KeyCode) -> Option { let templates = self.agent_templates(); // The catalog can empty out under us (a template file is deleted, a @@ -194,28 +270,12 @@ impl App { Some(RoutingKey::Handled(None)) } KeyCode::Char(' ') | KeyCode::Enter => { - let host = self.selected_host()?; let role = templates.get(self.host_role_index)?.id.clone(); - // Whole-list replacement, so the toggle reads the current set, - // flips one entry, and sends the result. Roles ride the hub's - // descriptor, so this re-registers — the point is that the - // orchestrator starts routing this role here. - let mut roles = host.roles.clone(); - let assigned = if let Some(at) = roles.iter().position(|held| held == &role) { - roles.remove(at); - false - } else { - roles.push(role.clone()); - true - }; - self.set_status(if assigned { - format!("{} now offered for {role}", host.id) - } else { - format!("{} no longer offered for {role}", host.id) - }); - Some(RoutingKey::Handled(Some(Cmd::WorkerOp( - WorkerOp::SetRoles { id: host.id, roles }, - )))) + // Whole-list replacement written to the declaration first: the + // roster is rebuilt from declarations every launch, so a role + // set only on the live entry is one the operator watches take + // effect and then loses. + Some(RoutingKey::Handled(self.toggle_selected_agent_role(&role))) } _ => None, } @@ -353,7 +413,7 @@ impl App { KeyCode::Char('r') => { self.reload_custom_harnesses(); self.refresh_credential_status_if_needed(); - self.set_status("Harnesses refreshed"); + self.set_status("Harness types refreshed"); RoutingKey::Handled(Some(Cmd::RefreshFleet)) } _ => RoutingKey::Unhandled, diff --git a/src/tui/src/ui/app/mod.rs b/src/tui/src/ui/app/mod.rs index b7b19652a..d7272e76d 100644 --- a/src/tui/src/ui/app/mod.rs +++ b/src/tui/src/ui/app/mod.rs @@ -8,10 +8,14 @@ //! [`feedback`] the feedback-board subpage's actions and setters, //! [`settings_edit`] the Config subpage's editable settings, [`account`] the //! logout action, [`templates`] the agent-template store's install/reload -//! actions, and [`render`] the ratatui draw for each view. Public items +//! actions, [`hosts`] the Hosts page's `Host → Agents` tree and its edits, +//! and [`render`] the ratatui draw for each view. Public items //! are re-exported here so callers use `crate::ui::app::*`. mod account; +mod agent_control; +#[cfg(test)] +mod agent_control_tests; mod appearance; mod changes; mod commands; @@ -19,12 +23,10 @@ mod credentials; mod custom_harnesses; mod decisions; mod feedback; -mod harness_control; -#[cfg(test)] -mod harness_control_tests; mod harness_workspace; #[cfg(test)] mod harness_workspace_tests; +mod hosts; mod input; mod keys; mod overlays; @@ -33,8 +35,16 @@ mod overlays_tests; mod rail; mod render; mod routing_options; +mod session_control; +#[cfg(test)] +mod session_control_tests; +mod session_focus; +#[cfg(test)] +mod session_focus_tests; mod settings_edit; mod state; +#[cfg(test)] +mod state_tests; mod status_line; mod templates; mod types; diff --git a/src/tui/src/ui/app/overlays.rs b/src/tui/src/ui/app/overlays.rs index 73f9aa9cf..c961cab73 100644 --- a/src/tui/src/ui/app/overlays.rs +++ b/src/tui/src/ui/app/overlays.rs @@ -31,7 +31,7 @@ impl App { [ (Overlay::Decisions, self.decision_open), (Overlay::TemplatePopup, self.template_popup_open()), - (Overlay::HarnessPicker, self.harness_picker.is_some()), + (Overlay::AgentPicker, self.agent_picker.is_some()), (Overlay::HandbackPrompt, self.handback_prompt.is_some()), (Overlay::InlinePrompt, self.prompt.is_some()), ( diff --git a/src/tui/src/ui/app/overlays_tests.rs b/src/tui/src/ui/app/overlays_tests.rs index 9f31f0ecc..ccde16086 100644 --- a/src/tui/src/ui/app/overlays_tests.rs +++ b/src/tui/src/ui/app/overlays_tests.rs @@ -18,8 +18,8 @@ use medulla::config::LoadedConfig; use medulla::runtime::mock::MockRuntime; use super::types::{ - tab_pos, App, HandbackPrompt, HarnessPicker, HarnessPickerStep, Overlay, PromptKind, - ResumePicker, RP_TEMPLATES, + tab_pos, AgentPicker, AgentPickerStep, App, HandbackPrompt, Overlay, PromptKind, ResumePicker, + RP_TEMPLATES, }; use crate::ui::composer::{Draft, TextPrompt}; @@ -39,11 +39,12 @@ fn raise(app: &mut App, overlay: Overlay) { app.tab_index = tab_pos("Hosts"); app.routing_index = RP_TEMPLATES; } - Overlay::HarnessPicker => { - app.harness_picker = Some(HarnessPicker { + Overlay::AgentPicker => { + app.agent_picker = Some(AgentPicker { + purpose: super::types::PickerPurpose::Spawn, choices: Vec::new(), index: 0, - step: HarnessPickerStep::Harness, + step: AgentPickerStep::Harness, cwd: "/".into(), workspace_query: String::new(), workspace_choices: Vec::new(), @@ -77,7 +78,7 @@ fn raise(app: &mut App, overlay: Overlay) { const EVERY_OVERLAY: [Overlay; 6] = [ Overlay::Decisions, Overlay::TemplatePopup, - Overlay::HarnessPicker, + Overlay::AgentPicker, Overlay::HandbackPrompt, Overlay::InlinePrompt, Overlay::ResumePicker, @@ -198,7 +199,7 @@ fn overlays_are_listed_back_to_front_in_the_order_the_render_paints_them() { // The list is iterated to paint, so its order is the stacking order: the // hand-back question is asked over the picker that may have opened it. let mut app = app(); - raise(&mut app, Overlay::HarnessPicker); + raise(&mut app, Overlay::AgentPicker); raise(&mut app, Overlay::HandbackPrompt); raise(&mut app, Overlay::Decisions); @@ -206,7 +207,7 @@ fn overlays_are_listed_back_to_front_in_the_order_the_render_paints_them() { app.visible_overlays(), vec![ Overlay::Decisions, - Overlay::HarnessPicker, + Overlay::AgentPicker, Overlay::HandbackPrompt ] ); diff --git a/src/tui/src/ui/app/rail.rs b/src/tui/src/ui/app/rail.rs deleted file mode 100644 index 7c65f7285..000000000 --- a/src/tui/src/ui/app/rail.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! The Agents rail: one cursor over the lanes *and* the declared fleet. -//! -//! The two lists answer adjacent questions — what is running, and what it is -//! running on — and an operator moves between them constantly: an agent stalls, -//! and the next thing you want is the harness it sits on and how much budget -//! that harness has left. Splitting them across tabs meant losing your place in -//! one to look at the other, so they share a rail and a selection here. -//! -//! Rows keep their own models ([`AgentRow`] from the event fold, [`FleetNode`] -//! from the declared capacity); this module only concatenates them, tracks which -//! is selected, and answers what the detail pane should show. - -use super::types::App; -use crate::ui::agents::{AgentRole, AgentRow}; -use crate::worker::pty::SessionRow; - -/// The label on the rail's "start a harness" row. -pub(in crate::ui::app) const NEW_SESSION_LABEL: &str = "+ New session"; - -/// One row of the Agents rail. -#[derive(Debug, Clone)] -pub enum RailRow { - /// A lane, task sublane, or lane-list divider. - /// - /// The lane list's own `── functions ──` separator is an - /// `AgentRow::Separator`; this variant is the group, not the row type. - Agent(AgentRow), - /// The action row that starts a harness of the operator's own. - /// - /// Sits directly under the orchestrator lane because that is where the eye - /// already is — starting a terminal was otherwise a chord (`Ctrl-T`) with - /// nothing on screen to suggest it exists, which is the same as not having - /// it for anyone who has not read the bindings. - NewHarness, - /// The `── your sessions ──` divider above the operator's own sessions. - HarnessSeparator, - /// A harness the operator started, which no lane will ever describe. - /// - /// Lanes are folded from task events, so a session nothing dispatched into - /// produces none — which is exactly the state an unmanaged harness lives in. - /// Without its own group it would be running, costing tokens, and invisible. - Harness(SessionRow), -} - -impl RailRow { - /// Whether the cursor may land on this row. - pub fn selectable(&self) -> bool { - match self { - RailRow::Agent(row) => row.selectable(), - RailRow::NewHarness => true, - RailRow::HarnessSeparator => false, - RailRow::Harness(_) => true, - } - } - - /// The PTY session this row names, when it names one directly. - pub fn session_id(&self) -> Option<&str> { - match self { - RailRow::Harness(row) => Some(row.id.as_str()), - _ => None, - } - } - - /// Whether this row is the "start a harness" action. - pub fn is_new_harness(&self) -> bool { - matches!(self, RailRow::NewHarness) - } -} - -impl App { - /// The rail's rows: the agent lanes. - /// - /// The declared fleet used to hang underneath these, and it was a third - /// rendering of things that already had two homes. Its agents were the very - /// lanes above the divider, so a worker that was both connected and declared - /// appeared twice; its hosts and harnesses are the Routing tab's Harnesses - /// page, which reads the same `fleet_capacity()`; and its templates were - /// already excluded here in favour of Routing's Agent Templates page. What - /// remained was duplication, so the rail now shows what is *running* and - /// nothing else. - /// Operator-started harnesses hang below the lanes under their own divider, - /// because they are the one thing running on this device that the event fold - /// cannot see. The `+ New session` action sits between the two, directly - /// under the orchestrator lane: it is what produces the group below it, and - /// a device that hosts nothing cannot start one, so it is absent there - /// rather than present and refusing. - pub(super) fn rail_rows(&self) -> Vec { - let lanes = self.lanes(); - let can_start = self.harnesses.is_some(); - let mut rows: Vec = Vec::new(); - let mut placed = !can_start; - for row in self.agent_rows() { - let orchestrator = matches!(&row, AgentRow::Lane { lane_index } - if lanes.get(*lane_index).map(|l| l.role) == Some(AgentRole::Orchestrator)); - rows.push(RailRow::Agent(row)); - if orchestrator && !placed { - rows.push(RailRow::NewHarness); - placed = true; - } - } - // No orchestrator lane yet — the very first frame, before any fold has - // run. The action still belongs on screen, and the top is where the - // orchestrator will appear above it. - if !placed { - rows.insert(0, RailRow::NewHarness); - } - let own = self.own_harness_rows(); - if !own.is_empty() { - rows.push(RailRow::HarnessSeparator); - rows.extend(own.into_iter().map(RailRow::Harness)); - } - rows - } - - /// How many local harnesses are waiting on the operator right now. - /// - /// Counts every live session on this device, not only the rows on the rail: - /// a harness the orchestrator started and then got stuck on a permission - /// prompt is exactly the case an operator needs told about, and it has no - /// row of its own — it is somewhere inside a lane. - /// - /// The attached session is excluded. Its prompt is on screen in front of - /// the person the count is for, so counting it would ask them to go and - /// look at what they are already looking at. - /// Reads the waiting *ids* rather than [`rows`](crate::worker::pty::PtyManager::rows), - /// which clones every session's whole row. This runs on the render thread - /// once per frame for the tab badge, so it takes one lock and copies the - /// handful of ids that are actually waiting — usually none. - pub(in crate::ui) fn harnesses_waiting(&self) -> usize { - let Some(harnesses) = self.harnesses.as_ref() else { - return 0; - }; - Self::count_waiting(&harnesses.sessions.waiting_sessions(), &self.harness_focus) - } - - /// The same count from an already-collected waiting set. - /// - /// The rail collects that set anyway to style its lanes, so the header count - /// is derived from it instead of taking the lock a second time — and, more - /// to the point, the header and the rows beneath it are then answering from - /// one snapshot rather than from two taken a few microseconds apart. - pub(in crate::ui) fn count_waiting( - waiting: &std::collections::HashSet, - focus: &crate::ui::harness_pane::HarnessFocus, - ) -> usize { - waiting - .iter() - .filter(|id| !focus.is_attached_to(id)) - .count() - } - - /// The harnesses this operator started or now holds, oldest first. - /// - /// Exited ones stay listed: the last screen is often the reason it exited, - /// and a row that vanishes on failure is a row that hides the failure. They - /// leave when the operator forgets them. - pub(super) fn own_harness_rows(&self) -> Vec { - let Some(harnesses) = self.harnesses.as_ref() else { - return Vec::new(); - }; - let mut rows: Vec = harnesses - .sessions - .rows() - .into_iter() - .filter(|row| { - row.origin.is_user() || row.control == crate::worker::pty::HarnessControl::User - }) - .collect(); - rows.sort_by_key(|row| row.started_at); - rows - } -} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs new file mode 100644 index 000000000..def8ab4e3 --- /dev/null +++ b/src/tui/src/ui/app/rail/mod.rs @@ -0,0 +1,477 @@ +//! The Agents rail: one cursor over the whole `Host → Agent → Session` tree. +//! +//! The rail used to concatenate two lists — the lanes the event fold produced, +//! and the harnesses the operator had started under a `── your harnesses ──` +//! divider — and that split is exactly what the agent/session redefinition +//! removes. A task *is* an agent session; a harness is not an entity at all, only +//! the type an agent runs. So the rail now renders one tree: +//! +//! ```text +//! ◆ orchestrator ← the conversation (not an agent) +//! + New agent ← declares one on this machine +//! ▸ this device ← host row, only when a remote host exists +//! ● medulla-claude ← DECLARED agent: present with zero sessions +//! ├ t_41 · running ← a session the orchestrator dispatched +//! └ debug login ← a session the operator started +//! ``` +//! +//! **Agents come from the tree, not from traffic**: a lane is folded from task +//! events, so an agent nothing had been dispatched to produced no row at all — +//! which made the rail a list of what happened rather than of what exists. +//! +//! The host and agent levels are the shared `Host → Agent` projection +//! ([`medulla::ui::hosts::host_rows`]) — literally the same call the Hosts tab +//! renders, so the two lenses cannot disagree about what exists. Lanes attach to +//! the agents it produces; a lane for an agent the projection does not know (a +//! backend-side roster agent, a peer session) still gets a row of its own, so +//! nothing that used to be visible disappears. **Sessions** are the rail's own +//! level and are resolved here: a dispatched one by the roster id the hub filed +//! its task under, an operator-started one by [`resolve::agent_for_session`]. +//! +//! Row shapes live in [`types`]; the session → agent rule in [`resolve`]; this +//! module is the assembly. + +use medulla::config::agent_declarations_for_host; +use medulla::runtime::AgentDeclaration; +use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; + +use super::types::App; +use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; +use crate::worker::pty::SessionRow; + +pub(in crate::ui::app) mod resolve; +#[cfg(test)] +pub(in crate::ui::app) mod tests; +mod types; + +pub use types::{AgentRailRow, HostRailRow, RailRow, SessionRailRow}; + +/// The label on the rail's "declare an agent" row. +/// +/// It says *agent* rather than *harness* because that is what it produces: a +/// declared `harness × workspace` identity that outlives the session it starts. +pub(in crate::ui::app) const NEW_AGENT_LABEL: &str = "+ New agent"; + +/// The label on the action row that opens a session under an agent. +/// +/// Indented and lower-cased beside [`NEW_AGENT_LABEL`] because it is a leaf of +/// one agent's group rather than an action on the machine. +pub(in crate::ui::app) const NEW_SESSION_LABEL: &str = "+ new session"; + +/// One agent and the sessions hanging off it, before the tree is flattened. +struct AgentGroup { + /// The agent row itself. + row: AgentRailRow, + /// Its sessions, dispatched and operator-started alike. + sessions: Vec, + /// Sessions the fold's own page already hid, carried so the counts add up. + hidden: usize, + /// Whether the fold drew an overflow row under this agent's lane. + /// + /// The rail does **not** re-cap what the fold already paged (#171): the fold + /// reveals `SUBTASK_PAGE` sessions per page and decides when the `+N more` + /// row exists, including the fully-revealed case where it is instead the + /// `show less` control and `hidden` is zero. A second cap here would clip + /// below the page the operator just asked for, so this only records that the + /// row is owed. + overflow: bool, +} + +/// One host and the agents placed on it, before the tree is flattened. +struct HostGroup { + /// The host row, drawn only once there is more than one of them. + row: HostRailRow, + /// Its agents, in the order the shared projection lists them. + agents: Vec, +} + +impl App { + /// The agent declarations this machine's config records. + /// + /// Read live rather than cached: [`declare_agent`](medulla::config::declare_agent) + /// writes the file and hands back the list, which is assigned straight into + /// the loaded config, so the next frame's rail is the list as written. + pub(in crate::ui::app) fn agent_declarations(&self) -> &[AgentDeclaration] { + &self.loaded.config.fleet.agent_declarations + } + + /// The host id this machine's agents are declared against. + /// + /// The running host's bus address is the authority — it is what the local + /// roster stamps every entry with. Without a running host there is nothing + /// local to place agents on, and the empty string matches the declarations + /// that name no host. + pub(in crate::ui::app) fn local_host_id(&self) -> String { + self.host_obs + .as_ref() + .map(|host| host.address().to_string()) + .unwrap_or_default() + } + + /// The declarations belonging to this machine, in declaration order. + pub(in crate::ui::app) fn local_agent_declarations(&self) -> Vec { + let host_id = self.local_host_id(); + agent_declarations_for_host(self.agent_declarations(), &host_id) + .into_iter() + .cloned() + .collect() + } + + /// The rail's rows: the conversation, the create action, and the tree. + /// + /// Assembled in three passes so each one answers a single question. First the + /// fold is split — the orchestrator and the function lanes keep their rows, + /// the agent lanes become groups keyed by agent id. Then the shared + /// `Host → Agent` projection places those groups, adding every agent that has + /// no traffic and every host that holds one. Last the live PTY sessions are + /// attached to whichever agent declares the directory they run in, and the + /// whole thing is flattened under host rows — which appear only when there is + /// more than one host to tell apart. + pub(super) fn rail_rows(&self) -> Vec { + let lanes = self.lanes(); + let (lane_rows, folded) = self.split_fold(&lanes); + let mut hosts = place_agents(&self.host_tree(), folded); + let orphans = self.attach_sessions(&mut hosts); + self.flatten(lane_rows, hosts, orphans) + } + + /// Split the folded rows into the non-agent ones and the per-agent groups. + fn split_fold(&self, lanes: &[AgentLane]) -> (Vec, Vec) { + let mut lane_rows: Vec = Vec::new(); + let mut groups: Vec = Vec::new(); + for row in self.agent_rows() { + match row { + AgentRow::Lane { lane_index } => { + let Some(lane) = lanes.get(lane_index) else { + continue; + }; + if lane.role != AgentRole::Agent { + lane_rows.push(row); + continue; + } + groups.push(self.group_for_lane(lane, lane_index)); + } + AgentRow::Sub { + lane_index, task, .. + } => { + let Some(group) = groups.last_mut() else { + continue; + }; + group.sessions.push(SessionRailRow { + agent_id: Some(group.row.agent_id.clone()), + lane_index: Some(lane_index), + task: Some(task), + local: None, + last: false, + }); + } + AgentRow::More { hidden, .. } => { + if let Some(group) = groups.last_mut() { + group.hidden += hidden; + group.overflow = true; + } + } + AgentRow::Separator => lane_rows.push(row), + } + } + (lane_rows, groups) + } + + /// The group an agent-role lane opens. + /// + /// The lane's `agent_id` is the roster id the hub filed its tasks under, so + /// it is also the key the projection's agent is matched by — the two cannot + /// drift, because the roster is a projection of the declarations. The host id + /// here is only a hint for a lane the projection turns out not to know; a + /// placed agent takes its host from the tree. + fn group_for_lane(&self, lane: &AgentLane, lane_index: usize) -> AgentGroup { + let agent_id = lane + .agent_id + .clone() + .unwrap_or_else(|| lane.key.trim_start_matches("agent:").to_string()); + let host_id = lane + .descriptor + .as_ref() + .and_then(|descriptor| descriptor.host_id.clone()) + .unwrap_or_default(); + AgentGroup { + row: AgentRailRow { + agent_id, + host_id, + agent: None, + lane_index: Some(lane_index), + }, + sessions: Vec::new(), + hidden: 0, + overflow: false, + } + } + + /// Attach the live local sessions to their agents, returning the unclaimed. + /// + /// An unclaimed session runs in a directory nothing declares. It is listed at + /// the end rather than dropped — a session that is running, costing tokens + /// and invisible is the failure the old `── your harnesses ──` group existed + /// to prevent — and it is what the inline create-agent offer is for. + fn attach_sessions(&self, hosts: &mut [HostGroup]) -> Vec { + let declarations = self.local_agent_declarations(); + let mut groups: Vec<&mut AgentGroup> = hosts + .iter_mut() + .flat_map(|host| host.agents.iter_mut()) + .collect(); + let mut orphans = Vec::new(); + for row in self.own_session_rows() { + let agent_id = resolve::agent_for_session(&declarations, &row) + .map(|declaration| declaration.agent_id.clone()); + let index = agent_id.as_ref().and_then(|agent_id| { + groups + .iter() + .position(|group| group.row.agent_id.trim() == agent_id.trim()) + }); + let session = SessionRailRow { + agent_id, + lane_index: index.and_then(|index| groups[index].row.lane_index), + task: None, + local: Some(row), + last: false, + }; + match index { + Some(index) => groups[index].sessions.push(session), + None => orphans.push(session), + } + } + orphans + } + + /// Flatten the tree into rows, wrapping agents in host rows when needed. + fn flatten( + &self, + lane_rows: Vec, + hosts: Vec, + orphans: Vec, + ) -> Vec { + let mut rows: Vec = lane_rows.into_iter().map(RailRow::Lane).collect(); + // A device that hosts nothing cannot declare an agent on itself, so the + // action is absent there rather than present and refusing. + let hosting = self.local_sessions.is_some(); + if hosting { + rows.push(RailRow::NewAgent); + } + // Which agents this machine may open a session under: a session is + // started by the host that owns the agent, so only the agents declared + // here get the action. Collected once rather than re-scanned per group. + let declared: Vec = if hosting { + self.local_agent_declarations() + .into_iter() + .map(|declaration| declaration.agent_id) + .collect() + } else { + Vec::new() + }; + // Progressive disclosure: one host is the common case, and a permanent + // `mac-studio ▸` wrapper would add a level of nesting to the surface an + // operator uses most. + let show_hosts = hosts.len() > 1; + for mut host in hosts { + if show_hosts { + rows.push(RailRow::Host(host.row)); + } + for group in &mut host.agents { + let offers_session = declared + .iter() + .any(|agent_id| agent_id.trim() == group.row.agent_id.trim()); + push_group(&mut rows, group, offers_session); + } + } + rows.extend(orphans.into_iter().map(|mut session| { + session.last = true; + RailRow::Session(Box::new(session)) + })); + rows + } + + /// How many local harnesses are waiting on the operator right now. + /// + /// Counts every live session on this device, not only the rows on the rail: + /// a session the orchestrator started and then got stuck on a permission + /// prompt is exactly the case an operator needs told about, and it may have + /// no row of its own. + /// + /// The attached session is excluded. Its prompt is on screen in front of + /// the person the count is for, so counting it would ask them to go and + /// look at what they are already looking at. + pub(in crate::ui) fn sessions_waiting(&self) -> usize { + let Some(harnesses) = self.local_sessions.as_ref() else { + return 0; + }; + Self::count_waiting(&harnesses.sessions.waiting_sessions(), &self.harness_focus) + } + + /// The same count from an already-collected waiting set. + /// + /// The rail collects that set anyway to style its rows, so the header count + /// is derived from it instead of taking the lock a second time — and, more + /// to the point, the header and the rows beneath it are then answering from + /// one snapshot rather than from two taken a few microseconds apart. + pub(in crate::ui) fn count_waiting( + waiting: &std::collections::HashSet, + focus: &crate::ui::harness_pane::HarnessFocus, + ) -> usize { + waiting + .iter() + .filter(|id| !focus.is_attached_to(id)) + .count() + } + + /// The sessions on this device that no dispatched task already describes. + /// + /// A dispatched session reaches the rail through its task, folded from the + /// event stream; listing it here as well would show one session twice. What + /// is left is the operator's own — started by them, or taken from the + /// orchestrator — which nothing else on this device can see. + /// + /// Exited ones stay listed: the last screen is often the reason it exited, + /// and a row that vanishes on failure is a row that hides the failure. They + /// leave when the operator forgets them. + pub(super) fn own_session_rows(&self) -> Vec { + let Some(harnesses) = self.local_sessions.as_ref() else { + return Vec::new(); + }; + let mut rows: Vec = harnesses + .sessions + .rows() + .into_iter() + .filter(|row| { + row.origin.is_user() || row.control == crate::worker::pty::SessionControl::User + }) + .collect(); + rows.sort_by_key(|row| row.started_at); + rows + } +} + +/// Place the folded lanes onto the shared `Host → Agent` tree. +/// +/// The tree decides what exists and in what order — it is the same projection +/// the Hosts tab renders, so the two lenses list the same agents under the same +/// hosts. A lane is matched onto its agent by id and contributes only what the +/// projection cannot know: the transcript behind the row, and the tasks folded +/// under it. +/// +/// A lane the tree does not know keeps a row of its own. That is not a leftover +/// case: an agent the backend rosters is not necessarily one this hub declares +/// or advertises, and a rail that dropped it would hide work that is running. +fn place_agents(tree: &[HostRow], folded: Vec) -> Vec { + let mut folded: Vec> = folded.into_iter().map(Some).collect(); + let mut hosts: Vec = tree + .iter() + .map(|host| HostGroup { + row: HostRailRow { + host_id: host.id.clone(), + label: host.label.clone(), + local: host.kind == HostKind::Local, + }, + agents: host + .agents + .iter() + .map(|agent| placed_agent(agent, &host.id, &mut folded)) + .collect(), + }) + .collect(); + for group in folded.into_iter().flatten() { + match unplaced_host(&hosts, &group.row.host_id) { + Some(index) => hosts[index].agents.push(group), + None => hosts.push(HostGroup { + row: HostRailRow { + host_id: group.row.host_id.clone(), + label: "unplaced".to_string(), + local: false, + }, + agents: vec![group], + }), + } + } + hosts +} + +/// One agent of the tree, with its folded lane taken if it has one. +fn placed_agent( + agent: &HostAgentRow, + host_id: &str, + folded: &mut [Option], +) -> AgentGroup { + let taken = folded + .iter_mut() + .find(|group| { + group + .as_ref() + .is_some_and(|group| group.row.agent_id.trim() == agent.agent_id.trim()) + }) + .and_then(Option::take); + let mut group = taken.unwrap_or_else(|| AgentGroup { + row: AgentRailRow { + agent_id: agent.agent_id.clone(), + host_id: host_id.to_string(), + agent: None, + lane_index: None, + }, + sessions: Vec::new(), + hidden: 0, + overflow: false, + }); + group.row.host_id = host_id.to_string(); + group.row.agent = Some(agent.clone()); + group +} + +/// Where a lane the tree does not know is drawn: the host it names if that host +/// is on the tree, else the machine looking at it. +/// +/// `None` only when there is no host at all to hang it from, which is a device +/// that hosts nothing and has declared nothing. +fn unplaced_host(hosts: &[HostGroup], host_id: &str) -> Option { + let host_id = host_id.trim(); + hosts + .iter() + .position(|host| !host_id.is_empty() && host.row.host_id.trim() == host_id) + .or_else(|| hosts.iter().position(|host| host.row.local)) +} + +/// Push one agent row and the sessions under it, tree-marked. +/// +/// `offers_session` closes the group with the `+ New session` action. It is off +/// for an agent this machine does not declare — a remote host's agent, or a lane +/// the fold produced for an agent declared somewhere else — because the flow it +/// opens reads the declaration for the harness and the directory to start in. +/// +/// Paging is the fold's, not the rail's (#171): `agent_rows` reveals a page of +/// task sublanes at a time and marks the rest with an overflow row, so a second +/// cap here would clip the page the operator just asked to see. The overflow row +/// is re-emitted under the group and stays selectable, which is what makes +/// `Enter` on it page the lane open — and, once the lane is fully revealed, fold +/// it back. +fn push_group(rows: &mut Vec, group: &mut AgentGroup, offers_session: bool) { + rows.push(RailRow::Agent(group.row.clone())); + let shown = group.sessions.len(); + for (index, session) in group.sessions.iter_mut().enumerate() { + // The action row below closes the group when it is offered, so the last + // session is only the tree's last leaf when neither it nor the overflow + // row follows. + session.last = !offers_session && !group.overflow && index + 1 == shown; + rows.push(RailRow::Session(Box::new(session.clone()))); + } + if group.overflow { + rows.push(RailRow::Lane(AgentRow::More { + lane_index: group.row.lane_index.unwrap_or(0), + hidden: group.hidden, + })); + } + // Last, under the sessions it adds to: the group reads as a list of what + // this agent is running, and the action that starts one more belongs at the + // end of that list rather than above it. + if offers_session { + rows.push(RailRow::NewSession { + agent_id: group.row.agent_id.clone(), + }); + } +} diff --git a/src/tui/src/ui/app/rail/resolve.rs b/src/tui/src/ui/app/rail/resolve.rs new file mode 100644 index 000000000..87bb4598b --- /dev/null +++ b/src/tui/src/ui/app/rail/resolve.rs @@ -0,0 +1,205 @@ +//! Resolving a session to the agent that owns it. +//! +//! The rail's own level, and the only one it resolves for itself: hosts and +//! agents come from the shared projection (see [`super`]). Kept apart from the +//! assembly so the rule can be tested without building an +//! [`App`](super::super::types::App). +//! +//! A **dispatched** session already knows its agent: the hub files a task under +//! the roster id the dispatch named ([`lane_id`]), and that id is the lane's +//! `agent_id`. Nothing is re-derived here — re-deriving it by workspace would +//! disagree with the hub on exactly the case the hub was fixed for (a machine +//! advertising several agents at one address). +//! +//! An **operator-started** session knows only where it is running, so it is +//! matched back to the declaration whose `harness × workspace` it is a session +//! of. No match means the directory is undeclared, which is a real state the +//! rail shows rather than hides — and the prompt for inline agent creation. +//! +//! [`lane_id`]: https://docs.rs/medulla + +use medulla::runtime::AgentDeclaration; + +use crate::worker::pty::SessionRow; + +/// The declaration a local PTY session belongs to, if one claims it. +/// +/// Matched on `harness × workspace`, which is what an agent *is*: the CLI that +/// runs the work and the directory it runs in. Paths are compared with trailing +/// separators trimmed, because a declaration typed by hand and a cwd resolved by +/// the spawner disagree about the trailing slash far more often than they +/// disagree about the directory. +/// +/// The harness compared is the session's *id* +/// ([`harness_id`](SessionRow::harness_id)) — a custom preset's own id, else the +/// CLI's wire name — because that is the vocabulary a declaration is written in. +/// A preset is a different agent running the same CLI, so comparing the CLI +/// underneath it matched a `deepseek` declaration against `claude` and left +/// every preset-backed session in the orphan list. +/// +/// The first match wins. Two declarations of the same harness in the same +/// directory are the same agent declared twice, so which one claims the session +/// changes nothing an operator can see. +pub fn agent_for_session<'a>( + declarations: &'a [AgentDeclaration], + row: &SessionRow, +) -> Option<&'a AgentDeclaration> { + let cwd = normalize_path(&row.cwd); + let harness = row.harness_id(); + declarations.iter().find(|declaration| { + declaration.harness.trim().eq_ignore_ascii_case(harness) + && normalize_path(&declaration.workspace.path) == cwd + }) +} + +/// A path with its trailing separators removed, for comparison only. +/// +/// Never used to *build* a path: an empty result means "the root or nothing", +/// and both compare equal to each other, which is the honest answer for a blank +/// declaration. +fn normalize_path(path: &str) -> &str { + let trimmed = path.trim(); + let stripped = trimmed.trim_end_matches('/'); + if stripped.is_empty() { + trimmed + } else { + stripped + } +} + +#[cfg(test)] +mod tests { + use super::*; + use medulla::protocol::HarnessProvider; + use medulla::runtime::WorkspaceRef; + + use crate::worker::pty::{PtyState, SessionControl, SessionOrigin}; + + fn session(provider: HarnessProvider, cwd: &str) -> SessionRow { + SessionRow { + id: "w_1".into(), + label: "local".into(), + provider, + preset: None, + state: PtyState::Running, + cwd: cwd.into(), + branch: None, + launch_root: None, + launch_commit: None, + launch_checkout_identity: None, + session_id: None, + thread_name: None, + started_at: 1, + last_output_at: 1, + last_error: None, + busy: false, + control: SessionControl::User, + origin: SessionOrigin::User, + name: None, + attention: None, + } + } + + #[test] + fn a_session_matches_the_declaration_of_its_harness_and_directory() { + let declarations = vec![ + AgentDeclaration::new("api-claude", "host", "claude", "/work/api"), + AgentDeclaration::new("web-claude", "host", "claude", "/work/web"), + ]; + let matched = agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/work/web"), + ) + .expect("the web checkout is declared"); + assert_eq!(matched.agent_id, "web-claude"); + } + + #[test] + fn a_trailing_separator_is_not_a_different_directory() { + let mut declaration = AgentDeclaration::new("api-claude", "host", "claude", ""); + declaration.workspace = WorkspaceRef::checkout("/work/api/"); + let declarations = vec![declaration]; + assert!(agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/work/api") + ) + .is_some()); + } + + #[test] + fn a_different_harness_in_the_same_directory_is_a_different_agent() { + let declarations = vec![AgentDeclaration::new( + "api-codex", + "host", + "codex", + "/work/api", + )]; + assert!( + agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/work/api") + ) + .is_none(), + "claude in the codex agent's directory is not that agent" + ); + } + + #[test] + fn a_preset_backed_session_belongs_to_the_agent_declared_for_that_preset() { + // A custom preset is its own agent — its own model, endpoint and + // environment — so a declaration records the *preset's* id. Matching on + // the CLI underneath it compared `claude` against `deepseek`, and every + // session an operator started from a preset was listed as belonging to + // no agent at all. + let declarations = vec![AgentDeclaration::new( + "api-deepseek", + "host", + "deepseek", + "/work/api", + )]; + let mut row = session(HarnessProvider::Claude, "/work/api"); + row.preset = Some("deepseek".into()); + + let matched = agent_for_session(&declarations, &row).expect("its own agent claims it"); + assert_eq!(matched.agent_id, "api-deepseek"); + assert_eq!(row.harness_id(), "deepseek"); + } + + #[test] + fn a_preset_is_not_the_base_cli_declared_in_the_same_directory() { + // The other direction of the same rule: an agent declared as plain + // `claude` is not the one a `deepseek` preset session belongs to, even + // though both run the claude binary in that folder. + let declarations = vec![AgentDeclaration::new( + "api-claude", + "host", + "claude", + "/work/api", + )]; + let mut row = session(HarnessProvider::Claude, "/work/api"); + row.preset = Some("deepseek".into()); + assert!(agent_for_session(&declarations, &row).is_none()); + + // And a native session still resolves by the provider's wire name: a + // blank preset is no preset. + let mut native = session(HarnessProvider::Claude, "/work/api"); + native.preset = Some(" ".into()); + assert_eq!(native.harness_id(), "claude"); + assert!(agent_for_session(&declarations, &native).is_some()); + } + + #[test] + fn an_undeclared_directory_resolves_to_no_agent() { + let declarations = vec![AgentDeclaration::new( + "api-claude", + "host", + "claude", + "/work/api", + )]; + assert!(agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/elsewhere") + ) + .is_none()); + } +} diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs new file mode 100644 index 000000000..99845476a --- /dev/null +++ b/src/tui/src/ui/app/rail/tests.rs @@ -0,0 +1,410 @@ +//! What the Agents rail assembles: declared agents with no traffic, sessions +//! grouped under the agent that owns them, and host rows only once there is a +//! second machine to tell apart. + +use std::collections::HashMap; +use std::sync::Arc; + +use medulla::config::LoadedConfig; +use medulla::protocol::HarnessProvider; +use medulla::runtime::mock::MockRuntime; +use medulla::runtime::{AgentDeclaration, Runtime}; + +use super::{RailRow, NEW_AGENT_LABEL}; +use crate::ui::app::App; +use crate::worker::pty::PtyManager; + +/// An Agents app on the mock runtime, hosting nothing. +pub(in crate::ui::app) fn app() -> App { + let runtime: Arc = Arc::new(MockRuntime::demo()); + let mut loaded = LoadedConfig::defaults("medulla.tui.json".into()); + loaded.config.link = Some(medulla::config::LinkConfig::default()); + App::new(runtime, loaded) +} + +/// The same app with a live (but empty) local host attached, so the rail is +/// allowed to offer `+ New agent` and to list local sessions. +pub(in crate::ui::app) fn hosting_app() -> App { + let mut app = app(); + app.set_local_sessions(shell_harnesses(PtyManager::new())); + app +} + +/// A [`LocalSessions`](crate::ui::harness_pane::LocalSessions) whose "codex" +/// is `/bin/sh`, so opening one starts a real pty client and nothing else. +pub(in crate::ui::app) fn shell_harnesses( + sessions: PtyManager, +) -> crate::ui::harness_pane::LocalSessions { + let mut env = HashMap::new(); + if let Ok(path) = std::env::var("PATH") { + env.insert("PATH".to_string(), path); + } + env.insert("TERM".to_string(), "xterm-256color".to_string()); + env.insert("TINYPLACE_CODEX_BIN".to_string(), "/bin/sh".to_string()); + crate::ui::harness_pane::LocalSessions { + sessions, + runtimes: Arc::new(std::sync::Mutex::new(Vec::new())), + hub_address: "medulla-orchestrator".to_string(), + env, + workspace: "/".to_string(), + providers: vec![HarnessProvider::Codex], + custom_harnesses: Vec::new(), + router: None, + attribution: true, + hooks: medulla::harness_hooks::HooksConfig::default(), + log: None, + } +} + +/// The agent rows, in rail order. +fn agent_ids(app: &App) -> Vec { + app.rail_rows() + .into_iter() + .filter_map(|row| match row { + RailRow::Agent(agent) => Some(agent.agent_id), + _ => None, + }) + .collect() +} + +#[test] +fn a_declared_agent_with_no_sessions_still_has_a_row() { + // The point of sourcing the rail from declarations: an agent nothing has + // been dispatched to folds no lane, so under the old taxonomy it had no row + // at all — a targetable identity you could not see. + let mut app = app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "idle-agent", + "", + "claude", + "/work/idle", + )]; + + let row = app + .rail_rows() + .into_iter() + .find_map(|row| match row { + RailRow::Agent(agent) if agent.agent_id == "idle-agent" => Some(agent), + _ => None, + }) + .expect("the declared agent has a row"); + + assert!(row.lane_index.is_none(), "it has no traffic to fold"); + assert_eq!(row.harness(), Some("claude")); + assert_eq!(row.workspace(), Some("/work/idle")); +} + +#[test] +fn a_declaration_and_its_lane_are_one_row_not_two() { + // Declaring an agent the fold already produced a lane for must not list it + // twice: the id is the join, and the lane keeps its own live row. + let mut app = app(); + let Some(existing) = agent_ids(&app).into_iter().next() else { + return; + }; + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + existing.clone(), + "", + "claude", + "/work", + )]; + + let ids = agent_ids(&app); + assert_eq!( + ids.iter().filter(|id| **id == existing).count(), + 1, + "{ids:?} lists {existing} once" + ); +} + +#[test] +fn every_session_row_sits_under_the_agent_that_owns_it() { + // The rail's whole grouping rule, asserted structurally: walking the rows, + // a session's agent is always whichever agent row it last passed. + let app = app(); + let mut current: Option = None; + let mut checked = 0; + for row in app.rail_rows() { + match row { + RailRow::Agent(agent) => current = Some(agent.agent_id), + RailRow::Session(session) => { + if let Some(agent_id) = &session.agent_id { + assert_eq!( + Some(agent_id), + current.as_ref(), + "a session is filed under the agent above it" + ); + checked += 1; + } + } + _ => {} + } + } + assert!(checked > 0, "the demo fixture dispatches at least one task"); +} + +// Unix-only: starts a real child on a real pseudo-terminal via `/bin/sh`, +// which Windows has no equivalent of. The row model under test is +// portable; only this way of standing a session up is not. +#[cfg(unix)] +#[test] +fn a_dispatched_session_and_an_operator_session_are_the_same_row_type() { + // §A0: the `── your harnesses ──` divider and the second row type are gone. + // A task row and a PTY row are both `RailRow::Session`, so nothing on the + // rail separates them into two groups. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("shell", "", "codex", "/")]; + let harnesses = app.local_sessions().expect("hosting").clone(); + let choice = harnesses + .choices() + .into_iter() + .find(|choice| choice.provider == HarnessProvider::Codex) + .expect("codex is configured"); + let id = harnesses + .open_unmanaged_named(&choice, "/", false, Some("debug login".into())) + .expect("a /bin/sh session starts"); + + let rows = app.rail_rows(); + let session = rows + .iter() + .find_map(|row| match row { + RailRow::Session(session) if session.session_id() == Some(id.as_str()) => Some(session), + _ => None, + }) + .expect("the operator's session has a row"); + assert_eq!(session.agent_id.as_deref(), Some("shell")); + assert_eq!(session.name(), Some("debug login")); + assert!(session.origin().is_user()); + harnesses.sessions.shutdown(); +} + +// Unix-only: starts a real child on a real pseudo-terminal via `/bin/sh`, +// which Windows has no equivalent of. The row model under test is +// portable; only this way of standing a session up is not. +#[cfg(unix)] +#[test] +fn a_session_in_an_undeclared_directory_is_still_listed() { + // Nothing declares `/`, so the session belongs to no agent — and a harness + // that is running, costing tokens and invisible is exactly the failure the + // old separate group existed to prevent. + let app = hosting_app(); + let harnesses = app.local_sessions().expect("hosting").clone(); + let choice = harnesses + .choices() + .into_iter() + .find(|choice| choice.provider == HarnessProvider::Codex) + .expect("codex is configured"); + let id = harnesses + .open_unmanaged(&choice, "/", false) + .expect("a /bin/sh session starts"); + + let listed = app.rail_rows().into_iter().any(|row| match row { + RailRow::Session(session) => { + session.session_id() == Some(id.as_str()) && session.agent_id.is_none() + } + _ => false, + }); + assert!(listed, "an unclaimed session is listed, not hidden"); + harnesses.sessions.shutdown(); +} + +#[test] +fn host_rows_appear_only_once_a_second_host_exists() { + let mut app = app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("local-claude", "", "claude", "/work")]; + assert!( + !app.rail_rows() + .iter() + .any(|row| matches!(row, RailRow::Host(_))), + "one machine needs no host wrapper" + ); + + app.loaded + .config + .fleet + .agent_declarations + .push(AgentDeclaration::new( + "studio-claude", + "studio", + "claude", + "/work", + )); + let hosts: Vec<(String, bool)> = app + .rail_rows() + .into_iter() + .filter_map(|row| match row { + RailRow::Host(host) => Some((host.host_id, host.local)), + _ => None, + }) + .collect(); + assert!( + hosts.iter().any(|(host_id, _)| host_id == "studio"), + "the second machine gets a header: {hosts:?}" + ); + assert!( + hosts.len() >= 2, + "so does this one, once there is a second: {hosts:?}" + ); + let local = app.local_host_refs(); + assert_eq!( + hosts.first().map(|(host_id, _)| host_id.as_str()), + local.first().map(|host| host.id.as_str()), + "this device leads, in the order the shared tree lists: {hosts:?}" + ); +} + +#[test] +fn the_rail_and_the_hosts_tab_list_the_same_agents_under_the_same_hosts() { + // The unification, asserted directly: both tabs render `host_rows`, so the + // Agents rail cannot claim an agent the Hosts tab does not have, place it on + // another host, or order them differently. The rail may hold *more* — a lane + // for a backend-side agent this hub never advertised — and those are the + // rows the tree does not cover, so they are excluded rather than asserted + // away. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![ + AgentDeclaration::new("api-claude", "", "claude", "/work/api"), + AgentDeclaration::new("web-codex", "", "codex", "/work/web"), + AgentDeclaration::new("studio-claude", "studio", "claude", "/work"), + ]; + + let expected: Vec<(String, String)> = app + .host_tree() + .into_iter() + .flat_map(|host| { + host.agents + .into_iter() + .map(move |agent| (host.id.clone(), agent.agent_id)) + }) + .collect(); + let known: Vec = expected.iter().map(|(_, agent)| agent.clone()).collect(); + let railed: Vec<(String, String)> = app + .rail_rows() + .into_iter() + .filter_map(|row| match row { + RailRow::Agent(agent) if known.contains(&agent.agent_id) => { + Some((agent.host_id, agent.agent_id)) + } + _ => None, + }) + .collect(); + + assert!(!expected.is_empty(), "the fixture declares agents"); + assert_eq!(railed, expected, "one tree, two lenses"); +} + +#[test] +fn the_create_action_is_absent_on_a_device_that_hosts_nothing() { + // A machine with no host cannot declare an agent on itself, so the action is + // missing rather than present and refusing. + assert!( + !app().rail_rows().iter().any(RailRow::is_new_agent), + "no host, no create action" + ); + assert!( + hosting_app().rail_rows().iter().any(RailRow::is_new_agent), + "hosting, so {NEW_AGENT_LABEL} is offered" + ); +} + +#[test] +fn an_agent_is_labelled_by_its_name_and_falls_back_to_its_id() { + // The label, the harness and the workspace all come from the shared tree, so + // an agent reads the same on both tabs. A row the tree does not cover has + // only its id, and says nothing about the harness rather than guessing. + let mut app = app(); + let mut declaration = AgentDeclaration::new("api-codex", "", "codex", "/work/api"); + app.loaded.config.fleet.agent_declarations = vec![declaration.clone()]; + + let row = |app: &App| { + app.rail_rows() + .into_iter() + .find_map(|row| match row { + RailRow::Agent(agent) if agent.agent_id == "api-codex" => Some(agent), + _ => None, + }) + .expect("the declared agent has a row") + }; + + let declared = row(&app); + assert_eq!(declared.label(), "api-codex", "no name means the id"); + assert_eq!(declared.harness(), Some("codex")); + assert_eq!(declared.workspace(), Some("/work/api")); + + declaration.name = Some(" ".into()); + app.loaded.config.fleet.agent_declarations = vec![declaration.clone()]; + assert_eq!(row(&app).label(), "api-codex", "a blank name is not a name"); + + declaration.name = Some("API".into()); + app.loaded.config.fleet.agent_declarations = vec![declaration]; + assert_eq!(row(&app).label(), "API"); + + let bare = super::AgentRailRow { + agent_id: "api-codex".into(), + host_id: String::new(), + agent: None, + lane_index: None, + }; + assert_eq!(bare.label(), "api-codex"); + assert_eq!(bare.harness(), None); + assert_eq!(bare.workspace(), None); +} + +#[test] +fn a_row_answers_for_the_agent_and_the_lane_behind_it() { + let app = app(); + for row in app.rail_rows() { + match &row { + RailRow::Agent(agent) => { + assert_eq!(row.agent_id(), Some(agent.agent_id.as_str())); + assert_eq!(row.lane_index(), agent.lane_index); + assert!(row.task().is_none(), "an agent is not a session"); + } + RailRow::Session(session) => { + assert_eq!(row.agent_id(), session.agent_id.as_deref()); + assert_eq!(row.lane_index(), session.lane_index); + } + // Hosts and the create action are about no agent and no lane. + RailRow::Host(_) | RailRow::NewAgent => { + assert_eq!(row.agent_id(), None); + assert_eq!(row.lane_index(), None); + assert_eq!(row.session_id(), None); + } + // The per-agent action names its agent — that is what `^T` and + // Enter act on — but no lane and no session of its own. + RailRow::NewSession { agent_id } => { + assert_eq!(row.agent_id(), Some(agent_id.as_str())); + assert_eq!(row.new_session_agent(), Some(agent_id.as_str())); + assert_eq!(row.lane_index(), None); + assert_eq!(row.session_id(), None); + assert!(row.task().is_none()); + } + RailRow::Lane(lane) => assert_eq!(row.lane_index(), lane.lane_index()), + } + } +} + +#[test] +fn only_the_rows_that_name_something_take_the_cursor() { + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-claude", + "studio", + "claude", + "/work", + )]; + for row in app.rail_rows() { + match row { + RailRow::Host(_) => assert!(!row.selectable(), "a host header is a label"), + RailRow::Agent(_) + | RailRow::Session(_) + | RailRow::NewAgent + | RailRow::NewSession { .. } => { + assert!(row.selectable()) + } + RailRow::Lane(_) => {} + } + } +} diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs new file mode 100644 index 000000000..afa88b0ed --- /dev/null +++ b/src/tui/src/ui/app/rail/types.rs @@ -0,0 +1,238 @@ +//! The Agents rail's row taxonomy: `Host → Agent → Session`. +//! +//! One shape for the whole tree. A row is a host, an agent, one of that agent's +//! sessions, or the action that declares a new agent — and the lane rows the +//! event fold still owns for the surfaces that are *not* agents (the +//! orchestrator's own conversation, the `── functions ──` divider, the function +//! lanes beneath it). +//! +//! The taxonomy this replaced carried the split the redefinition removes: an +//! `AgentRow::Sub` rendered a *task* and a `RailRow::Harness` rendered a +//! *session*, in two groups separated by a `── your harnesses ──` divider. A +//! task **is** an agent session — the two differ only in +//! [`SessionOrigin`](crate::worker::pty::SessionOrigin) — so both collapse into +//! [`RailRow::Session`] under the agent that owns them, and the divider is gone. + +use medulla::ui::hosts::HostAgentRow; + +use crate::ui::agents::{AgentRow, TaskState}; +use crate::worker::pty::{SessionOrigin, SessionRow}; + +/// One host in the tree. +/// +/// Emitted **only when there is a second host to tell apart** (progressive +/// disclosure): with just the local machine — the common case — agents sit at the +/// top level and no host row wraps them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRailRow { + /// The host id agents are stamped with; the local bus address for this + /// machine. + pub host_id: String, + /// What the row says. + pub label: String, + /// Whether this is a machine the operator can act on from here — the local + /// device and any other host declared in this config. + pub local: bool, +} + +/// One agent in the tree — `harness × workspace` on a host. +/// +/// Sourced from the shared `Host → Agent` projection +/// ([`medulla::ui::hosts::host_rows`]), which is the same tree the Hosts tab +/// renders: an agent exists because it is declared (or because the roster +/// advertises it), never because traffic happened to fold a lane for it. A lane +/// the fold produced for an agent that projection does not know (a backend-side +/// roster agent, a peer session) still gets a row, so the restructure never +/// hides something that used to be visible. +#[derive(Debug, Clone)] +pub struct AgentRailRow { + /// The `agentId` a dispatch targets — the key sessions are grouped under. + pub agent_id: String, + /// The host this agent runs on. Empty when nothing places it. + pub host_id: String, + /// The projection's row for this agent, when the tree knows it. `None` for a + /// lane-only agent, which is all the fold could tell us about. + pub agent: Option, + /// The lane the event fold produced for it, when it has traffic. `None` for + /// a declared agent that has not run anything. + pub lane_index: Option, +} + +impl AgentRailRow { + /// What to call this agent: whatever the shared projection resolved (its + /// declared name, else its roster label), falling back to the id itself. + pub fn label(&self) -> String { + self.agent + .as_ref() + .map(|agent| agent.label.clone()) + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| self.agent_id.clone()) + } + + /// The harness type this agent runs, when the tree knows one. + pub fn harness(&self) -> Option<&str> { + self.agent.as_ref()?.harness.as_deref() + } + + /// The directory this agent's sessions work in, when the tree knows one. + pub fn workspace(&self) -> Option<&str> { + self.agent.as_ref()?.workspace.as_deref() + } +} + +/// One session of an agent — **one row type, whatever started it**. +/// +/// An orchestrator dispatch arrives as a [`TaskState`] folded from the event +/// stream; an operator-started session arrives as a live [`SessionRow`] from the +/// local PTY manager. They are the same thing seen through the two surfaces that +/// can see it, so one row carries either (or, for a dispatch this machine is +/// serving, both). +#[derive(Debug, Clone)] +pub struct SessionRailRow { + /// The agent this session belongs to. `None` when nothing declares the + /// directory it runs in — the session is still listed rather than hidden. + pub agent_id: Option, + /// The lane the owning agent folded to, for the transcript behind the row. + pub lane_index: Option, + /// The dispatched task, when the orchestrator started this session. + pub task: Option, + /// The live PTY session, when this device is the one running it. + pub local: Option, + /// Whether this is the last session listed under its agent, for the tree + /// glyph. + pub last: bool, +} + +impl SessionRailRow { + /// The local PTY session id this row names, when it names one. + pub fn session_id(&self) -> Option<&str> { + self.local.as_ref().map(|row| row.id.as_str()) + } + + /// Who started this session. + /// + /// A row backed by a task is an orchestrator dispatch by construction; a + /// local-only row reports what the PTY manager recorded at launch. + pub fn origin(&self) -> SessionOrigin { + match (&self.task, &self.local) { + (Some(_), _) => SessionOrigin::Orchestrator, + (None, Some(row)) => row.origin, + (None, None) => SessionOrigin::Orchestrator, + } + } + + /// The name the operator gave this session, when they gave one. + pub fn name(&self) -> Option<&str> { + self.local + .as_ref() + .and_then(|row| row.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + } +} + +/// One row of the Agents rail. +#[derive(Debug, Clone)] +pub enum RailRow { + /// A host header, emitted only once a remote host exists. + Host(HostRailRow), + /// A declared (or folded) agent. + Agent(AgentRailRow), + /// One session of the agent above it. + /// + /// Boxed because a session row carries both a whole [`TaskState`] and a + /// whole [`SessionRow`], which together are several times the size of every + /// other variant — and a rail is a `Vec` rebuilt each frame, so the + /// widest variant is what every row costs. + Session(Box), + /// The action row that declares a new agent on this machine. + /// + /// Sits directly above the tree it produces, because a machine with no + /// agents declared has nothing else on that half of the rail to suggest the + /// flow exists — which is the same as not having it. + NewAgent, + /// The action row that opens a new session under the agent above it. + /// + /// The last row of an agent's group, under its sessions, because that is + /// where "and one more" belongs: the list you are reading is what the agent + /// is running, and this adds to it. Emitted only for an agent this machine + /// declares — a session is started on the host that owns the agent, so + /// offering the action on a remote one would be a button that refuses. + /// + /// `open_new_session` has existed since the tree landed and was reachable + /// only by `Ctrl-T`, i.e. only by an operator who already knew it was there. + NewSession { + /// The agent whose harness and workspace the session inherits. + agent_id: String, + }, + /// A fold row that is not an agent: the orchestrator's own conversation, the + /// `── functions ──` divider, a function lane, or a `+N more` counter. + /// + /// The one variant the topology does not name, because the orchestrator is + /// not an agent and still needs somewhere to live. Everything in it is + /// either the conversation or a label. + Lane(AgentRow), +} + +impl RailRow { + /// Whether the cursor may land on this row. + pub fn selectable(&self) -> bool { + match self { + RailRow::Host(_) => false, + RailRow::Agent(_) => true, + RailRow::Session(_) => true, + RailRow::NewAgent => true, + RailRow::NewSession { .. } => true, + RailRow::Lane(row) => row.selectable(), + } + } + + /// The PTY session this row names, when it names one directly. + pub fn session_id(&self) -> Option<&str> { + match self { + RailRow::Session(row) => row.session_id(), + _ => None, + } + } + + /// The task this row renders, when it renders one. + pub fn task(&self) -> Option<&TaskState> { + match self { + RailRow::Session(row) => row.task.as_ref(), + _ => None, + } + } + + /// The lane index behind this row, for the transcript pane. + pub fn lane_index(&self) -> Option { + match self { + RailRow::Agent(row) => row.lane_index, + RailRow::Session(row) => row.lane_index, + RailRow::Lane(row) => row.lane_index(), + RailRow::Host(_) | RailRow::NewAgent | RailRow::NewSession { .. } => None, + } + } + + /// The agent this row is about, when it is about one. + pub fn agent_id(&self) -> Option<&str> { + match self { + RailRow::Agent(row) => Some(row.agent_id.as_str()), + RailRow::Session(row) => row.agent_id.as_deref(), + RailRow::NewSession { agent_id } => Some(agent_id.as_str()), + _ => None, + } + } + + /// Whether this row is the "declare an agent" action. + pub fn is_new_agent(&self) -> bool { + matches!(self, RailRow::NewAgent) + } + + /// The agent a "start a session" action row would open one under. + pub fn new_session_agent(&self) -> Option<&str> { + match self { + RailRow::NewSession { agent_id } => Some(agent_id.as_str()), + _ => None, + } + } +} diff --git a/src/tui/src/ui/app/render/agents/composer.rs b/src/tui/src/ui/app/render/agents/composer.rs index 0b2e9fadc..8d1731a8c 100644 --- a/src/tui/src/ui/app/render/agents/composer.rs +++ b/src/tui/src/ui/app/render/agents/composer.rs @@ -72,7 +72,7 @@ impl App { // that cannot send it leaves the rail unreachable, so the key that // always works is named right where the cursor is. let caption = if self.agents_rail_focused() { - "↑↓ walk agents · K kill harness · Enter or type to write".to_string() + "↑↓ walk agents · K kill session · Enter or type to write".to_string() } else { format!("› {target} · Esc to pick an agent") }; diff --git a/src/tui/src/ui/app/render/agents/harness.rs b/src/tui/src/ui/app/render/agents/harness.rs index b00a25e8c..b1006f415 100644 --- a/src/tui/src/ui/app/render/agents/harness.rs +++ b/src/tui/src/ui/app/render/agents/harness.rs @@ -36,8 +36,8 @@ impl App { /// `None` when this device does not host, when the work settled (the /// runtime drops the record then, so the pane stops claiming a screen for /// work that is over), or when the answer would be a guess. - pub(super) fn local_harness_session(&self, selection: &Selection) -> Option { - let harnesses = self.harnesses.as_ref()?; + pub(super) fn local_session(&self, selection: &Selection) -> Option { + let harnesses = self.local_sessions.as_ref()?; // An operator-started harness row *is* a session — it names one // directly rather than through a task, which is the whole reason it // needs its own rail group: nothing ever dispatched into it, so the @@ -68,7 +68,7 @@ impl App { /// pane is attached the harness's cursor is drawn too — an operator typing /// into a terminal with no cursor cannot tell where their text is going. pub(super) fn draw_local_harness(&mut self, f: &mut Frame, area: Rect, session_id: &str) { - let Some(harnesses) = self.harnesses.clone() else { + let Some(harnesses) = self.local_sessions.clone() else { return; }; let attached = self.harness_focus.is_attached_to(session_id); @@ -89,7 +89,7 @@ impl App { // Recorded before the paint so a wheel event landing between frames // still has somewhere to go. - self.hit_harness = Some((inner, session_id.to_string())); + self.hit_session = Some((inner, session_id.to_string())); harnesses.fit(session_id, inner.width, inner.height); let Some(snapshot) = harnesses.screen(session_id) else { return; @@ -120,7 +120,7 @@ impl App { /// the reason people avoid them. fn harness_title(&self, session_id: &str, attached: bool) -> String { let row = self - .harnesses + .local_sessions .as_ref() .and_then(|harnesses| harnesses.sessions.row(session_id)); // What it is waiting for, before what it is: an operator who opened this @@ -144,7 +144,7 @@ impl App { ), // A session that vanished between resolving and drawing. Rare, and // naming it beats a title that claims a provider we no longer know. - None => "harness".to_string(), + None => "session".to_string(), }; if attached { format!("{what} · typing here · {FOCUS_CHORD_LABEL} to release") diff --git a/src/tui/src/ui/app/render/agents/mod.rs b/src/tui/src/ui/app/render/agents/mod.rs index f85a088c8..d826f458d 100644 --- a/src/tui/src/ui/app/render/agents/mod.rs +++ b/src/tui/src/ui/app/render/agents/mod.rs @@ -10,10 +10,13 @@ //! never two jobs. //! //! Split by responsibility: [`types`] resolves what the cursor is on and where -//! the panes landed, [`rail`] draws the threads strip and the lane/fleet list, -//! [`transcript`] the pane beside it, [`work`] the panel showing what the -//! selected agent is working on, and [`composer`] the input under that. This -//! module owns only the layout that decides how much room each one gets. +//! the panes landed, [`session`] resolves which session that row names and what +//! it arms, [`rail`] draws the threads strip and the lane/fleet list, +//! [`transcript`] the pane beside it, [`summary`] what that pane shows for a row +//! with no transcript of its own, [`started`] the sessions each conversation +//! turn spawned, [`work`] the panel showing what the selected agent is working +//! on, and [`composer`] the input under that. This module owns only the layout +//! that decides how much room each one gets. use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::Frame; @@ -26,10 +29,15 @@ use super::super::types::App; mod composer; mod harness; mod rail; +mod session; +mod started; +mod summary; mod transcript; mod types; mod work; +#[cfg(test)] +mod started_tests; #[cfg(test)] mod transcript_tests; #[cfg(test)] @@ -79,24 +87,21 @@ impl App { let rows = self.rail_rows(); let active = self.agent_index.min(rows.len().saturating_sub(1)); self.agent_index = active; - let lane_index = match rows.get(active) { - Some(RailRow::Agent(row)) => row.lane_index().unwrap_or(0), - _ => 0, - }; - let task = match rows.get(active) { - Some(RailRow::Agent(AgentRow::Sub { task, .. })) => Some(task.clone()), - _ => None, - }; - // Only a *lane* row can be the orchestrator's. The action row and the - // operator's own harnesses fall back to lane 0 for the transcript - // behind them, and reading the role off that fallback claimed the - // orchestrator's composer for rows that are not it — a text box under a - // row with nothing to say to. Mirrors + // No fallback: a row with no lane keeps `None`, and the pane renders + // what that row *is* rather than whoever happens to hold lane 0 — which + // is the orchestrator, so the old `unwrap_or(0)` put its thinking under + // `+ New agent`, host headers and idle agents. + let lane_index = rows.get(active).and_then(|row| row.lane_index()); + let task = rows.get(active).and_then(|row| row.task()).cloned(); + // Only a *lane* row can be the orchestrator's. Agents, sessions, hosts + // and the action rows are not lanes, and reading the role off the old + // lane-0 fallback claimed the orchestrator's composer for rows that are + // not it — a text box under a row with nothing to say to. Mirrors // [`App::on_orchestrator_lane`](crate::ui::app), which the keyboard // reads, so focus and layout cannot disagree. let on_orchestrator = match rows.get(active) { - Some(RailRow::Agent(_)) => lanes - .get(lane_index) + Some(RailRow::Lane(AgentRow::Lane { .. })) => lane_index + .and_then(|index| lanes.get(index)) .map(|l| l.role == AgentRole::Orchestrator) .unwrap_or(true), None => true, @@ -109,20 +114,9 @@ impl App { lane_index, task, on_orchestrator, - harness: None, + session: None, }; - selection.harness = self.local_harness_session(&selection); - // Focus follows the pane, not the other way round. If the cursor moved - // off the attached session — or that session ended — the keyboard comes - // back to the chrome, because keys landing in a harness the operator is - // no longer looking at is the worst failure this feature can have. - if let Some(attached) = self.harness_focus.attached_to() { - if selection.harness.as_deref() != Some(attached) { - self.release_harness(); - } - } - self.harness_pane_session = selection.harness.clone(); - self.selected_harness_session = selection.harness.clone(); + self.resolve_selected_session(&mut selection); selection } @@ -177,7 +171,7 @@ impl App { // work panel goes for the same reason: the harness's own screen already // shows its todos and edits, and the columns are better spent on the // terminal than on our second-hand copy of it. - let embedded = selection.harness.is_some(); + let embedded = selection.session.is_some(); // The composer belongs to the orchestrator lane and nowhere else. That // lane *is* the conversation — typing into it is how work starts. Every // other row is something already running somewhere: an agent, a task, a diff --git a/src/tui/src/ui/app/render/agents/rail/attention_tests.rs b/src/tui/src/ui/app/render/agents/rail/attention_tests.rs index 2cc7d9930..f08dbf762 100644 --- a/src/tui/src/ui/app/render/agents/rail/attention_tests.rs +++ b/src/tui/src/ui/app/render/agents/rail/attention_tests.rs @@ -1,4 +1,4 @@ -//! Tests for attention cues in harness rows and lane state classification. +//! Tests for attention cues in session rows and lane state classification. use medulla::ui::agents::{AgentLane, AgentRole, TaskState, TaskStatus}; use ratatui::style::{Color, Modifier}; @@ -9,6 +9,7 @@ use super::rail_title; use super::state::{classify_lane, lane_waiting_session, task_waiting_session}; use super::status::HarnessVisualState; use super::tests::{app, harness_row, NOW}; +use crate::ui::app::rail::RailRow; fn lane() -> AgentLane { AgentLane { @@ -57,7 +58,7 @@ fn waiting_row(cwd: &str) -> SessionRow { #[test] fn a_harness_waiting_on_you_blinks_and_says_what_it_wants() { let app = app(); - let lines = app.own_harness_lines(&waiting_row("/workspace/medulla"), false, 48, NOW); + let lines = app.own_session_lines(&waiting_row("/workspace/medulla"), false, 48, NOW); assert!(lines[0].to_string().starts_with("⚠ codex"), "{}", lines[0]); let style = lines[0].spans[0].style; @@ -69,7 +70,7 @@ fn a_harness_waiting_on_you_blinks_and_says_what_it_wants() { #[test] fn a_selected_harness_waiting_on_you_stays_blinking_yellow() { let app = app(); - let lines = app.own_harness_lines(&waiting_row("/workspace/medulla"), true, 48, NOW); + let lines = app.own_session_lines(&waiting_row("/workspace/medulla"), true, 48, NOW); let style = lines[0].spans[0].style; assert_eq!(style.fg, Some(Color::Yellow)); @@ -85,7 +86,7 @@ fn a_long_attention_reason_wraps_without_losing_words() { row.attention = Some(HarnessAttention::new(AttentionKind::Dialog, reason, NOW)); let rendered = app - .own_harness_lines(&row, false, 36, NOW) + .own_session_lines(&row, false, 36, NOW) .iter() .skip(1) .map(|line| line.to_string().trim().to_string()) @@ -103,7 +104,7 @@ fn the_pane_you_are_typing_in_does_not_blink_at_you() { let row = waiting_row("/workspace/medulla"); app.harness_focus = crate::ui::harness_pane::HarnessFocus::Attached(row.id.clone()); - let lines = app.own_harness_lines(&row, false, 48, NOW); + let lines = app.own_session_lines(&row, false, 48, NOW); assert_eq!(lines.len(), 1, "no second line: {lines:?}"); assert!(!lines[0].spans[0] @@ -118,7 +119,7 @@ fn an_exited_harness_stops_asking_for_anything() { let mut row = waiting_row("/workspace/medulla"); row.state = PtyState::Exited { code: Some(0) }; - let lines = app.own_harness_lines(&row, false, 48, NOW); + let lines = app.own_session_lines(&row, false, 48, NOW); assert_eq!(lines.len(), 1); assert!(lines[0].to_string().starts_with("✓ codex"), "{}", lines[0]); @@ -174,12 +175,30 @@ fn task_attention_marks_only_the_exact_waiting_session() { } #[test] -fn rail_title_reports_the_attention_snapshot_count() { +fn rail_title_counts_the_agents_on_the_tree_not_the_lanes() { + // Two agents on the rail and one of them running a task. Counting lanes + // instead would report the agents that happen to have traffic — one — and + // read as "Agents · 1" on a machine that has two. let mut item = lane(); item.tasks = vec![task(TaskStatus::Running, 1)]; + let rows = vec![ + RailRow::Agent(agent_row("busy")), + RailRow::Agent(agent_row("idle")), + RailRow::NewAgent, + ]; assert_eq!( - rail_title(&[item], 2), - "Agents · 1 · 1 running · ⚠ 2 waiting on you" + rail_title(&rows, &[item], 2), + "Agents · 2 · 1 running · ⚠ 2 waiting on you" ); } + +/// A bare agent row, as the tree produces one for a declared agent. +fn agent_row(agent_id: &str) -> crate::ui::app::rail::AgentRailRow { + crate::ui::app::rail::AgentRailRow { + agent_id: agent_id.to_string(), + host_id: String::new(), + agent: None, + lane_index: None, + } +} diff --git a/src/tui/src/ui/app/render/agents/rail/harness_line/layout.rs b/src/tui/src/ui/app/render/agents/rail/harness_line/layout.rs index 4c6bc144a..80a83af20 100644 --- a/src/tui/src/ui/app/render/agents/rail/harness_line/layout.rs +++ b/src/tui/src/ui/app/render/agents/rail/harness_line/layout.rs @@ -26,7 +26,7 @@ use ratatui::style::Style; use ratatui::text::{Line as TLine, Span}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use crate::worker::pty::{HarnessControl, PtyState, SessionRow}; +use crate::worker::pty::{PtyState, SessionControl, SessionRow}; use super::super::wrap::{short_home, wrap_path}; @@ -240,17 +240,17 @@ fn harness_text(row: &SessionRow, style: HarnessNameStyle) -> String { /// Who holds the session, in the operator's chosen spelling. /// -/// "unmanaged" rather than [`HarnessControl::as_str`]'s "you": this is the whole +/// "unmanaged" rather than [`SessionControl::as_str`]'s "you": this is the whole /// reason an operator-started row exists, and someone who hands one to the /// orchestrator needs to see that it took effect. -fn control_text(control: HarnessControl, style: ControlStyle) -> String { +fn control_text(control: SessionControl, style: ControlStyle) -> String { match (style, control) { - (ControlStyle::Text, HarnessControl::User) => "unmanaged", - (ControlStyle::Text, HarnessControl::Orchestrator) => "orchestrator", + (ControlStyle::Text, SessionControl::User) => "unmanaged", + (ControlStyle::Text, SessionControl::Orchestrator) => "orchestrator", // `⊘` reads as "dispatch does not enter here", which is exactly what an // operator-held session means; `⊙` is the orchestrator holding it. - (ControlStyle::Icon, HarnessControl::User) => "⊘", - (ControlStyle::Icon, HarnessControl::Orchestrator) => "⊙", + (ControlStyle::Icon, SessionControl::User) => "⊘", + (ControlStyle::Icon, SessionControl::Orchestrator) => "⊙", } .to_string() } diff --git a/src/tui/src/ui/app/render/agents/rail/mod.rs b/src/tui/src/ui/app/render/agents/rail/mod.rs index b2a8e9f21..b2245f94c 100644 --- a/src/tui/src/ui/app/render/agents/rail/mod.rs +++ b/src/tui/src/ui/app/render/agents/rail/mod.rs @@ -17,7 +17,7 @@ use ratatui::Frame; use crate::ui::agents::{AgentLane, TaskStatus}; use crate::worker::pty::ATTENTION_GLYPH; -use super::super::super::rail::{RailRow, NEW_SESSION_LABEL}; +use super::super::super::rail::{RailRow, NEW_AGENT_LABEL, NEW_SESSION_LABEL}; use super::super::super::types::App; use super::super::color; use super::types::{AgentsPanes, Selection}; @@ -52,8 +52,15 @@ pub(in crate::ui::app) const RAIL_MAX_CONTENT: usize = 36; /// two lines still reads as one row rather than as two entries. const CONT_INDENT: usize = 5; -/// Build the rail title from the lane inventory and one attention snapshot. -fn rail_title(lanes: &[AgentLane], waiting: usize) -> String { +/// Build the rail title from the tree, the lane inventory, and one attention +/// snapshot. +/// +/// The count is of **agents** — the rows of the tree — not of lanes. A lane is +/// folded from traffic, so counting lanes said how many things had been +/// dispatched to, which is not what "Agents · 3" claims and drops to zero on a +/// machine with three declared agents and a quiet morning. Running tasks still +/// come from the lanes: that *is* a fact about traffic. +fn rail_title(rows: &[RailRow], lanes: &[AgentLane], waiting: usize) -> String { let running_tasks: usize = lanes .iter() .map(|lane| { @@ -63,7 +70,10 @@ fn rail_title(lanes: &[AgentLane], waiting: usize) -> String { .count() }) .sum(); - let agents = lanes.iter().filter(|lane| !lane.role.is_function()).count(); + let agents = rows + .iter() + .filter(|row| matches!(row, RailRow::Agent(_))) + .count(); let mut title = if running_tasks > 0 { format!("Agents · {agents} · {running_tasks} running") } else { @@ -89,19 +99,21 @@ impl App { let waiting_sessions = std::collections::HashSet::new(); let now = medulla::clock::now_millis(); match row { - RailRow::Harness(_) => [false, true] - .into_iter() - .flat_map(|active| { - self.rail_row_lines( - row, - lanes, - active, - RAIL_MAX_CONTENT, - &waiting_sessions, - now, - ) - }) - .collect(), + RailRow::Session(session) if session.local.is_some() && session.task.is_none() => { + [false, true] + .into_iter() + .flat_map(|active| { + self.rail_row_lines( + row, + lanes, + active, + RAIL_MAX_CONTENT, + &waiting_sessions, + now, + ) + }) + .collect() + } _ => self.rail_row_lines(row, lanes, false, RAIL_MAX_CONTENT, &waiting_sessions, now), } } @@ -125,7 +137,7 @@ impl App { // disagree with each other — and the render thread takes the sessions // lock once rather than once per lane per task. let waiting_sessions = self - .harnesses + .local_sessions .as_ref() .map(|h| h.sessions.waiting_sessions()) .unwrap_or_default(); @@ -133,7 +145,7 @@ impl App { // rather than on rows of their own — the same number the tab badge // carries, so the two can never disagree. let waiting = App::count_waiting(&waiting_sessions, &self.harness_focus); - let title = rail_title(&selection.lanes, waiting); + let title = rail_title(&selection.rows, &selection.lanes, waiting); // The border says which half the keyboard is driving. Without it, Esc // moving focus to the rail is invisible until the next arrow press. let block = crate::ui::widgets::panel(&self.theme, title, self.agents_rail_focused()); @@ -274,7 +286,34 @@ impl App { now: i64, ) -> Vec> { match row { - RailRow::Harness(session) => self.own_harness_lines(session, active, width, now), + // A session this device runs and no task describes is the operator's + // own: it gets the multi-line status-line treatment, because its + // working directory is the only thing telling two of them apart. + // + // A name goes above that rather than into it. The status line is + // configurable and describes the *harness*; the name is what the + // person who opened the session called it, and it is the first thing + // they look for. + RailRow::Session(session) if session.task.is_none() => { + let Some(local) = &session.local else { + return Vec::new(); + }; + let mut lines = Vec::new(); + if let Some(name) = session.name() { + let style = if active { + self.theme.selection() + } else { + Style::default().fg(color("cyan")) + }; + lines.extend(wrap_line( + &TLine::from(Span::styled(format!(" {name}"), style)), + width, + CONT_INDENT, + )); + } + lines.extend(self.own_session_lines(local, active, width, now)); + lines + } other => wrap_line( &self.rail_row_line(other, lanes, active, waiting_sessions, now), width, @@ -293,28 +332,86 @@ impl App { now: i64, ) -> TLine<'static> { match row { - RailRow::Agent(row) => self.agent_row_line(row, lanes, active, waiting_sessions), - RailRow::NewHarness => self.new_harness_line(active), - RailRow::HarnessSeparator => TLine::from(Span::styled( - "── your sessions ──", - Style::default().add_modifier(Modifier::DIM), + RailRow::Lane(row) => self.agent_row_line(row, lanes, active, waiting_sessions), + RailRow::Host(host) => TLine::from(Span::styled( + format!("▸ {}", host.label), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), )), - // Only reached through `rail_row_lines`, which draws a harness over - // several lines; kept total so measurement can call either. - RailRow::Harness(row) => self - .own_harness_lines(row, active, RAIL_MAX_CONTENT, now) - .into_iter() - .next() - .unwrap_or_default(), + RailRow::Agent(agent) => { + self.declared_agent_line(agent, lanes, active, waiting_sessions) + } + RailRow::NewAgent => self.new_agent_line(active), + RailRow::NewSession { .. } => self.new_session_line(active), + RailRow::Session(session) => match (&session.task, &session.local) { + (Some(task), _) => self.agent_row_line( + &crate::ui::agents::AgentRow::Sub { + lane_index: session.lane_index.unwrap_or(0), + task: task.clone(), + last: session.last, + }, + lanes, + active, + waiting_sessions, + ), + // Only reached through `rail_row_lines`, which draws a local + // session over several lines; kept total so measurement can call + // either. + (None, Some(local)) => self + .own_session_lines(local, active, RAIL_MAX_CONTENT, now) + .into_iter() + .next() + .unwrap_or_default(), + (None, None) => TLine::from(""), + }, + } + } + + /// Format an agent row. + /// + /// An agent with a lane keeps the lane's own line — its turn count, context + /// window, and live state are what an operator reads it for. A *declared* + /// agent with no traffic has none of that, so it says what it is instead: + /// its harness and the folder it works in, dimmed, because "declared and + /// idle" should not compete with the rows that are doing something. + fn declared_agent_line( + &self, + agent: &super::super::super::rail::AgentRailRow, + lanes: &[AgentLane], + active: bool, + waiting_sessions: &std::collections::HashSet, + ) -> TLine<'static> { + if let Some(lane_index) = agent.lane_index { + return self.agent_row_line( + &crate::ui::agents::AgentRow::Lane { lane_index }, + lanes, + active, + waiting_sessions, + ); } + let style = if active { + self.theme.selection() + } else { + Style::default().add_modifier(Modifier::DIM) + }; + let detail = match (agent.harness(), agent.workspace()) { + (Some(harness), Some(workspace)) => format!( + " · {harness} · {}", + crate::ui::util::clip_left(workspace, 24) + ), + (Some(harness), None) => format!(" · {harness}"), + _ => String::new(), + }; + TLine::from(Span::styled(format!("○ {}{detail}", agent.label()), style)) } - /// Format the `+ New session` action row. + /// Format the `+ New agent` action row. /// /// Drawn as a button rather than as another list entry — bold and coloured, /// with its chord beside it — because it is the one row on the rail that /// *does* something rather than selecting something. - fn new_harness_line(&self, active: bool) -> TLine<'static> { + fn new_agent_line(&self, active: bool) -> TLine<'static> { let style = if active { self.theme.selection() } else { @@ -323,7 +420,26 @@ impl App { .add_modifier(Modifier::BOLD) }; TLine::from(vec![ - Span::styled(format!(" {NEW_SESSION_LABEL} "), style), + Span::styled(format!(" {NEW_AGENT_LABEL} "), style), + Span::styled(" ⏎", Style::default().add_modifier(Modifier::DIM)), + ]) + } + + /// Format the `+ new session` action row that closes an agent's group. + /// + /// Drawn as the group's last leaf — the same `└` the last session would have + /// carried — so it reads as part of that agent rather than as a second + /// machine-level button beside `+ New agent`. It carries the `^T` hint the + /// machine-level button used to, because `Ctrl-T` on a row belonging to an + /// agent opens exactly this flow. + fn new_session_line(&self, active: bool) -> TLine<'static> { + let style = if active { + self.theme.selection() + } else { + Style::default().fg(color("cyan")) + }; + TLine::from(vec![ + Span::styled(format!(" └ {NEW_SESSION_LABEL}"), style), Span::styled(" ⏎ / ^T", Style::default().add_modifier(Modifier::DIM)), ]) } diff --git a/src/tui/src/ui/app/render/agents/rail/rows.rs b/src/tui/src/ui/app/render/agents/rail/rows.rs index 735e2fc59..f6d025183 100644 --- a/src/tui/src/ui/app/render/agents/rail/rows.rs +++ b/src/tui/src/ui/app/render/agents/rail/rows.rs @@ -8,7 +8,7 @@ use unicode_width::UnicodeWidthChar; use crate::ui::agents::{AgentLane, AgentRole, AgentRow, TaskStatus}; use crate::ui::util::fmt_tokens; -use crate::worker::pty::{HarnessAttention, HarnessControl, SessionRow, ATTENTION_GLYPH}; +use crate::worker::pty::{HarnessAttention, SessionControl, SessionRow, ATTENTION_GLYPH}; use super::super::super::super::types::App; use super::super::super::color; @@ -28,7 +28,7 @@ impl App { /// PTY attention overrides the ordinary state glyph and adds a textual cue, /// while the operator's field placement and visibility choices remain in /// force for the status line itself. - pub(in crate::ui::app::render) fn own_harness_lines( + pub(in crate::ui::app::render) fn own_session_lines( &self, row: &SessionRow, active: bool, @@ -51,7 +51,7 @@ impl App { } } else if active { self.theme.selection() - } else if row.control == HarnessControl::User { + } else if row.control == SessionControl::User { Style::default().fg(color("cyan")) } else { Style::default() @@ -223,7 +223,7 @@ impl App { if !self.loaded.config.appearance.show_session_titles { return None; } - let harnesses = self.harnesses.as_ref()?; + let harnesses = self.local_sessions.as_ref()?; running_session_title(lane, |task_id| { let id = harnesses.session_for_task(task_id)?; harnesses diff --git a/src/tui/src/ui/app/render/agents/rail/state.rs b/src/tui/src/ui/app/render/agents/rail/state.rs index afb4ab2a4..4d9202228 100644 --- a/src/tui/src/ui/app/render/agents/rail/state.rs +++ b/src/tui/src/ui/app/render/agents/rail/state.rs @@ -126,7 +126,7 @@ impl App { item: &AgentLane, waiting_sessions: &HashSet, ) -> bool { - let Some(harnesses) = self.harnesses.as_ref() else { + let Some(harnesses) = self.local_sessions.as_ref() else { return false; }; lane_waiting_session( @@ -140,7 +140,7 @@ impl App { /// Whether the exact task row is backed by a waiting local harness. pub(super) fn task_attention(&self, task_id: &str, waiting_sessions: &HashSet) -> bool { - let Some(harnesses) = self.harnesses.as_ref() else { + let Some(harnesses) = self.local_sessions.as_ref() else { return false; }; task_waiting_session( diff --git a/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs b/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs index cc11fa6c7..6f359d3da 100644 --- a/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs +++ b/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs @@ -21,7 +21,7 @@ fn a_field_moved_to_line_two_leaves_the_first_line_and_indents() { path: FieldPlacement::Line2, ..StatusLineConfig::default() }); - let lines = app.own_harness_lines(&harness_row("/workspace/medulla"), false, 48, NOW); + let lines = app.own_session_lines(&harness_row("/workspace/medulla"), false, 48, NOW); assert_eq!(lines.len(), 2); assert_eq!(lines[0].to_string(), "● codex · unmanaged · main"); @@ -34,7 +34,7 @@ fn a_renamed_thread_is_shown_on_its_own_default_line() { let mut row = harness_row("/workspace/medulla"); row.thread_name = Some("Ship the sidebar".into()); - let lines = app.own_harness_lines(&row, false, 48, NOW); + let lines = app.own_session_lines(&row, false, 48, NOW); assert_eq!(lines.len(), 2); assert_eq!( @@ -50,7 +50,7 @@ fn three_lines_are_available_and_an_unused_one_is_closed_up() { branch: FieldPlacement::Line3, ..StatusLineConfig::default() }); - let lines = app.own_harness_lines(&harness_row("/workspace/medulla"), false, 48, NOW); + let lines = app.own_session_lines(&harness_row("/workspace/medulla"), false, 48, NOW); assert_eq!(lines.len(), 2); assert_eq!( @@ -73,7 +73,7 @@ fn every_line_is_still_bounded_by_the_rail_width() { row.branch = Some("feat/a-very-long-branch-name-indeed".into()); for width in [0, 1, 4, 8, 12, 36] { - for line in app.own_harness_lines(&row, false, width, NOW) { + for line in app.own_session_lines(&row, false, width, NOW) { assert!(line.width() <= width, "width {width}: {line:?}"); } } @@ -94,7 +94,7 @@ fn wide_branch_and_path_glyphs_stay_within_their_cell_budget() { path_style, ..StatusLineConfig::default() }); - let lines = app.own_harness_lines(&row, false, 10, NOW); + let lines = app.own_session_lines(&row, false, 10, NOW); assert_eq!(lines.len(), 2); assert!( @@ -112,7 +112,7 @@ fn the_harness_name_and_control_state_have_compact_spellings() { harness_style: HarnessNameStyle::Long, ..StatusLineConfig::default() }); - assert!(long.own_harness_lines(&row, false, 48, NOW)[0] + assert!(long.own_session_lines(&row, false, 48, NOW)[0] .to_string() .starts_with("● Codex · unmanaged")); @@ -122,7 +122,7 @@ fn the_harness_name_and_control_state_have_compact_spellings() { ..StatusLineConfig::default() }); assert_eq!( - icons.own_harness_lines(&row, false, 48, NOW)[0].to_string(), + icons.own_session_lines(&row, false, 48, NOW)[0].to_string(), "● ◆ · ⊘ · main · /workspace/medulla" ); } @@ -139,7 +139,7 @@ fn the_path_style_chooses_how_much_of_the_directory_survives() { path_style: style, ..StatusLineConfig::default() }); - app.own_harness_lines(&row, false, 44, NOW)[0].to_string() + app.own_session_lines(&row, false, 44, NOW)[0].to_string() }; assert_eq!(with_style(PathStyle::Last), "medulla-public"); @@ -163,11 +163,11 @@ fn a_field_can_be_held_back_until_its_row_is_selected() { let row = harness_row("/workspace/medulla"); assert_eq!( - app.own_harness_lines(&row, false, 48, NOW)[0].to_string(), + app.own_session_lines(&row, false, 48, NOW)[0].to_string(), "● codex · unmanaged · main" ); assert_eq!( - app.own_harness_lines(&row, true, 48, NOW)[0].to_string(), + app.own_session_lines(&row, true, 48, NOW)[0].to_string(), "● codex · unmanaged · main · /workspace/medulla" ); } @@ -182,9 +182,14 @@ fn rail_measurement_includes_fields_visible_only_on_the_selected_row() { path_when: FieldVisibility::Active, ..StatusLineConfig::default() }); - let row = crate::ui::app::rail::RailRow::Harness(harness_row( - "/workspace/tinyhumans/products/medulla-public", - )); + let row = + crate::ui::app::rail::RailRow::Session(Box::new(crate::ui::app::rail::SessionRailRow { + agent_id: None, + lane_index: None, + task: None, + local: Some(harness_row("/workspace/tinyhumans/products/medulla-public")), + last: true, + })); let measured = app.rail_row_measurement_lines(&row, &[]); assert!(measured.iter().any(|line| line.width() == 0)); @@ -205,7 +210,7 @@ fn an_on_alert_field_appears_only_for_a_harness_that_needs_attention() { let healthy = harness_row("/workspace/medulla"); assert_eq!( - app.own_harness_lines(&healthy, false, 48, NOW)[0].to_string(), + app.own_session_lines(&healthy, false, 48, NOW)[0].to_string(), "● codex · unmanaged · main" ); @@ -213,7 +218,7 @@ fn an_on_alert_field_appears_only_for_a_harness_that_needs_attention() { let mut alerting = harness_row("/workspace/medulla"); alerting.state = state; assert!( - app.own_harness_lines(&alerting, false, 48, NOW)[0] + app.own_session_lines(&alerting, false, 48, NOW)[0] .to_string() .ends_with("/workspace/medulla"), "{state:?} should count as an alert" @@ -222,7 +227,7 @@ fn an_on_alert_field_appears_only_for_a_harness_that_needs_attention() { let mut errored = harness_row("/workspace/medulla"); errored.last_error = Some("spawn failed".into()); - assert!(app.own_harness_lines(&errored, false, 48, NOW)[0] + assert!(app.own_session_lines(&errored, false, 48, NOW)[0] .to_string() .ends_with("/workspace/medulla")); } @@ -237,7 +242,7 @@ fn hiding_every_field_still_leaves_one_selectable_line() { path: FieldPlacement::Hidden, ..StatusLineConfig::default() }); - let lines = app.own_harness_lines(&harness_row("/workspace/medulla"), false, 48, NOW); + let lines = app.own_session_lines(&harness_row("/workspace/medulla"), false, 48, NOW); assert_eq!(lines.len(), 1, "the row must still occupy a clickable line"); assert_eq!(lines[0].to_string(), ""); diff --git a/src/tui/src/ui/app/render/agents/rail/tests.rs b/src/tui/src/ui/app/render/agents/rail/tests.rs index 63648348d..998c44817 100644 --- a/src/tui/src/ui/app/render/agents/rail/tests.rs +++ b/src/tui/src/ui/app/render/agents/rail/tests.rs @@ -12,7 +12,7 @@ use ratatui::text::{Line as TLine, Span}; use unicode_width::UnicodeWidthStr; use crate::ui::app::App; -use crate::worker::pty::{AttentionKind, HarnessAttention, HarnessControl, PtyState, SessionRow}; +use crate::worker::pty::{AttentionKind, HarnessAttention, PtyState, SessionControl, SessionRow}; use super::rows::{display_session_title, running_session_title}; use super::wrap::{flow_path, short_home, wrap_line, wrap_path}; @@ -38,7 +38,7 @@ fn attention_uses_the_configured_color_and_can_stay_solid() { 0, )); - let lines = app.own_harness_lines(&row, false, 48, NOW); + let lines = app.own_session_lines(&row, false, 48, NOW); let style = lines[0].spans[0].style; assert_eq!(style.fg, Some(Color::LightMagenta)); @@ -155,6 +155,7 @@ pub(super) fn harness_row(cwd: &str) -> SessionRow { id: "w_1".into(), label: "local".into(), provider: medulla::protocol::HarnessProvider::Codex, + preset: None, state: PtyState::Running, cwd: cwd.into(), branch: Some("main".into()), @@ -167,7 +168,7 @@ pub(super) fn harness_row(cwd: &str) -> SessionRow { last_output_at: 1, last_error: None, busy: false, - control: HarnessControl::User, + control: SessionControl::User, origin: crate::worker::pty::SessionOrigin::User, name: None, attention: None, @@ -185,7 +186,7 @@ fn viewport_keeps_all_three_lines_of_the_selected_harness_visible() { #[test] fn an_operator_harness_uses_one_compact_line_like_the_orchestrator() { let app = app(); - let lines = app.own_harness_lines(&harness_row("/workspace/medulla"), false, 48, NOW); + let lines = app.own_session_lines(&harness_row("/workspace/medulla"), false, 48, NOW); assert_eq!(lines.len(), 1, "a harness should consume one rail row"); assert_eq!( @@ -197,7 +198,7 @@ fn an_operator_harness_uses_one_compact_line_like_the_orchestrator() { #[test] fn a_long_harness_path_is_shortened_instead_of_adding_rows() { let app = app(); - let lines = app.own_harness_lines( + let lines = app.own_session_lines( &harness_row("/workspace/tinyhumans/products/medulla-public"), false, 36, @@ -216,7 +217,7 @@ fn a_long_harness_path_is_shortened_instead_of_adding_rows() { fn a_harness_prefix_never_exceeds_the_available_width() { let app = app(); for width in [0, 1, 4, 8] { - let line = &app.own_harness_lines(&harness_row("/workspace/medulla"), false, width, NOW)[0]; + let line = &app.own_session_lines(&harness_row("/workspace/medulla"), false, width, NOW)[0]; assert!(line.width() <= width, "width {width}: {line:?}"); } } @@ -228,14 +229,14 @@ fn harness_branch_and_path_can_be_hidden_independently() { app.loaded.config.appearance.show_harness_branch = false; assert_eq!( - app.own_harness_lines(&row, false, 48, NOW)[0].to_string(), + app.own_session_lines(&row, false, 48, NOW)[0].to_string(), "● codex · unmanaged · /workspace/medulla" ); app.loaded.config.appearance.show_harness_branch = true; app.loaded.config.appearance.show_harness_path = false; assert_eq!( - app.own_harness_lines(&row, false, 48, NOW)[0].to_string(), + app.own_session_lines(&row, false, 48, NOW)[0].to_string(), "● codex · unmanaged · main" ); } @@ -247,7 +248,7 @@ fn a_non_git_harness_omits_the_branch_without_a_placeholder() { row.branch = None; assert_eq!( - app.own_harness_lines(&row, false, 48, NOW)[0].to_string(), + app.own_session_lines(&row, false, 48, NOW)[0].to_string(), "● codex · unmanaged · /workspace/medulla" ); } diff --git a/src/tui/src/ui/app/render/agents/session.rs b/src/tui/src/ui/app/render/agents/session.rs new file mode 100644 index 000000000..5218c7028 --- /dev/null +++ b/src/tui/src/ui/app/render/agents/session.rs @@ -0,0 +1,52 @@ +//! Which session the Agents cursor is on, and what that answer arms. +//! +//! One question, asked once per draw, with three consequences: the pane and the +//! rail both need the id so they draw the same session, the keyboard needs it so +//! `Ctrl-]` attaches to the session the operator is looking at, and a row this +//! device does *not* host has to be told apart from a row that names no session +//! at all — one is watchable and the other is nothing, and both leave the local +//! session empty. +//! +//! It lives beside the layout rather than in it because the layout only consumes +//! the answer: a session paints its own composer, so resolving it is what +//! decides how many rows the split has to give away. + +use super::super::super::rail::RailRow; +use super::super::super::types::App; +use super::types::Selection; + +impl App { + /// Resolve the session `selection` points at and record it for the next key + /// press. + /// + /// Fills in [`Selection::session`] and, from it, the pane and rail pointers + /// and the remote-session classification. Called while `selection` is still + /// being built, so everything downstream — layout, drawing, the keyboard — + /// reads one answer. + pub(super) fn resolve_selected_session(&mut self, selection: &mut Selection) { + selection.session = self.local_session(selection); + // Focus follows the pane, not the other way round. If the cursor moved + // off the attached session — or that session ended — the keyboard comes + // back to the chrome, because keys landing in a harness the operator is + // no longer looking at is the worst failure this feature can have. + if let Some(attached) = self.harness_focus.attached_to() { + if selection.session.as_deref() != Some(attached) { + self.release_session(); + } + } + self.pane_session = selection.session.clone(); + self.rail_session = selection.session.clone(); + // A session row this device is not running: watchable, but not takeable + // (§E7). Recorded here because this is the only place that can tell the + // difference — one row down the cursor, both cases are a `None` session. + self.pane_remote_session = match (&selection.session, selection.rows.get(selection.active)) + { + (None, Some(RailRow::Session(row))) => Some( + row.agent_id + .clone() + .unwrap_or_else(|| "another host".to_string()), + ), + _ => None, + }; + } +} diff --git a/src/tui/src/ui/app/render/agents/started.rs b/src/tui/src/ui/app/render/agents/started.rs new file mode 100644 index 000000000..924f93ea2 --- /dev/null +++ b/src/tui/src/ui/app/render/agents/started.rs @@ -0,0 +1,161 @@ +//! The sessions the orchestrator started, rendered under the query that caused +//! them. +//! +//! §A7 gave the orchestrator's conversation a "sessions started" block so an +//! operator could click through to a dispatched session. It was one aggregate +//! list at the top of the pane: after three queries it said *"sessions started · +//! 7"* and left the reader to work out which query each one came from — which is +//! the fact they were looking for. +//! +//! So the entries are grouped by **turn** and drawn where that turn is: +//! +//! ```text +//! ❯ ship the auth fix and update the docs +//! ⏺ deploying two agents… +//! ▸ t_41 · api-claude · claude × ~/proj/api · running +//! ▸ t_42 · web-codex · codex × ~/proj/web · running +//! +//! ❯ now run the tests +//! ``` +//! +//! **Attribution needs no new bookkeeping.** The conversation is an ordered +//! event stream carrying both halves already: a [`TuiEvent::User`] opens a turn, +//! and every [`TuiEvent::TaskStart`] after it — until the next `User` — is a +//! task that turn caused. The rail's own session rows carry the same task ids, +//! so joining them is a lookup rather than a side-channel. +//! +//! A session whose `task_start` is not in this stream — dispatched in another +//! thread, or folded from a snapshot that predates the visible events — is +//! **not dropped**: it is listed in a trailing group under its own heading, at +//! the end of the transcript where the reader already is. Sessions started +//! before the first query of the thread land ahead of that first query, which is +//! chronologically where they happened. + +use std::collections::HashMap; + +use crate::ui::agents::Line as StyledLine; +use crate::ui::events::{EventEnvelope, TuiEvent}; + +use super::super::super::session_focus::StartedSession; +use super::super::chat_lines; + +/// The turn a task belongs to, keyed by task id. +/// +/// Turn `0` is everything before the first user message — a dispatch the +/// orchestrator made on its own, or one folded in from an earlier state. +fn turn_of_tasks(events: &[EventEnvelope]) -> HashMap { + let mut turns = HashMap::new(); + let mut turn = 0usize; + for env in events { + match &env.event { + TuiEvent::User { .. } => turn += 1, + TuiEvent::TaskStart { task_id, .. } => { + // First writer wins: a task id is announced once, and a retry + // re-announcing it belongs to the turn that first asked. + turns.entry(task_id.clone()).or_insert(turn); + } + _ => {} + } + } + turns +} + +/// The conversation, with each turn's spawned sessions listed under it. +/// +/// Returns the lines and, line for line, the task each one opens — `None` for +/// ordinary transcript lines. The parallel vector is what keeps the entries +/// clickable through the same task-keyed path as the old block: the caller +/// windows both by the same scroll offset, so a click resolves to whatever is +/// drawn at that row rather than to an index recorded before the last scroll. +pub(super) fn chat_lines_with_sessions( + events: &[EventEnvelope], + width: usize, + started: &[StartedSession], +) -> (Vec, Vec>) { + let turns = turn_of_tasks(events); + let mut lines: Vec = Vec::new(); + let mut hits: Vec> = Vec::new(); + let mut push = |line: StyledLine, task: Option| { + lines.push(line); + hits.push(task); + }; + + // The stream split at its user turns: segment `n` runs from the n-th user + // message up to the one after it, and segment 0 is whatever came before the + // first. Splitting is safe because `chat_lines` flushes its pending tool + // calls immediately *before* handling a `User` event, so a boundary there + // produces the same lines the whole stream would. + let mut bounds = vec![0usize]; + bounds.extend( + events + .iter() + .enumerate() + .filter_map(|(index, env)| matches!(env.event, TuiEvent::User { .. }).then_some(index)), + ); + bounds.push(events.len()); + + for (turn, window) in bounds.windows(2).enumerate() { + let (from, to) = (window[0], window[1]); + for line in chat_lines(&events[from..to], width) { + push(line, None); + } + for session in started.iter().filter(|session| { + turns + .get(&session.task_id) + .is_some_and(|attributed| *attributed == turn) + }) { + push(entry_line(session, width), Some(session.task_id.clone())); + } + } + + // Whatever this stream cannot account for. Listed rather than dropped: a + // session that is running, costing tokens and unreachable is worse than one + // filed under a heading that admits it does not know where it came from. + let orphans: Vec<&StartedSession> = started + .iter() + .filter(|session| !turns.contains_key(&session.task_id)) + .collect(); + if !orphans.is_empty() { + push(StyledLine::default(), None); + push( + StyledLine { + text: format!( + "sessions started outside this conversation · {}", + orphans.len() + ), + color: Some("cyan".into()), + dim: true, + }, + None, + ); + for session in orphans { + push(entry_line(session, width), Some(session.task_id.clone())); + } + } + (lines, hits) +} + +/// One session entry: the task, the agent, where it runs, and how it is doing. +fn entry_line(session: &StartedSession, width: usize) -> StyledLine { + let mut parts = vec![session.task_id.clone()]; + if !session.agent.trim().is_empty() { + parts.push(session.agent.clone()); + } + match (&session.harness, &session.workspace) { + (Some(harness), Some(workspace)) => parts.push(format!( + "{harness} × {}", + crate::ui::util::clip_left(workspace, 28) + )), + (Some(harness), None) => parts.push(harness.clone()), + (None, Some(workspace)) => { + parts.push(crate::ui::util::clip_left(workspace, 28).to_string()) + } + (None, None) => {} + } + parts.push(session.status.to_string()); + StyledLine { + text: crate::ui::util::clip(&format!(" ▸ {}", parts.join(" · ")), width.max(20)), + color: Some("cyan".into()), + dim: true, + } +} diff --git a/src/tui/src/ui/app/render/agents/started_tests.rs b/src/tui/src/ui/app/render/agents/started_tests.rs new file mode 100644 index 000000000..3cb9d0820 --- /dev/null +++ b/src/tui/src/ui/app/render/agents/started_tests.rs @@ -0,0 +1,200 @@ +//! Attribution of spawned sessions to the conversation turn that caused them. + +use crate::ui::events::{EventEnvelope, TuiEvent}; + +use super::super::super::session_focus::StartedSession; +use super::started::chat_lines_with_sessions; + +/// One event in the stream, at a monotonic timestamp. +fn env(seq: u64, event: TuiEvent) -> EventEnvelope { + EventEnvelope { + seq, + at: seq as i64, + event, + } +} + +/// A user turn. +fn user(seq: u64, body: &str) -> EventEnvelope { + env( + seq, + TuiEvent::User { + body: body.to_string(), + }, + ) +} + +/// A dispatch announced by the orchestrator. +fn task_start(seq: u64, task_id: &str) -> EventEnvelope { + env( + seq, + TuiEvent::TaskStart { + task_id: task_id.to_string(), + instruction: String::new(), + depth: 1, + agent_id: None, + contract: None, + }, + ) +} + +/// A rail entry for a session serving `task_id`. +fn started(task_id: &str, agent: &str) -> StartedSession { + StartedSession { + agent: agent.to_string(), + harness: Some("claude".into()), + workspace: Some("/work/api".into()), + task_id: task_id.to_string(), + status: "running", + row_index: 0, + } +} + +/// The rendered text of each line, for order assertions. +fn texts(lines: &[crate::ui::agents::Line]) -> Vec { + lines.iter().map(|line| line.text.clone()).collect() +} + +/// Where a substring first appears in the rendered lines. +fn position(lines: &[crate::ui::agents::Line], needle: &str) -> usize { + texts(lines) + .iter() + .position(|text| text.contains(needle)) + .unwrap_or_else(|| panic!("{needle} is missing from {:?}", texts(lines))) +} + +#[test] +fn each_session_renders_under_the_query_that_started_it() { + // Two queries, two dispatches. The old block listed both at the top of the + // pane, which said *that* five sessions exist and never *which query* asked + // for one — the fact an operator reading the conversation is after. + let events = vec![ + user(1, "ship the auth fix"), + task_start(2, "t_auth"), + user(3, "now run the tests"), + task_start(4, "t_tests"), + ]; + let sessions = vec![ + started("t_auth", "api-claude"), + started("t_tests", "api-claude"), + ]; + + let (lines, hits) = chat_lines_with_sessions(&events, 80, &sessions); + + assert!( + position(&lines, "ship the auth fix") < position(&lines, "t_auth"), + "the entry follows its query" + ); + assert!( + position(&lines, "t_auth") < position(&lines, "now run the tests"), + "and precedes the next one: {:?}", + texts(&lines) + ); + assert!( + position(&lines, "now run the tests") < position(&lines, "t_tests"), + "the second turn keeps its own" + ); + // Every entry stays addressable by task, line for line with what was drawn. + assert_eq!(hits.len(), lines.len()); + assert_eq!(hits[position(&lines, "t_auth")].as_deref(), Some("t_auth")); + assert_eq!( + hits[position(&lines, "t_tests")].as_deref(), + Some("t_tests") + ); + assert_eq!( + hits[position(&lines, "ship the auth fix")], + None, + "an ordinary transcript line opens nothing" + ); +} + +#[test] +fn an_entry_names_the_agent_its_harness_and_its_workspace() { + let events = vec![user(1, "go"), task_start(2, "t_1")]; + let (lines, _) = chat_lines_with_sessions(&events, 100, &[started("t_1", "api-claude")]); + let entry = &texts(&lines)[position(&lines, "t_1")]; + assert!(entry.contains("api-claude"), "{entry}"); + assert!(entry.contains("claude × /work/api"), "{entry}"); + assert!(entry.contains("running"), "{entry}"); +} + +#[test] +fn a_session_this_conversation_cannot_account_for_still_appears() { + // Dispatched in another thread, or folded in from a state older than the + // visible events. A session that is running, costing tokens and unreachable + // is the failure the block existed to prevent, so it is filed under a + // heading that admits it rather than dropped. + let events = vec![user(1, "go"), task_start(2, "t_known")]; + let sessions = vec![started("t_known", "api"), started("t_orphan", "web")]; + + let (lines, hits) = chat_lines_with_sessions(&events, 80, &sessions); + + let at = position(&lines, "t_orphan"); + assert_eq!(hits[at].as_deref(), Some("t_orphan"), "and stays clickable"); + assert!( + position(&lines, "sessions started outside this conversation") < at, + "under a heading that says where it came from: {:?}", + texts(&lines) + ); + assert!( + position(&lines, "t_known") < at, + "after the turns that can be accounted for" + ); +} + +#[test] +fn a_session_started_before_the_first_query_leads_the_conversation() { + // Turn 0 is everything before the first user message. Chronologically that + // is where it happened, so that is where it is drawn. + let events = vec![task_start(1, "t_early"), user(2, "go")]; + let (lines, _) = chat_lines_with_sessions(&events, 80, &[started("t_early", "api")]); + assert!( + position(&lines, "t_early") < position(&lines, "go"), + "{:?}", + texts(&lines) + ); +} + +#[test] +fn splitting_the_stream_at_its_turns_does_not_change_the_transcript() { + // The grouping is a *relocation* of the entries, not a re-render of the + // conversation: with no sessions to place, the output has to be exactly + // what the unsplit fold produces, tool calls and all. + let events = vec![ + user(1, "first"), + env( + 2, + TuiEvent::ToolCallStart { + index: 0, + name: "read".into(), + }, + ), + env( + 3, + TuiEvent::ToolCallDelta { + index: 0, + args_delta: "{\"path\":\"a\"}".into(), + }, + ), + env( + 4, + TuiEvent::Assistant { + body: "done".into(), + }, + ), + user(5, "second"), + env( + 6, + TuiEvent::Assistant { + body: "also done".into(), + }, + ), + ]; + + let (grouped, hits) = chat_lines_with_sessions(&events, 80, &[]); + assert_eq!( + texts(&grouped), + texts(&super::super::chat_lines(&events, 80)) + ); + assert!(hits.iter().all(Option::is_none)); +} diff --git a/src/tui/src/ui/app/render/agents/summary.rs b/src/tui/src/ui/app/render/agents/summary.rs new file mode 100644 index 000000000..8b6fac821 --- /dev/null +++ b/src/tui/src/ui/app/render/agents/summary.rs @@ -0,0 +1,242 @@ +//! What the pane shows for a rail row that has no transcript of its own. +//! +//! A host header, an agent nothing has been dispatched to, and the two action +//! rows all name something real, and none of them is a conversation. The pane +//! used to fall back to [`lane_lines`](crate::ui::agents::lane_lines) for them — +//! with a lane index that had itself fallen back to **0**, the orchestrator's — +//! so selecting `+ New agent` showed the orchestrator thinking, attributed to a +//! row that had not thought anything. +//! +//! So each of those rows describes itself instead: the agent says what it is and +//! how to start a session on it, the host says where it is and how many agents +//! it holds, and the action rows say what they will do. Nothing here is +//! lane-shaped, because none of these rows has a lane. + +use crate::ui::agents::Line as StyledLine; + +use super::super::super::rail::{RailRow, NEW_AGENT_LABEL, NEW_SESSION_LABEL}; +use super::super::super::types::App; +use super::types::Selection; + +/// A rendered description of a laneless row: what to title the pane, and what to +/// put in it. +pub(super) struct RowPanel { + /// The pane title — what the row is, not "Transcript". + pub(super) title: String, + /// The body, already wrapped to the pane width. + pub(super) lines: Vec, +} + +/// Describe whatever laneless row the cursor is on. +/// +/// Total by construction: an unknown or missing row degrades to an empty panel +/// rather than borrowing another row's content, which is the failure this module +/// exists to end. +pub(super) fn row_panel(app: &App, selection: &Selection, width: usize) -> RowPanel { + match selection.row() { + Some(RailRow::Agent(agent)) => agent_panel(app, selection, agent, width), + Some(RailRow::Host(host)) => host_panel(selection, host), + Some(RailRow::NewAgent) => new_agent_panel(), + Some(RailRow::NewSession { agent_id }) => new_session_panel(app, agent_id), + _ => RowPanel { + title: "Transcript".into(), + lines: Vec::new(), + }, + } +} + +/// The agent itself: its identity, where it runs, and what it is running. +/// +/// Reached only for an agent with no lane — one the fold has produced no traffic +/// for. An agent *with* a lane keeps its own transcript, which is the thing an +/// operator opened it for. +fn agent_panel( + app: &App, + selection: &Selection, + agent: &super::super::super::rail::AgentRailRow, + width: usize, +) -> RowPanel { + let sessions = sessions_under(selection); + let mut lines = vec![ + field("agent", &agent.label()), + field("id", &agent.agent_id), + field("harness", agent.harness().unwrap_or("not declared")), + field("workspace", agent.workspace().unwrap_or("not declared")), + field("host", &host_label(app, selection, &agent.host_id)), + ]; + let roles = agent + .agent + .as_ref() + .map(|row| row.roles.join(", ")) + .filter(|roles| !roles.trim().is_empty()) + // An agent with no roles is offered for every template, which is the + // useful thing to say — "none" would read as "excluded from all". + .unwrap_or_else(|| "any".to_string()); + lines.push(field("roles", &roles)); + lines.push(field("sessions", &sessions.to_string())); + lines.push(StyledLine::default()); + lines.extend(wrap_note( + if sessions == 0 { + format!( + "No sessions yet. {NEW_SESSION_LABEL} under this agent — or ^T — starts one in {}.", + agent.workspace().unwrap_or("its workspace") + ) + } else { + format!( + "Its sessions are listed under it on the rail; {NEW_SESSION_LABEL} — or ^T — starts another." + ) + }, + width, + )); + RowPanel { + title: format!("agent · {}", agent.label()), + lines, + } +} + +/// The host: what it is called, whether it can be acted on, what it holds. +fn host_panel(selection: &Selection, host: &super::super::super::rail::HostRailRow) -> RowPanel { + let lines = vec![ + field("host", &host.label), + field("id", &host.host_id), + field( + "reach", + if host.local { + "this device — its agents are declared here" + } else { + "remote — its agents are declared on that machine" + }, + ), + field("agents", &agents_under(selection).to_string()), + ]; + RowPanel { + title: format!("host · {}", host.label), + lines, + } +} + +/// The `+ New agent` action, in one breath: what it writes and what it does not. +fn new_agent_panel() -> RowPanel { + RowPanel { + title: NEW_AGENT_LABEL.to_string(), + lines: vec![ + note("Declare an agent on this device: a harness type × a workspace"), + note("directory, with a name you choose."), + StyledLine::default(), + note("Declaring starts nothing. The agent gets a row from that moment"), + note("whether or not anything ever runs in it, and the orchestrator"), + note("can dispatch to it by name."), + StyledLine::default(), + note("⏎ or click opens the picker."), + ], + } +} + +/// The `+ new session` action, named for the agent it will start one on. +fn new_session_panel(app: &App, agent_id: &str) -> RowPanel { + let declaration = medulla::config::agent_declaration(app.agent_declarations(), agent_id); + let mut lines = vec![ + note("Start a session on this agent — its declared harness, in its"), + note("declared directory. You are asked for a name first."), + StyledLine::default(), + ]; + match declaration { + Some(declaration) => { + lines.push(field("agent", agent_id)); + lines.push(field("harness", &declaration.harness)); + lines.push(field( + "workspace", + declaration.workspace.path().unwrap_or("not declared"), + )); + } + // The rail only offers the action for a declared agent, so this is the + // window between a declaration being removed and the next frame. + None => lines.push(field("agent", agent_id)), + } + lines.push(StyledLine::default()); + lines.push(note( + "It is yours at birth: the orchestrator will not dispatch into it", + )); + lines.push(note("until you hand it over with ^G.")); + lines.push(StyledLine::default()); + lines.push(note("⏎ or click starts it.")); + RowPanel { + title: format!("{NEW_SESSION_LABEL} · {agent_id}"), + lines, + } +} + +/// One `label value` row, label dimmed so the values line up as the content. +fn field(label: &str, value: &str) -> StyledLine { + StyledLine { + text: format!("{label:<10} {value}"), + color: None, + dim: false, + } +} + +/// One dimmed prose line. +fn note(text: &str) -> StyledLine { + StyledLine { + text: text.to_string(), + color: None, + dim: true, + } +} + +/// Wrap a sentence into dimmed lines at the pane width. +fn wrap_note(text: String, width: usize) -> Vec { + crate::ui::util::wrap(&text, width.max(20)) + .into_iter() + .map(|row| note(&row)) + .collect() +} + +/// How many session rows hang off the agent under the cursor. +/// +/// Counted off the rail rather than off the roster so the number and the rows +/// beneath it cannot disagree: the walk stops at the next agent, host, or the +/// machine-level action, which is exactly where that agent's group ends. +fn sessions_under(selection: &Selection) -> usize { + selection + .rows + .iter() + .skip(selection.active + 1) + .take_while(|row| { + !matches!( + row, + RailRow::Agent(_) | RailRow::Host(_) | RailRow::NewAgent + ) + }) + .filter(|row| matches!(row, RailRow::Session(_))) + .count() +} + +/// How many agent rows hang off the host under the cursor. +fn agents_under(selection: &Selection) -> usize { + selection + .rows + .iter() + .skip(selection.active + 1) + .take_while(|row| !matches!(row, RailRow::Host(_))) + .filter(|row| matches!(row, RailRow::Agent(_))) + .count() +} + +/// What to call the host an agent is placed on. +/// +/// The rail's own host row when there is one, else this machine when the id is +/// the local host's, else the bare id — which is all a lane-only agent carries. +fn host_label(app: &App, selection: &Selection, host_id: &str) -> String { + let host_id = host_id.trim(); + if let Some(host) = selection.rows.iter().find_map(|row| match row { + RailRow::Host(host) if host.host_id.trim() == host_id && !host_id.is_empty() => Some(host), + _ => None, + }) { + return host.label.clone(); + } + if host_id.is_empty() || host_id == app.local_host_id().trim() { + return "this device".to_string(); + } + host_id.to_string() +} diff --git a/src/tui/src/ui/app/render/agents/transcript.rs b/src/tui/src/ui/app/render/agents/transcript.rs index 9c57b4850..b3687363c 100644 --- a/src/tui/src/ui/app/render/agents/transcript.rs +++ b/src/tui/src/ui/app/render/agents/transcript.rs @@ -18,8 +18,9 @@ use crate::ui::meters; use medulla::harness_contract::AgentBudgetMetadata; use super::super::super::types::App; -use super::super::{chat_lines, styled_to_tline}; +use super::super::styled_to_tline; use super::types::Selection; +use super::{started, summary}; impl App { /// Draw the transcript or declaration for whatever the cursor is on. @@ -37,7 +38,7 @@ impl App { // // Resolved in `agents_selection`, not here: it decides the layout as // well as the contents, so the split has already been made for it. - if let Some(session_id) = selection.harness.clone() { + if let Some(session_id) = selection.session.clone() { self.draw_local_harness(f, area, &session_id); return; } @@ -48,16 +49,35 @@ impl App { let lane = selection.lane(); let pane_width = ((area.width as usize).saturating_sub(4)).max(24); let on_orchestrator = selection.on_orchestrator; + // Resolved for the laneless rows — a host header, an idle agent, either + // action row — which have no transcript and must not be given someone + // else's. `selection.lane()` is `None` for exactly those, so this is the + // one branch that can answer for them. + let panel = (!on_orchestrator && selection.task.is_none() && lane.is_none()) + .then(|| summary::row_panel(self, selection, pane_width)); + // The sessions this conversation started, and the task each of its lines + // opens. Only the orchestrator's own pane carries them. + let mut started_hits: Vec> = Vec::new(); let content_lines: Vec = if let Some(t) = &selection.task { task_lines(t, pane_width) } else if on_orchestrator { // The orchestrator lane is the conversation: show what was said, not // the model calls that said it. The calls stay in Settings › Trace. - chat_lines(&self.snapshot.events, pane_width) + // §A7 rides inside it — each turn is followed by the sessions that + // turn started. + let started = self.started_sessions(); + let (lines, hits) = + started::chat_lines_with_sessions(&self.snapshot.events, pane_width, &started); + started_hits = hits; + lines + } else if let Some(panel) = &panel { + panel.lines.clone() } else { lane_lines(lane, pane_width) }; - let title = if let Some(t) = &selection.task { + let title = if let Some(panel) = &panel { + panel.title.clone() + } else if let Some(t) = &selection.task { format!( "{} › {} · {} turns", lane.map(|l| l.label.as_str()).unwrap_or("task"), @@ -71,8 +91,18 @@ impl App { .get(self.active_thread_idx()) .map(|t| t.name.clone()) .unwrap_or_else(|| "main".into()); + // The session count moved into the title with the entries + // themselves: they are scattered through the conversation now, so + // the pane says how many there are and that they can be opened, + // rather than repeating a heading above every group. + let sessions = started_hits.iter().flatten().count(); + let opener = if sessions > 0 { + format!(" · {sessions} sessions · click ▸ to open") + } else { + String::new() + }; format!( - "orchestrator · {thread} · {} turns", + "orchestrator · {thread} · {} turns{opener}", self.snapshot.messages.len().div_ceil(2) ) } else if let Some(l) = lane { @@ -84,6 +114,11 @@ impl App { let inner = block.inner(area); f.render_widget(block, area); let mut header: Vec = Vec::new(); + // §A7's entries are inside the conversation now (see [`started`]), so + // the hit map is built from the scrolled view below rather than from a + // fixed block at the top. Cleared here for the frames that draw no + // conversation at all. + self.hit_started_sessions = None; // What the selected agent is working on, in one line. The Work panel // beside this shows the whole picture, but it needs columns a narrow // terminal does not have — and the single most useful fact, what the @@ -247,7 +282,30 @@ impl App { self.agent_scroll.min(max_scroll) }; let end = content_lines.len() - eff; - let view = &content_lines[end.saturating_sub(capacity)..end]; + let window = end.saturating_sub(capacity)..end; + let view = &content_lines[window.clone()]; + // The §A7 entries are transcript lines now, so their hit box is the + // *visible* slice of the conversation and the task each drawn row opens. + // Keyed by task rather than by index for the same reason it always was: + // the rail is rebuilt every frame, and a session can end between the + // click landing and this resolving. + if on_orchestrator && !view.is_empty() { + let hits: Vec> = started_hits + .get(window) + .map(<[Option]>::to_vec) + .unwrap_or_default(); + if hits.iter().any(Option::is_some) { + self.hit_started_sessions = Some(( + Rect { + y: inner.y.saturating_add(header.len() as u16), + height: (hits.len() as u16) + .min(inner.height.saturating_sub(header.len() as u16)), + ..inner + }, + hits, + )); + } + } let mut out = header; if view.is_empty() { out.push(TLine::from(Span::styled( diff --git a/src/tui/src/ui/app/render/agents/transcript_tests.rs b/src/tui/src/ui/app/render/agents/transcript_tests.rs index 57ed899e9..c84877702 100644 --- a/src/tui/src/ui/app/render/agents/transcript_tests.rs +++ b/src/tui/src/ui/app/render/agents/transcript_tests.rs @@ -1,16 +1,18 @@ -//! Rendering regressions for session context in the transcript header. +//! Rendering regressions for session context in the transcript header, and for +//! the rows that have no transcript at all. use std::sync::Arc; use medulla::config::LoadedConfig; use medulla::harness_work::{HarnessSessionInfo, WorkSnapshot}; use medulla::runtime::mock::MockRuntime; -use medulla::runtime::Runtime; -use medulla::ui::agents::{AgentLane, AgentRole}; +use medulla::runtime::{AgentDeclaration, Runtime}; +use medulla::ui::agents::{AgentLane, AgentRole, TurnBlock}; use ratatui::backend::TestBackend; use ratatui::layout::Rect; use ratatui::Terminal; +use crate::ui::app::rail::{AgentRailRow, HostRailRow, RailRow}; use crate::ui::app::App; use super::types::Selection; @@ -48,10 +50,10 @@ fn descriptorless_lanes_still_show_their_pull_request_context() { rows: Vec::new(), active: 0, lanes: vec![lane], - lane_index: 0, + lane_index: Some(0), task: None, on_orchestrator: false, - harness: None, + session: None, }; let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap(); terminal @@ -86,3 +88,185 @@ fn descriptorless_lanes_still_show_their_pull_request_context() { assert!(output_without_pr.contains("branch fix-context")); assert!(!output_without_pr.contains("PR 42")); } + +/// A marker only the orchestrator's lane ever renders. +const ORCHESTRATOR_ONLY: &str = "ORCHESTRATORTHINKING"; + +/// Lane 0 — the orchestrator's — with a body no other row may show. +fn orchestrator_lane() -> AgentLane { + AgentLane { + key: "orchestrator".into(), + label: "orchestrator".into(), + role: AgentRole::Orchestrator, + turns: vec![TurnBlock { + at: 1, + header: ORCHESTRATOR_ONLY.into(), + header_color: None, + reasoning: Some(ORCHESTRATOR_ONLY.into()), + content: Some(ORCHESTRATOR_ONLY.into()), + tools: Vec::new(), + }], + last_at: 1, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: None, + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + +/// Draw one rail row's pane and return everything it painted. +fn pane_for(row: RailRow) -> String { + let runtime: Arc = Arc::new(MockRuntime::empty()); + let mut app = App::new(runtime, LoadedConfig::defaults("medulla.tui.json".into())); + let selection = Selection { + rows: vec![row], + active: 0, + lanes: vec![orchestrator_lane()], + // What the fix makes representable: a row with no lane of its own. It + // used to be `0`, which is this lane. + lane_index: None, + task: None, + on_orchestrator: false, + session: None, + }; + let mut terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + terminal + .draw(|frame| app.draw_agents_pane(frame, Rect::new(0, 0, 90, 20), &selection)) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() +} + +/// A declared agent with nothing dispatched to it: a row, and no lane. +fn idle_agent() -> AgentRailRow { + AgentRailRow { + agent_id: "api-claude".into(), + host_id: String::new(), + agent: Some(medulla::ui::hosts::HostAgentRow { + agent_id: "api-claude".into(), + label: "API".into(), + harness: Some("claude".into()), + workspace: Some("/work/api".into()), + roles: vec!["reviewer".into()], + max_sessions: Some(1), + declared: true, + editable: true, + live: false, + selected: false, + }), + lane_index: None, + } +} + +#[test] +fn a_row_with_no_lane_never_renders_the_orchestrators_stream() { + // The bug: `selection.lane()` fell back to lane 0 — the orchestrator's — 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. + for (what, row) in [ + ("the create action", RailRow::NewAgent), + ( + "a host header", + RailRow::Host(HostRailRow { + host_id: "studio".into(), + label: "studio".into(), + local: false, + }), + ), + ("an idle agent", RailRow::Agent(idle_agent())), + ( + "the per-agent action", + RailRow::NewSession { + agent_id: "api-claude".into(), + }, + ), + ] { + let output = pane_for(row); + assert!( + !output.contains(ORCHESTRATOR_ONLY), + "{what} showed lane 0's stream: {output}" + ); + } +} + +#[test] +fn an_idle_agent_describes_itself_instead() { + let output = pane_for(RailRow::Agent(idle_agent())); + assert!(output.contains("agent · API"), "{output}"); + assert!(output.contains("api-claude"), "the id: {output}"); + assert!(output.contains("claude"), "the harness: {output}"); + assert!(output.contains("/work/api"), "the workspace: {output}"); + assert!(output.contains("reviewer"), "its roles: {output}"); + assert!(output.contains("No sessions yet"), "the count: {output}"); + assert!( + output.contains("new session"), + "and how to start one: {output}" + ); +} + +#[test] +fn a_host_row_and_the_action_rows_say_what_they_are() { + let host = pane_for(RailRow::Host(HostRailRow { + host_id: "studio".into(), + label: "studio".into(), + local: false, + })); + assert!(host.contains("host · studio"), "{host}"); + assert!(host.contains("remote"), "local or remote: {host}"); + + let new_agent = pane_for(RailRow::NewAgent); + assert!(new_agent.contains("New agent"), "{new_agent}"); + assert!(new_agent.contains("Declare an agent"), "{new_agent}"); + + let new_session = pane_for(RailRow::NewSession { + agent_id: "api-claude".into(), + }); + assert!(new_session.contains("new session"), "{new_session}"); + assert!( + new_session.contains("api-claude"), + "named for its agent: {new_session}" + ); +} + +#[test] +fn the_selection_gives_a_laneless_row_no_lane_at_all() { + // The guard itself, one level below the render: the rail's own rows resolve + // to `None` rather than to lane 0, so no caller can inherit a stream. + let mut app = crate::ui::app::rail::tests::hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "idle-agent", + "", + "claude", + "/work/idle", + )]; + let rows = app.rail_rows(); + for (index, row) in rows.iter().enumerate() { + if row.lane_index().is_some() { + continue; + } + app.agent_index = index; + let selection = app.agents_selection(); + assert!( + selection.lane_index.is_none() && selection.lane().is_none(), + "row {index} borrowed a lane it does not have" + ); + if !matches!(row, RailRow::Lane(_)) { + assert!( + !selection.on_orchestrator, + "row {index} is not the conversation" + ); + } + } +} diff --git a/src/tui/src/ui/app/render/agents/types.rs b/src/tui/src/ui/app/render/agents/types.rs index a7546875c..35aec3435 100644 --- a/src/tui/src/ui/app/render/agents/types.rs +++ b/src/tui/src/ui/app/render/agents/types.rs @@ -21,26 +21,41 @@ pub(super) struct Selection { /// Every lane the fold produced, in display order. pub(super) lanes: Vec, /// The lane whose transcript the pane shows, when the cursor is on one. - pub(super) lane_index: usize, + /// + /// `None` for a row that has no lane of its own — a host header, an agent + /// nothing has been dispatched to, and either action row. Deliberately an + /// `Option` rather than a defaulted index: this used to fall back to lane + /// **0**, which is the orchestrator's, so selecting `+ New agent` or an idle + /// agent rendered the orchestrator's own thinking as if it belonged to that + /// row. A row with no lane now has no lane, and the caller renders what the + /// row actually is. + pub(super) lane_index: Option, /// The task sublane under the cursor, if the cursor is on one. pub(super) task: Option, /// Whether the pane is showing the operator's own conversation, which /// scrolls separately and renders the chat log rather than model calls. pub(super) on_orchestrator: bool, - /// The live local harness session this row resolves to, when it resolves to - /// one. + /// The live local session this row resolves to, when it resolves to one. /// /// Decided here rather than at draw time because it changes the *layout*, - /// not just the contents: a harness paints its own composer, so ours has no + /// not just the contents: a session paints its own composer, so ours has no /// rows and the work panel no columns. Resolving it after the split would /// mean laying out for a transcript and then drawing a terminal into it. - pub(super) harness: Option, + pub(super) session: Option, } impl Selection { /// The lane the pane describes, if any. + /// + /// `None` is a real answer — the row under the cursor has no transcript — + /// and never a stand-in for lane 0. pub(super) fn lane(&self) -> Option<&AgentLane> { - self.lanes.get(self.lane_index) + self.lanes.get(self.lane_index?) + } + + /// The rail row under the cursor, if the rail has one. + pub(super) fn row(&self) -> Option<&RailRow> { + self.rows.get(self.active) } } diff --git a/src/tui/src/ui/app/render/changes.rs b/src/tui/src/ui/app/render/changes.rs index c380e6106..1f3dd5178 100644 --- a/src/tui/src/ui/app/render/changes.rs +++ b/src/tui/src/ui/app/render/changes.rs @@ -35,7 +35,7 @@ impl App { .as_deref() .map(|id| id.get(..7).unwrap_or(id)) .unwrap_or("unavailable"); - rows.push(ListItem::new(format!("Harness launch {launch}"))); + rows.push(ListItem::new(format!("Session launch {launch}"))); rows.extend( self.changes.recent_commits.iter().map(|commit| { ListItem::new(format!("{} {}", commit.short_id(), commit.subject)) diff --git a/src/tui/src/ui/app/render/frame_state.rs b/src/tui/src/ui/app/render/frame_state.rs new file mode 100644 index 000000000..797fa805e --- /dev/null +++ b/src/tui/src/ui/app/render/frame_state.rs @@ -0,0 +1,43 @@ +//! Clearing the state one frame leaves behind for the next one. +//! +//! A draw records where things landed — which session the pane is showing, which +//! rectangles the mouse can hit — and the *next* key or click reads it back. That +//! only stays honest if every frame re-earns it: a pointer left over from the +//! last draw answers for a pane that may no longer be on screen. So each of these +//! is cleared here, at the top of the draw, and filled back in by whichever tab +//! actually paints it. + +use super::super::types::App; + +impl App { + /// Drop everything the previous frame recorded, before this one draws. + pub(super) fn reset_frame_state(&mut self) { + // The harness pane is resolved during the draw and read by the *next* + // key press, so it has to be cleared here rather than left over: a + // `Ctrl-]` on the Settings tab must not attach to whatever the Agents + // tab was showing several frames ago. `draw_agents_pane` fills it back + // in when it resolves a session. + self.pane_session = None; + // Its counterpart, for the same reason: a remembered remote row would + // answer the take chord on a tab that is not showing it. + self.pane_remote_session = None; + // Same reasoning as above: a stale rect would route the wheel into a + // terminal that is no longer on screen. + self.hit_session = None; + self.hit_workflow_preview = None; + // Same again for the hand-back question's answers: a click must never + // reach a `[Y]` that was on screen two frames ago, least of all when + // what it now sits over is the session the operator went back to. + self.hit_handback.clear(); + // Focus follows the pane, not the other way round. `agents_selection` + // (called only while drawing the Agents tab) is what notices the cursor + // moving off the attached session; it has nothing to say once the + // operator has left the tab entirely. Without this, `harness_focus` + // stayed `Attached` after a click elsewhere, and the next keystroke — + // meant for whatever tab was now on screen — was typed into a harness + // pane the operator could no longer see. + if self.harness_focus.attached_to().is_some() && self.tab() != "Agents" { + self.release_session(); + } + } +} diff --git a/src/tui/src/ui/app/render/mod.rs b/src/tui/src/ui/app/render/mod.rs index 9d55fa7c6..d55f25989 100644 --- a/src/tui/src/ui/app/render/mod.rs +++ b/src/tui/src/ui/app/render/mod.rs @@ -2,7 +2,8 @@ //! the [`App::draw`] layout, hints/tabs/status line, the shared [`App::panel`] block //! builder, and content dispatch — plus the small styling helpers ([`color`], //! [`styled_to_tline`], [`event_color`], [`chat_lines`], [`App::event_line`]) -//! reused by the per-tab submodules. Each tab's body lives in a sibling module. +//! reused by the per-tab submodules. Each tab's body lives in a sibling module, +//! and [`frame_state`] owns the per-frame reset every draw opens with. use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -22,13 +23,14 @@ mod agents; mod changes; mod decisions; mod feedback; +mod frame_state; pub(super) mod graph; -mod harness_modals; mod overview; mod points; mod prompt; mod routing; mod selection; +mod session_modals; mod settings; mod status_line; mod template_modal; @@ -288,30 +290,9 @@ impl App { /// "which backend am I on, and what is it doing" is a glance-down check. pub fn draw(&mut self, f: &mut Frame) { self.area = f.area(); - // The harness pane is resolved during the draw and read by the *next* - // key press, so it has to be cleared here rather than left over: a - // `Ctrl-]` on the Settings tab must not attach to whatever the Agents - // tab was showing several frames ago. `draw_agents_pane` fills it back - // in when it resolves a session. - self.harness_pane_session = None; - // Same reasoning as above: a stale rect would route the wheel into a - // terminal that is no longer on screen. - self.hit_harness = None; - self.hit_workflow_preview = None; - // Same again for the hand-back question's answers: a click must never - // reach a `[Y]` that was on screen two frames ago, least of all when - // what it now sits over is the harness the operator went back to. - self.hit_handback.clear(); - // Focus follows the pane, not the other way round. `agents_selection` - // (called only while drawing the Agents tab) is what notices the cursor - // moving off the attached session; it has nothing to say once the - // operator has left the tab entirely. Without this, `harness_focus` - // stayed `Attached` after a click elsewhere, and the next keystroke — - // meant for whatever tab was now on screen — was typed into a harness - // pane the operator could no longer see. - if self.harness_focus.attached_to().is_some() && self.tab() != "Agents" { - self.release_harness(); - } + // Everything the last frame recorded for the next key press, dropped + // before this one records its own — see [`frame_state`]. + self.reset_frame_state(); // The composer now lives inside the Agents pane, so the only things that // still claim a row of their own below the content are the inline prompt // and the resume picker. @@ -354,7 +335,7 @@ impl App { match overlay { Overlay::Decisions => self.draw_decisions(f, rows[2]), Overlay::TemplatePopup => self.draw_template_modal(f, rows[2]), - Overlay::HarnessPicker => self.draw_harness_picker(f, rows[2]), + Overlay::AgentPicker => self.draw_harness_picker(f, rows[2]), Overlay::HandbackPrompt => self.draw_handback_prompt(f, rows[2]), Overlay::InlinePrompt => self.draw_prompt(f, rows[3]), Overlay::ResumePicker => self.draw_resume(f, rows[3]), @@ -387,7 +368,7 @@ impl App { // and an operator reading Workflows or Settings is exactly the person // who does not know a pane has stopped. The count rides on the tab so // the signal survives leaving the tab that carries it. - let waiting = self.harnesses_waiting(); + let waiting = self.sessions_waiting(); // Badges are built *before* the width is measured, because they are part // of what has to fit: measuring the bare names and then rendering wider // labels overflows the bar on a terminal that was only just wide enough, @@ -470,7 +451,7 @@ impl App { f.render_widget( Paragraph::new(TLine::from(Span::styled( format!( - "Typing into the harness — every key goes to it · {} releases the keyboard", + "Typing into the session — every key goes to it · {} releases the keyboard", crate::ui::harness_pane::FOCUS_CHORD_LABEL ), Style::default().add_modifier(Modifier::BOLD), @@ -489,7 +470,7 @@ impl App { } else if workflows { "Tab views · ⏎ open · Esc back · ←→ follow edges · ↑↓ lanes · i inspect · c copilot · x run · d dry-run · r refresh" } else { - "Tab views · Esc/↑↓ rail · ⏎/^] harness · d harness diff · ⇧⏎ newline · ⌥X cancel · ⌥A answer · ^N thread · ^↑↓ switch · ^Y copy · ^X abort" + "Tab views · Esc/↑↓ rail · ⏎/^] session · d session diff · ⇧⏎ newline · ⌥X cancel · ⌥A answer · ^N thread · ^↑↓ switch · ^Y copy · ^X abort" }; f.render_widget( Paragraph::new(TLine::from(Span::styled( diff --git a/src/tui/src/ui/app/render/routing/add_host.rs b/src/tui/src/ui/app/render/routing/add_host.rs index 6730e64cf..61a425968 100644 --- a/src/tui/src/ui/app/render/routing/add_host.rs +++ b/src/tui/src/ui/app/render/routing/add_host.rs @@ -177,7 +177,7 @@ impl App { } else { StepState::Live }; - lines.push(header(2, "Which harness it runs", state)); + lines.push(header(2, "Which harness type it runs", state)); lines.push(TLine::from("")); let providers = self.add_host_providers(); for (index, provider) in providers.iter().enumerate() { @@ -233,7 +233,7 @@ impl App { "Enter continue · Esc start over · c copy the install line" } (false, AddHostKind::Local) => { - "↑↓ choose a harness · Enter continue · Esc start over" + "↑↓ choose a harness type · Enter continue · Esc start over" } }, dim, diff --git a/src/tui/src/ui/app/render/routing/harnesses/mod.rs b/src/tui/src/ui/app/render/routing/harnesses/mod.rs index 97ae09be3..bf9acabc0 100644 --- a/src/tui/src/ui/app/render/routing/harnesses/mod.rs +++ b/src/tui/src/ui/app/render/routing/harnesses/mod.rs @@ -167,7 +167,7 @@ impl App { f.render_widget( Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) - .block(self.panel("Harnesses")), + .block(self.panel("Harness Types")), area, ); } diff --git a/src/tui/src/ui/app/render/routing/hosts.rs b/src/tui/src/ui/app/render/routing/hosts.rs deleted file mode 100644 index 9d7ca0f76..000000000 --- a/src/tui/src/ui/app/render/routing/hosts.rs +++ /dev/null @@ -1,387 +0,0 @@ -//! Registered host list and fleet actions. - -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line as TLine, Span, Text}; -use ratatui::widgets::Paragraph; -use ratatui::Frame; - -use medulla::protocol::{BudgetWindow, HarnessBudget, HarnessReadiness}; - -use super::super::super::types::App; - -impl App { - /// Draw registered hosts as a list above a preview of the selected one. - /// - /// The split is what makes roles assignable. Folding every host's capacity, - /// readiness and budgets inline cost two rows apiece — mostly reading - /// "details not captured" — and left nowhere for a toggle list that belongs - /// to *one* host. One row per host up top, everything about the selected one - /// below, and the preview follows the cursor as it moves. - pub(super) fn draw_hosts(&mut self, f: &mut Frame, area: Rect) { - // `Runtime::workers()` keeps the name the serve/hub wire uses; what it - // returns is the host level of the containment chain, which is what this - // page shows and what the rest of the UI now calls it. - let hosts = self.runtime.workers(); - let selected = self.host_index.min(hosts.len().saturating_sub(1)); - self.host_index = selected; - - // Give the preview at most half the page, and never more than the - // selected host actually has to say. A short roster on a tall terminal - // should not push its list into a strip. - let preview_rows = hosts - .get(selected) - .map(|host| self.preview_lines(host).len() as u16 + 2) - .unwrap_or(0) - .min(area.height / 2); - let [list_area, preview_area] = Layout::vertical([ - Constraint::Min(3), - Constraint::Length(if hosts.is_empty() { 0 } else { preview_rows }), - ]) - .areas(area); - - self.draw_host_list(f, list_area, &hosts, selected); - if !hosts.is_empty() { - self.draw_host_preview(f, preview_area, &hosts[selected]); - } - } - - /// The roster itself: one row per host, identity only. - fn draw_host_list( - &mut self, - f: &mut Frame, - area: Rect, - hosts: &[medulla::runtime::WorkerInfo], - selected: usize, - ) { - let block = self.panel(format!("Hosts · {}", hosts.len())); - let inner = block.inner(area); - f.render_widget(block, area); - let mut lines = Vec::new(); - if hosts.is_empty() { - lines.push(TLine::from(Span::styled( - "No hosts registered. Open Add Host to connect a remote machine.", - dim(), - ))); - } else { - // Reserve the footer (and optional hub identity) before choosing the - // host window so the selected row and action hints stay visible. - let footer_rows = 1 + usize::from(self.snapshot.link.is_some()) * 2; - let visible = usize::from(inner.height).saturating_sub(footer_rows).max(1); - let start = crate::ui::selection::viewport_start(selected, hosts.len(), visible); - for (index, host) in hosts.iter().enumerate().skip(start).take(visible) { - let selected_default = if host.selected { "●" } else { " " }; - let handle = host.handle.as_deref().unwrap_or(&host.address); - let label = host - .label - .as_deref() - .map(|value| format!(" · {value}")) - .unwrap_or_default(); - let harness = host - .harness - .as_deref() - .map(|value| format!(" · {}", value.to_uppercase())) - .unwrap_or_default(); - let roles = match host.roles.len() { - 0 => String::new(), - 1 => " · 1 role".to_string(), - count => format!(" · {count} roles"), - }; - let mut style = if host.selected { - Style::default().fg(Color::Green) - } else { - Style::default() - }; - // While the roles toggle has focus the list still marks its row, - // but dimly — two lit cursors on one page read as two selections. - if index == selected { - style = if self.host_roles_focus { - style.add_modifier(Modifier::BOLD) - } else { - self.theme.selection() - }; - } - lines.push(TLine::from(Span::styled( - format!( - "{selected_default} {} · {handle}{label}{harness}{roles}", - host.id - ), - style, - ))); - } - } - if let Some(identity) = &self.snapshot.link { - lines.push(TLine::from("")); - lines.push(TLine::from(vec![ - Span::styled("this hub · ", Style::default().fg(Color::Cyan)), - Span::raw(identity.node_name.clone()), - ])); - } - lines.push(TLine::from(Span::styled( - "↑↓/jk browse · → roles · r refresh · a add · Enter/s select · e edit · d/x remove", - dim(), - ))); - f.render_widget(Paragraph::new(Text::from(lines)), inner); - } - - /// Everything known about the selected host, plus its role toggles. - fn draw_host_preview( - &mut self, - f: &mut Frame, - area: Rect, - host: &medulla::runtime::WorkerInfo, - ) { - let block = self.panel(format!("Host · {}", host.id)); - let inner = block.inner(area); - f.render_widget(block, area); - // Draw to the height the pane actually got, which is what lets the role - // list scroll rather than run off the bottom of a short terminal. - let lines = self.preview_lines_within(host, Some(inner.height as usize)); - f.render_widget(Paragraph::new(Text::from(lines)), inner); - } - - /// The preview at its natural height, used to size the pane. - fn preview_lines(&self, host: &medulla::runtime::WorkerInfo) -> Vec> { - self.preview_lines_within(host, None) - } - - /// Build the preview body, windowing the role list when `budget` rows is - /// less than it needs. Shared with the height calculation so the pane is - /// sized to what it will actually draw rather than a guess. - fn preview_lines_within( - &self, - host: &medulla::runtime::WorkerInfo, - budget: Option, - ) -> Vec> { - let mut lines = Vec::new(); - lines.push(TLine::from(vec![ - Span::styled("workspace ", dim()), - Span::raw( - host.workspace - .clone() - .unwrap_or_else(|| "not reported".into()), - ), - ])); - let capacity = match ( - host.ip_address.as_deref(), - host.cpu_cores, - host.memory_available_bytes, - host.memory_total_bytes, - ) { - (None, None, None, None) => "details not captured · press r to refresh".into(), - (ip, cpu, available, total) => format!( - "IP {} · CPU {} · RAM {} available / {} total", - ip.unwrap_or("unknown"), - cpu.map(|cores| format!("{cores} cores")) - .unwrap_or_else(|| "unknown".into()), - available - .map(format_bytes) - .unwrap_or_else(|| "unknown".into()), - total.map(format_bytes).unwrap_or_else(|| "unknown".into()), - ), - }; - lines.push(TLine::from(vec![ - Span::styled("capacity ", dim()), - Span::raw(capacity), - ])); - if let Some(note) = readiness_summary(&host.readiness) { - lines.push(TLine::from(vec![ - Span::styled("harnesses ", dim()), - Span::raw(note), - ])); - } - if let Some(note) = budget_summary(&host.budgets) { - lines.push(TLine::from(vec![ - Span::styled("budgets ", dim()), - Span::raw(note), - ])); - } - // Whatever rows the detail above did not use are the role list's to fill. - let role_budget = budget.map(|rows| rows.saturating_sub(lines.len())); - lines.extend(self.role_lines(host, role_budget)); - lines - } - - /// The role toggle list. Roles come from the agent-template catalog, so a - /// host can only be offered for a role this hub actually knows how to brief. - /// - /// `budget` caps the rows; the window follows the cursor so a role can never - /// be selected but off-screen. - fn role_lines( - &self, - host: &medulla::runtime::WorkerInfo, - budget: Option, - ) -> Vec> { - let templates = self.agent_templates(); - if templates.is_empty() { - return vec![TLine::from(vec![ - Span::styled("roles ", dim()), - Span::styled("no agent templates are declared".to_string(), dim()), - ])]; - } - // The summary leads, because "none assigned" is the state most hosts are - // in and it must not read as a machine excluded from every role. Trailing - // it under a dozen checkboxes buried exactly the line that says otherwise. - let summary = if host.roles.is_empty() { - "none assigned · offered for any role".to_string() - } else { - host.roles.join(", ") - }; - let mut lines = vec![TLine::from(vec![ - Span::styled("roles ", dim()), - Span::styled( - summary, - if host.roles.is_empty() { - dim() - } else { - Style::default().fg(Color::Green) - }, - ), - ])]; - // One row is already spent on the summary. - let visible = budget - .map(|rows| rows.saturating_sub(1).max(1)) - .unwrap_or(templates.len()) - .min(templates.len()); - let start = - crate::ui::selection::viewport_start(self.host_role_index, templates.len(), visible); - for (index, template) in templates.iter().enumerate().skip(start).take(visible) { - let assigned = host.roles.iter().any(|role| role == &template.id); - let mark = if assigned { "[x]" } else { "[ ]" }; - let cursor = if self.host_roles_focus && index == self.host_role_index { - "▸" - } else { - " " - }; - let mut style = if assigned { - Style::default().fg(Color::Green) - } else { - dim() - }; - if self.host_roles_focus && index == self.host_role_index { - style = self.theme.selection(); - } - lines.push(TLine::from(vec![ - Span::styled(" ", dim()), - Span::styled(format!("{cursor} {mark} {}", template.id), style), - ])); - } - lines - } -} - -/// Format a byte count for a compact host-capacity row. -fn format_bytes(bytes: u64) -> String { - const GIB: f64 = 1024.0 * 1024.0 * 1024.0; - const MIB: f64 = 1024.0 * 1024.0; - if bytes >= GIB as u64 { - format!("{:.1} GiB", bytes as f64 / GIB) - } else { - format!("{:.0} MiB", bytes as f64 / MIB) - } -} - -/// Shared subdued style for host detail rows. -fn dim() -> Style { - Style::default().add_modifier(Modifier::DIM) -} - -/// Strip control characters from probe-supplied text before it reaches a ratatui -/// span. Readiness reasons arrive from a remote host over tiny.place, so a -/// compromised or malicious peer could otherwise smuggle terminal escape/OSC -/// sequences (cursor moves, title rewrites) into the operator's terminal. -fn inline_text(value: &str) -> String { - value.chars().filter(|c| !c.is_control()).collect() -} - -/// A compact per-harness readiness line, e.g. -/// `ready claude · not-ready codex (not authenticated)`. `None` when the host -/// advertised no readiness. Display-only; readiness is heuristic and advisory. -/// The reason is untrusted peer text, so it is sanitized before rendering and a -/// reason that sanitizes to empty is dropped. -fn readiness_summary(items: &[HarnessReadiness]) -> Option { - if items.is_empty() { - return None; - } - let parts: Vec = items - .iter() - .map(|r| { - let provider = r.provider.as_str(); - if r.ready { - format!("ready {provider}") - } else if let Some(reason) = r - .reason - .as_deref() - .map(inline_text) - .filter(|s| !s.is_empty()) - { - format!("not-ready {provider} ({reason})") - } else { - format!("not-ready {provider}") - } - }) - .collect(); - Some(parts.join(" · ")) -} - -/// A compact per-harness budget line carrying headroom, window, and cooldown, -/// e.g. `codex 1.5k left (weekly) · claude cooldown 1893456000`. Entries with no -/// usable signal (a pure estimate: no numbers, no window, no cooldown) are -/// omitted; `None` when nothing is worth showing. -fn budget_summary(items: &[HarnessBudget]) -> Option { - let parts: Vec = items.iter().filter_map(budget_line).collect(); - if parts.is_empty() { - None - } else { - Some(parts.join(" · ")) - } -} - -/// One provider's budget segment, or `None` when it carries no usable signal. -fn budget_line(b: &HarnessBudget) -> Option { - let window = window_label(b.window); - if b.remaining_tokens.is_none() && b.cooldown_until.is_none() && window.is_none() { - return None; // a bare estimate — nothing concrete to show. - } - let mut seg = b.provider.as_str().to_string(); - if let Some(remaining) = b.remaining_tokens { - seg.push_str(&format!(" {} left", fmt_tokens(remaining))); - } - if let Some(window) = window { - seg.push_str(&format!(" ({window})")); - } - if let Some(until) = b.cooldown_until { - seg.push_str(&format!(" · cooldown {until}")); - } - Some(seg) -} - -/// The short label for a metering window, or `None` for `Unknown`. -fn window_label(window: BudgetWindow) -> Option<&'static str> { - match window { - BudgetWindow::Daily => Some("daily"), - BudgetWindow::Weekly => Some("weekly"), - BudgetWindow::FiveHour => Some("5h"), - BudgetWindow::Unknown => None, - } -} - -/// Compact token count that scales into thousands/millions (`980` · `1.5k` · -/// `1.2M`). Negative inputs (never expected) clamp to zero. -fn fmt_tokens(tokens: i64) -> String { - let tokens = tokens.max(0) as u64; - if tokens >= 1_000_000 { - format!("{:.1}M", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - // Keep one fractional digit so `1_500` reads `1.5k`, not a rounded `2k` - // that would overstate remaining headroom; drop it for whole thousands. - let thousands = tokens as f64 / 1_000.0; - if thousands.fract() == 0.0 { - format!("{}k", thousands as u64) - } else { - format!("{thousands:.1}k") - } - } else { - tokens.to_string() - } -} diff --git a/src/tui/src/ui/app/render/routing/hosts/format.rs b/src/tui/src/ui/app/render/routing/hosts/format.rs new file mode 100644 index 000000000..b8df74ea2 --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/format.rs @@ -0,0 +1,124 @@ +//! Formatting shared by the host tree and its preview: byte counts, token +//! headroom, the subdued style, and the two probe-reported summaries. + +use ratatui::style::{Modifier, Style}; + +use medulla::protocol::{BudgetWindow, HarnessBudget, HarnessReadiness}; + +/// Format a byte count for a compact host-capacity row. +pub(super) fn format_bytes(bytes: u64) -> String { + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + if bytes >= GIB as u64 { + format!("{:.1} GiB", bytes as f64 / GIB) + } else { + format!("{:.0} MiB", bytes as f64 / MIB) + } +} + +/// Shared subdued style for host detail rows. +pub(super) fn dim() -> Style { + Style::default().add_modifier(Modifier::DIM) +} + +/// Strip control characters from text before it reaches a ratatui span. +/// +/// Readiness reasons, labels, ids and workspace paths arrive from a remote host +/// over tiny.place, so a compromised or malicious peer could otherwise smuggle +/// terminal escape/OSC sequences (cursor moves, title rewrites) into the +/// operator's terminal. +pub(super) fn inline_text(value: &str) -> String { + value.chars().filter(|c| !c.is_control()).collect() +} + +/// A compact per-harness readiness line, e.g. +/// `ready claude · not-ready codex (not authenticated)`. `None` when the host +/// advertised no readiness. Display-only; readiness is heuristic and advisory. +/// The reason is untrusted peer text, so it is sanitized before rendering and a +/// reason that sanitizes to empty is dropped. +pub(super) fn readiness_summary(items: &[HarnessReadiness]) -> Option { + if items.is_empty() { + return None; + } + let parts: Vec = items + .iter() + .map(|r| { + let provider = r.provider.as_str(); + if r.ready { + format!("ready {provider}") + } else if let Some(reason) = r + .reason + .as_deref() + .map(inline_text) + .filter(|s| !s.is_empty()) + { + format!("not-ready {provider} ({reason})") + } else { + format!("not-ready {provider}") + } + }) + .collect(); + Some(parts.join(" · ")) +} + +/// A compact per-harness budget line carrying headroom, window, and cooldown, +/// e.g. `codex 1.5k left (weekly) · claude cooldown 1893456000`. Entries with no +/// usable signal (a pure estimate: no numbers, no window, no cooldown) are +/// omitted; `None` when nothing is worth showing. +pub(super) fn budget_summary(items: &[HarnessBudget]) -> Option { + let parts: Vec = items.iter().filter_map(budget_line).collect(); + if parts.is_empty() { + None + } else { + Some(parts.join(" · ")) + } +} + +/// One provider's budget segment, or `None` when it carries no usable signal. +fn budget_line(b: &HarnessBudget) -> Option { + let window = window_label(b.window); + if b.remaining_tokens.is_none() && b.cooldown_until.is_none() && window.is_none() { + return None; // a bare estimate — nothing concrete to show. + } + let mut seg = b.provider.as_str().to_string(); + if let Some(remaining) = b.remaining_tokens { + seg.push_str(&format!(" {} left", fmt_tokens(remaining))); + } + if let Some(window) = window { + seg.push_str(&format!(" ({window})")); + } + if let Some(until) = b.cooldown_until { + seg.push_str(&format!(" · cooldown {until}")); + } + Some(seg) +} + +/// The short label for a metering window, or `None` for `Unknown`. +fn window_label(window: BudgetWindow) -> Option<&'static str> { + match window { + BudgetWindow::Daily => Some("daily"), + BudgetWindow::Weekly => Some("weekly"), + BudgetWindow::FiveHour => Some("5h"), + BudgetWindow::Unknown => None, + } +} + +/// Compact token count that scales into thousands/millions (`980` · `1.5k` · +/// `1.2M`). Negative inputs (never expected) clamp to zero. +fn fmt_tokens(tokens: i64) -> String { + let tokens = tokens.max(0) as u64; + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + // Keep one fractional digit so `1_500` reads `1.5k`, not a rounded `2k` + // that would overstate remaining headroom; drop it for whole thousands. + let thousands = tokens as f64 / 1_000.0; + if thousands.fract() == 0.0 { + format!("{}k", thousands as u64) + } else { + format!("{thousands:.1}k") + } + } else { + tokens.to_string() + } +} diff --git a/src/tui/src/ui/app/render/routing/hosts/list.rs b/src/tui/src/ui/app/render/routing/hosts/list.rs new file mode 100644 index 000000000..c134698b4 --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/list.rs @@ -0,0 +1,152 @@ +//! The tree itself: one row per host, then one per agent under it. + +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line as TLine, Span, Text}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; + +use super::super::super::super::hosts::HostsRow; +use super::super::super::super::types::App; +use super::format::{dim, inline_text}; + +/// The action hints, split so a narrow terminal still shows the first line. +const FOOTER: &str = + "↑↓/jk browse · → roles · n new agent · a add host · r refresh · Enter/s select · e edit · d/x remove"; + +impl App { + /// Draw the host tree, windowed so the cursor stays visible. + pub(super) fn draw_host_list( + &mut self, + f: &mut Frame, + area: Rect, + tree: &[HostRow], + rows: &[HostsRow], + selected: usize, + ) { + let agents: usize = tree.iter().map(|host| host.agents.len()).sum(); + let block = self.panel(format!("Hosts · {} · agents · {agents}", tree.len())); + let inner = block.inner(area); + f.render_widget(block, area); + let mut lines = Vec::new(); + if rows.is_empty() { + lines.push(TLine::from(Span::styled( + "No hosts. This device is not hosting — open Add Host to connect a machine.", + dim(), + ))); + } else { + // Reserve the footer (and optional hub identity) before choosing the + // window, so the selected row and the action hints stay visible. + let footer_rows = 1 + usize::from(self.snapshot.link.is_some()) * 2; + let visible = usize::from(inner.height).saturating_sub(footer_rows).max(1); + let start = crate::ui::selection::viewport_start(selected, rows.len(), visible); + for (index, row) in rows.iter().enumerate().skip(start).take(visible) { + let Some(host) = tree.get(row.host) else { + continue; + }; + let (text, mut style) = match row.agent.and_then(|at| host.agents.get(at)) { + Some(agent) => (agent_line(agent), agent_style(agent)), + None => (host_line(host), host_style(host)), + }; + if index == selected { + // While the roles toggle has focus the list still marks its + // row, but dimly — two lit cursors on one page read as two + // selections. + style = if self.host_roles_focus { + style.add_modifier(Modifier::BOLD) + } else { + self.theme.selection() + }; + } + lines.push(TLine::from(Span::styled(text, style))); + } + } + if let Some(identity) = &self.snapshot.link { + lines.push(TLine::from("")); + lines.push(TLine::from(vec![ + Span::styled("this hub · ", Style::default().fg(Color::Cyan)), + Span::raw(identity.node_name.clone()), + ])); + } + lines.push(TLine::from(Span::styled(FOOTER, dim()))); + f.render_widget(Paragraph::new(Text::from(lines)), inner); + } +} + +/// A host header: what it is called, where it is, and how many agents it holds. +/// +/// A remote host says so on its own row rather than only in the preview: it is +/// the difference between a machine you can declare an agent on and one you can +/// only watch, and that must be legible without moving the cursor. +fn host_line(host: &HostRow) -> String { + let kind = match host.kind { + HostKind::Local => "local".to_string(), + HostKind::Remote => "remote · read-only".to_string(), + }; + let agents = match host.agents.len() { + 0 => "no agents".to_string(), + 1 => "1 agent".to_string(), + count => format!("{count} agents"), + }; + format!( + "▾ {} · {} · {kind} · {agents}", + inline_text(&host.label), + inline_text(&host.id) + ) +} + +/// An agent under its host: the id a dispatch targets, its harness, where it +/// works, and the roles it is offered for. +/// +/// Indented under the header, and marked when it is the manual default (`●`). +/// A declared agent the roster has no entry for is flagged rather than hidden: +/// it is why nothing is being dispatched to it. +fn agent_line(agent: &HostAgentRow) -> String { + let mark = if agent.selected { "●" } else { " " }; + let harness = agent + .harness + .as_deref() + .map(|value| format!(" · {}", inline_text(&value.to_uppercase()))) + .unwrap_or_default(); + let workspace = agent + .workspace + .as_deref() + .map(|value| format!(" · {}", inline_text(value))) + .unwrap_or_default(); + let roles = match agent.roles.len() { + 0 => String::new(), + 1 => " · 1 role".to_string(), + count => format!(" · {count} roles"), + }; + let state = match (agent.live, agent.declared) { + (false, _) => " · declared, not running", + (true, false) => " · undeclared", + (true, true) => "", + }; + format!( + " {mark} {}{harness}{workspace}{roles}{state}", + inline_text(&agent.agent_id) + ) +} + +/// A remote host reads dim: it is context, not something to act on. +fn host_style(host: &HostRow) -> Style { + match host.kind { + HostKind::Local => Style::default().fg(Color::Cyan), + HostKind::Remote => dim().fg(Color::Cyan), + } +} + +/// The default agent is green; one that is declared but not running is dim, +/// because it is not a thing the orchestrator can reach right now. +fn agent_style(agent: &HostAgentRow) -> Style { + if agent.selected { + Style::default().fg(Color::Green) + } else if !agent.live { + dim() + } else { + Style::default() + } +} diff --git a/src/tui/src/ui/app/render/routing/hosts/mod.rs b/src/tui/src/ui/app/render/routing/hosts/mod.rs new file mode 100644 index 000000000..b494677ea --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/mod.rs @@ -0,0 +1,51 @@ +//! The Hosts page: the `Host → Agents` tree above a preview of the row the +//! cursor is on. +//! +//! The page renders the topology the advert is a projection of (spec §2.4): the +//! hosts this machine runs — always present, running or not — then every remote +//! host the roster reaches, each with the agents known to be on it. It used to +//! render the worker roster flat and call each row a host, which was true only +//! while a machine advertised one worker; a machine now declares one agent per +//! `harness × workspace`, so that list was agents with the host level collapsed +//! out of it. +//! +//! The split into list + preview is what makes roles assignable: folding every +//! row's capacity, readiness and budgets inline cost two rows apiece — mostly +//! reading "details not captured" — and left nowhere for a toggle list that +//! belongs to *one* agent. + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::Frame; + +use super::super::super::types::App; + +mod format; +mod list; +mod preview; + +impl App { + /// Draw the host tree with a preview of the selected row beneath it. + pub(super) fn draw_hosts(&mut self, f: &mut Frame, area: Rect) { + let (tree, rows, selected) = self.hosts_view(); + self.host_index = selected; + + // Give the preview at most half the page, and never more than the + // selected row actually has to say. A short tree on a tall terminal + // should not push its list into a strip. + let preview_rows = rows + .get(selected) + .map(|row| self.preview_lines(&tree, *row).len() as u16 + 2) + .unwrap_or(0) + .min(area.height / 2); + let [list_area, preview_area] = Layout::vertical([ + Constraint::Min(3), + Constraint::Length(if rows.is_empty() { 0 } else { preview_rows }), + ]) + .areas(area); + + self.draw_host_list(f, list_area, &tree, &rows, selected); + if let Some(row) = rows.get(selected).copied() { + self.draw_host_preview(f, preview_area, &tree, row); + } + } +} diff --git a/src/tui/src/ui/app/render/routing/hosts/preview.rs b/src/tui/src/ui/app/render/routing/hosts/preview.rs new file mode 100644 index 000000000..d2e7766e3 --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/preview.rs @@ -0,0 +1,340 @@ +//! The preview under the tree: everything about the row the cursor is on. +//! +//! Two shapes, because the two row kinds answer different questions. A **host** +//! preview is the machine — the capacity, readiness and budgets its capability +//! probe reported, and whether an agent may be declared on it from here. An +//! **agent** preview is the thing a dispatch targets — its harness, workspace, +//! session bound, and the role toggles that are this page's one real edit. + +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line as TLine, Span, Text}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use medulla::runtime::WorkerInfo; +use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; + +use super::super::super::super::hosts::HostsRow; +use super::super::super::super::types::App; +use super::format::{budget_summary, dim, format_bytes, inline_text, readiness_summary}; + +impl App { + /// Draw the preview pane for the selected row. + pub(super) fn draw_host_preview( + &mut self, + f: &mut Frame, + area: Rect, + tree: &[HostRow], + row: HostsRow, + ) { + let Some(host) = tree.get(row.host) else { + return; + }; + let title = match row.agent.and_then(|at| host.agents.get(at)) { + Some(agent) => format!("Agent · {}", inline_text(&agent.agent_id)), + None => format!("Host · {}", inline_text(&host.label)), + }; + let block = self.panel(title); + let inner = block.inner(area); + f.render_widget(block, area); + // Draw to the height the pane actually got, which is what lets the role + // list scroll rather than run off the bottom of a short terminal. + let lines = self.preview_lines_within(tree, row, Some(inner.height as usize)); + f.render_widget(Paragraph::new(Text::from(lines)), inner); + } + + /// The preview at its natural height, used to size the pane. + pub(super) fn preview_lines(&self, tree: &[HostRow], row: HostsRow) -> Vec> { + self.preview_lines_within(tree, row, None) + } + + /// How many rows the preview would draw in `budget` of them. Test seam for + /// the one property the budget exists to hold: it is never exceeded. + #[cfg(test)] + pub(in crate::ui::app) fn preview_height_within( + &self, + tree: &[HostRow], + row: HostsRow, + budget: usize, + ) -> usize { + self.preview_lines_within(tree, row, Some(budget)).len() + } + + /// Build the preview body, windowing the role list when `budget` rows is + /// less than it needs. Shared with the height calculation so the pane is + /// sized to what it will actually draw rather than a guess. + fn preview_lines_within( + &self, + tree: &[HostRow], + row: HostsRow, + budget: Option, + ) -> Vec> { + let Some(host) = tree.get(row.host) else { + return Vec::new(); + }; + match row.agent.and_then(|at| host.agents.get(at)) { + Some(agent) => self.agent_preview(host, agent, budget), + None => self.host_preview(host), + } + } + + /// The machine: where it is, what it has, and what may be done to it here. + fn host_preview(&self, host: &HostRow) -> Vec> { + let mut lines = vec![TLine::from(vec![ + Span::styled("address ", dim()), + Span::raw(inline_text(&host.id)), + ])]; + let probe = host + .detail_worker + .as_deref() + .and_then(|id| self.runtime.workers().into_iter().find(|w| w.id == id)); + lines.push(TLine::from(vec![ + Span::styled("capacity ", dim()), + Span::raw(capacity_line(probe.as_ref())), + ])); + if let Some(note) = probe.as_ref().and_then(|w| readiness_summary(&w.readiness)) { + lines.push(TLine::from(vec![ + Span::styled("harnesses ", dim()), + Span::raw(note), + ])); + } + if let Some(note) = probe.as_ref().and_then(|w| budget_summary(&w.budgets)) { + lines.push(TLine::from(vec![ + Span::styled("budgets ", dim()), + Span::raw(note), + ])); + } + // The capability split, stated where the operator is looking. A remote + // host's agents are declared on that machine — this end can watch them + // and dispatch to them, and that is all (spec §2.4). + let (note, style) = match host.kind { + HostKind::Local if host.agents.is_empty() => ( + "none declared here · n declares one".to_string(), + Style::default().fg(Color::Green), + ), + HostKind::Local => ( + format!("{} declared here · n declares another", host.agents.len()), + Style::default().fg(Color::Green), + ), + HostKind::Remote if host.agents.is_empty() => ( + "none known · they are declared on that machine".to_string(), + dim(), + ), + HostKind::Remote => ( + format!( + "{} known to the roster · declared on that machine", + host.agents.len() + ), + dim(), + ), + }; + lines.push(TLine::from(vec![ + Span::styled("agents ", dim()), + Span::styled(note, style), + ])); + if host.kind == HostKind::Remote { + // Honest about the gap rather than papering over it: the host link + // does not exchange declared agent lists yet, so what is listed is + // whatever this hub happens to have in its own roster. + lines.push(TLine::from(Span::styled( + " this hub lists what its roster reaches, not that machine's declarations", + dim(), + ))); + } + lines + } + + /// The agent: what runs where, how many sessions it may hold, and its roles. + fn agent_preview( + &self, + host: &HostRow, + agent: &HostAgentRow, + budget: Option, + ) -> Vec> { + let mut lines = vec![ + TLine::from(vec![ + Span::styled("host ", dim()), + Span::raw(format!( + "{} · {}", + inline_text(&host.label), + match host.kind { + HostKind::Local => "local", + HostKind::Remote => "remote · read-only", + } + )), + ]), + TLine::from(vec![ + Span::styled("harness ", dim()), + Span::raw( + agent + .harness + .as_deref() + .map(inline_text) + .unwrap_or_else(|| "not reported".into()), + ), + ]), + TLine::from(vec![ + Span::styled("workspace ", dim()), + Span::raw( + agent + .workspace + .as_deref() + .map(inline_text) + .unwrap_or_else(|| "not reported".into()), + ), + ]), + ]; + let sessions = match agent.max_sessions { + Some(1) => "1 at a time · checkout".to_string(), + Some(max) => format!("{max} at a time"), + None => "not declared here".to_string(), + }; + lines.push(TLine::from(vec![ + Span::styled("sessions ", dim()), + Span::raw(sessions), + ])); + let state = match (agent.declared, agent.live) { + (true, true) => "declared · in the roster", + (true, false) => "declared · not running", + (false, true) => "in the roster · not declared here", + (false, false) => "not declared, not running", + }; + lines.push(TLine::from(vec![ + Span::styled("state ", dim()), + Span::raw(state), + ])); + // Whatever rows the detail above did not use are the role list's to fill. + let role_budget = budget.map(|rows| rows.saturating_sub(lines.len())); + lines.extend(self.role_lines(agent, role_budget)); + lines + } + + /// The role toggle list. Roles come from the agent-template catalog, so an + /// agent can only be offered for a role this hub actually knows how to + /// brief. + /// + /// `budget` caps the rows; the window follows the cursor so a role can never + /// be selected but off-screen. A remote agent gets the summary and no + /// checkboxes: its roles are assigned on the machine that declares it. + /// + /// The cap is a hard one — the result never exceeds `budget`. A zero budget + /// returns nothing at all and a budget of one returns only the summary, + /// because the alternative is drawing past the bottom of the pane, which on + /// a short terminal clipped the role cursor: the row the operator was about + /// to toggle was the one that fell off. + fn role_lines(&self, agent: &HostAgentRow, budget: Option) -> Vec> { + if budget == Some(0) { + return Vec::new(); + } + // What is left once the summary below has taken its row. + let remaining = budget.map(|rows| rows.saturating_sub(1)); + // The summary leads, because "none assigned" is the state most agents + // are in and it must not read as one excluded from every role. Trailing + // it under a dozen checkboxes buried exactly the line that says + // otherwise. + let summary = if agent.roles.is_empty() { + "none assigned · offered for any role".to_string() + } else { + agent + .roles + .iter() + .map(|role| inline_text(role)) + .collect::>() + .join(", ") + }; + let mut lines = vec![TLine::from(vec![ + Span::styled("roles ", dim()), + Span::styled( + summary, + if agent.roles.is_empty() { + dim() + } else { + Style::default().fg(Color::Green) + }, + ), + ])]; + if !agent.editable { + if remaining != Some(0) { + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled( + "read-only · assign roles on that machine".to_string(), + dim(), + ), + ])); + } + return lines; + } + let templates = self.agent_templates(); + if templates.is_empty() { + if remaining != Some(0) { + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled("no agent templates are declared".to_string(), dim()), + ])); + } + return lines; + } + let visible = remaining.unwrap_or(templates.len()).min(templates.len()); + if visible == 0 { + // One row left, and the summary has it. Better the sentence that + // says what the agent is offered for than one checkbox out of a + // dozen, which reads as the whole list. + return lines; + } + let start = + crate::ui::selection::viewport_start(self.host_role_index, templates.len(), visible); + for (index, template) in templates.iter().enumerate().skip(start).take(visible) { + let assigned = agent.roles.iter().any(|role| role == &template.id); + let mark = if assigned { "[x]" } else { "[ ]" }; + let cursor = if self.host_roles_focus && index == self.host_role_index { + "▸" + } else { + " " + }; + let mut style = if assigned { + Style::default().fg(Color::Green) + } else { + dim() + }; + if self.host_roles_focus && index == self.host_role_index { + style = self.theme.selection(); + } + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled(format!("{cursor} {mark} {}", template.id), style), + ])); + } + lines + } +} + +/// The machine's resources, or why they are not known yet. +/// +/// A host with nothing in the roster is the ordinary state of a declared host +/// that is not running — it has no probe to read, which is a different answer +/// from a probe that came back empty. +fn capacity_line(probe: Option<&WorkerInfo>) -> String { + let Some(probe) = probe else { + return "nothing in the roster reports for this host".into(); + }; + match ( + probe.ip_address.as_deref(), + probe.cpu_cores, + probe.memory_available_bytes, + probe.memory_total_bytes, + ) { + (None, None, None, None) => "details not captured · press r to refresh".into(), + (ip, cpu, available, total) => format!( + "IP {} · CPU {} · RAM {} available / {} total", + ip.map(inline_text).unwrap_or_else(|| "unknown".into()), + cpu.map(|cores| format!("{cores} cores")) + .unwrap_or_else(|| "unknown".into()), + available + .map(format_bytes) + .unwrap_or_else(|| "unknown".into()), + total.map(format_bytes).unwrap_or_else(|| "unknown".into()), + ), + } +} diff --git a/src/tui/src/ui/app/render/harness_modals.rs b/src/tui/src/ui/app/render/session_modals.rs similarity index 93% rename from src/tui/src/ui/app/render/harness_modals.rs rename to src/tui/src/ui/app/render/session_modals.rs index 75fec2ba4..462bd0a20 100644 --- a/src/tui/src/ui/app/render/harness_modals.rs +++ b/src/tui/src/ui/app/render/session_modals.rs @@ -13,23 +13,23 @@ use ratatui::text::{Line as TLine, Span, Text}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use ratatui::Frame; -use super::super::types::{App, HarnessPickerStep}; +use super::super::types::{AgentPickerStep, App}; const HARNESS_TRAILER_LINES: usize = 3; impl App { - /// Draw the "start a harness" picker. + /// Draw the "start a session" picker. pub(super) fn draw_harness_picker(&mut self, f: &mut Frame, area: Rect) { - let Some(picker) = &self.harness_picker else { + let Some(picker) = &self.agent_picker else { return; }; let (rows, title) = match picker.step { - HarnessPickerStep::Harness => ( + AgentPickerStep::Harness => ( picker.choices.len(), - "Choose harness — ↑/↓ · Enter workspace · Esc cancel", + "Choose a harness type — ↑/↓ · Enter workspace · Esc cancel", ), - HarnessPickerStep::Decision => (2, "Choose control — ↑/↓ · Enter confirm · Esc back"), - HarnessPickerStep::Workspace => ( + AgentPickerStep::Decision => (2, "Choose control — ↑/↓ · Enter confirm · Esc back"), + AgentPickerStep::Workspace => ( picker.workspace_choices.len(), "Choose workspace — type to filter · Tab complete · Enter start · Esc back", ), @@ -54,7 +54,7 @@ impl App { let mut lines = match picker.step { - HarnessPickerStep::Harness => { + AgentPickerStep::Harness => { let capacity = (inner.height as usize).saturating_sub(HARNESS_TRAILER_LINES); let range = harness_choice_window(picker.choices.len(), picker.index, capacity); picker.choices[range.clone()] @@ -75,7 +75,7 @@ impl App { }) .collect() } - HarnessPickerStep::Decision => { + AgentPickerStep::Decision => { let selected = picker .choices .get(picker.index) @@ -134,15 +134,15 @@ impl App { )), ] } - HarnessPickerStep::Workspace => { - let selected_harness = picker + AgentPickerStep::Workspace => { + let selected_session = picker .choices .get(picker.index) .map(|choice| choice.display_name()) .unwrap_or("harness"); let mut lines = vec![ TLine::from(Span::styled( - format!(" {selected_harness}"), + format!(" {selected_session}"), Style::default().add_modifier(Modifier::BOLD), )), TLine::from(format!( @@ -187,7 +187,7 @@ impl App { lines } }; - if picker.step == HarnessPickerStep::Harness { + if picker.step == AgentPickerStep::Harness { lines.push(TLine::from("")); lines.push(TLine::from(Span::styled( " Next: choose a workspace", @@ -197,7 +197,7 @@ impl App { // Said here as well as in the status line, because it is the one fact // that makes this different from every other way to start a harness. // Skip on the Decision step — it already shows both options inline. - if picker.step != HarnessPickerStep::Decision { + if picker.step != AgentPickerStep::Decision { lines.push(TLine::from(Span::styled( " unmanaged · the orchestrator will not dispatch into it", Style::default().add_modifier(Modifier::DIM), @@ -263,9 +263,9 @@ impl App { }; let area = centered(area, 72, 12); let title = if prompt.is_takeover { - "Take control of this harness" + "Take control of this session" } else { - "You still have this harness" + "You still have this session" }; let block = Block::default() .borders(Borders::ALL) @@ -294,7 +294,7 @@ impl App { ], ); let lines = vec![ - TLine::from("The orchestrator is using this harness."), + TLine::from("The orchestrator is using this session."), TLine::from("Take control to type into it."), TLine::from(""), hint, @@ -307,9 +307,9 @@ impl App { // focused in may not know they are holding anything. The sentence says // which of the two happened rather than implying the second. let how = if prompt.took_control { - "You took this harness when you focused in." + "You took this session when you focused in." } else { - "You asked for this harness." + "You asked for this session." }; // The note line shows a caret only while it is being edited, so the // operator can tell at a glance whether `y` will answer or type. diff --git a/src/tui/src/ui/app/render/settings/appearance_usage.rs b/src/tui/src/ui/app/render/settings/appearance_usage.rs index 6050f6b69..644bf8c67 100644 --- a/src/tui/src/ui/app/render/settings/appearance_usage.rs +++ b/src/tui/src/ui/app/render/settings/appearance_usage.rs @@ -51,7 +51,7 @@ impl App { lines.push(TLine::from("")); lines.push(TLine::from(Span::styled("Attention cues", heading))); lines.push(TLine::from(Span::styled( - " How Medulla highlights a task or harness waiting for you.", + " How Medulla highlights a task or session waiting for you.", description, ))); lines.push(TLine::from("")); @@ -180,7 +180,7 @@ impl App { // The harness-row toggles used to sit here. Point at where they went // rather than leaving an operator to find the new page by accident. lines.push(TLine::from(Span::styled( - "harness rows are laid out on the Status line page", + "session rows are laid out on the Status line page", Style::default().add_modifier(Modifier::DIM), ))); let where_saved = match &self.config_path { diff --git a/src/tui/src/ui/app/render/settings/help.rs b/src/tui/src/ui/app/render/settings/help.rs index b369cf604..ff4bfb136 100644 --- a/src/tui/src/ui/app/render/settings/help.rs +++ b/src/tui/src/ui/app/render/settings/help.rs @@ -36,7 +36,7 @@ impl App { TLine::from("↑↓ move between subpages · 1-9 jump straight to one"), TLine::from("Appearance: j / k pick an option · ←/→ or Enter change it (saved live)"), TLine::from( - "Status line: j / k pick a harness-row field · ←/→ or Enter cycle it (live preview)", + "Status line: j / k pick a session-row field · ←/→ or Enter cycle it (live preview)", ), TLine::from("Config: j / k pick a setting · ←/→ change · Enter toggle (saved to config.toml)"), TLine::from("Feedback: j / k browse · u/d vote · c comment · n feature · b bug · s sort · f filter"), @@ -45,17 +45,27 @@ impl App { TLine::from(" "), TLine::from("Ctrl-N new thread · Ctrl-↑↓ switch threads · Ctrl-C quit"), TLine::from(" "), - TLine::from(Span::styled("Harnesses", bold)), + TLine::from(Span::styled("Sessions", bold)), TLine::from(format!( - "{FOCUS_CHORD_LABEL} type into the selected harness (and take it from the orchestrator)" + "{FOCUS_CHORD_LABEL} type into the selected session (and take it from the orchestrator)" )), - TLine::from("Ctrl-T start a harness of your own · Ctrl-G grab it or give it back"), + TLine::from( + "Agents rail: Enter on + New agent declares one (harness type × workspace dir)", + ), + TLine::from( + "Ctrl-T opens a session of the selected agent · elsewhere it starts a loose session", + ), + TLine::from("Ctrl-G grabs the selected session or gives it back to the orchestrator"), + TLine::from( + "Click a line of the orchestrator's \"sessions started\" block to open that session", + ), + TLine::from("Ctrl-O returns to the orchestrator (and, once there, releases the mouse)"), TLine::from(format!( - "From an empty composer Esc focuses the rail · from a harness {FOCUS_CHORD_LABEL} releases to it" + "From an empty composer Esc focuses the rail · from a session {FOCUS_CHORD_LABEL} releases to it" )), - TLine::from("On the rail ↑↓ select a running harness task · K then y kills it"), + TLine::from("On the rail ↑↓ select a running session · K then y kills it"), TLine::from(Span::styled( - "While you hold a harness the orchestrator will not dispatch into it", + "While you hold a session the orchestrator will not dispatch into it", dim, )), TLine::from(" "), diff --git a/src/tui/src/ui/app/render/settings/status_line.rs b/src/tui/src/ui/app/render/settings/status_line.rs index 42f82dae3..c4411b0db 100644 --- a/src/tui/src/ui/app/render/settings/status_line.rs +++ b/src/tui/src/ui/app/render/settings/status_line.rs @@ -5,7 +5,7 @@ //! about a row that is thirty-six columns wide and shares those columns between //! five fields, so "branch on line 2" is not a question anyone can answer in the //! abstract — they have to see what it does to the path. It renders through the -//! same [`own_harness_lines`](crate::ui::app::App::own_harness_lines) the rail +//! same [`own_session_lines`](crate::ui::app::App::own_session_lines) the rail //! itself uses, against sample sessions, so it cannot drift from the real row. use ratatui::layout::Rect; @@ -18,7 +18,7 @@ use unicode_width::UnicodeWidthStr; use medulla::protocol::HarnessProvider; use crate::ui::app::render::agents::RAIL_MAX_CONTENT; -use crate::worker::pty::{HarnessControl, PtyState, SessionRow}; +use crate::worker::pty::{PtyState, SessionControl, SessionRow}; use super::super::super::status_line::{STATUS_LINE_ROWS, STATUS_LINE_ROW_COUNT}; use super::super::super::types::App; @@ -126,7 +126,7 @@ impl App { } let row = sample(); for (offset, line) in self - .own_harness_lines(&row, *active, width, medulla::clock::now_millis()) + .own_session_lines(&row, *active, width, medulla::clock::now_millis()) .into_iter() .enumerate() { @@ -156,6 +156,7 @@ fn sample_selected() -> SessionRow { id: "preview".into(), label: "preview".into(), provider: HarnessProvider::Claude, + preset: None, state: PtyState::Running, cwd: "/home/you/work/tinyhumans/medulla-public".into(), branch: Some("feat/status-line".into()), @@ -168,7 +169,7 @@ fn sample_selected() -> SessionRow { last_output_at: 0, last_error: None, busy: false, - control: HarnessControl::User, + control: SessionControl::User, origin: crate::worker::pty::SessionOrigin::User, name: None, attention: None, @@ -180,10 +181,11 @@ fn sample_selected() -> SessionRow { fn sample_orchestrator() -> SessionRow { SessionRow { provider: HarnessProvider::Codex, + preset: None, state: PtyState::Exited { code: Some(0) }, cwd: "/tmp/scratch".into(), branch: None, - control: HarnessControl::Orchestrator, + control: SessionControl::Orchestrator, ..sample_selected() } } @@ -192,7 +194,7 @@ fn sample_orchestrator() -> SessionRow { fn sample_alerting() -> SessionRow { SessionRow { state: PtyState::Failed, - last_error: Some("harness exited unexpectedly".into()), + last_error: Some("session exited unexpectedly".into()), ..sample_selected() } } diff --git a/src/tui/src/ui/app/render/tests.rs b/src/tui/src/ui/app/render/tests.rs index f74010cae..b555c19db 100644 --- a/src/tui/src/ui/app/render/tests.rs +++ b/src/tui/src/ui/app/render/tests.rs @@ -32,18 +32,18 @@ fn compact_tab_labels_shorten_the_current_wide_destinations() { #[test] fn harness_choice_window_keeps_the_selection_visible() { assert_eq!( - super::harness_modals::harness_choice_window(20, 0, 13), + super::session_modals::harness_choice_window(20, 0, 13), 0..13 ); assert_eq!( - super::harness_modals::harness_choice_window(20, 10, 13), + super::session_modals::harness_choice_window(20, 10, 13), 4..17 ); assert_eq!( - super::harness_modals::harness_choice_window(20, 19, 13), + super::session_modals::harness_choice_window(20, 19, 13), 7..20 ); - assert_eq!(super::harness_modals::harness_choice_window(2, 1, 13), 0..2); + assert_eq!(super::session_modals::harness_choice_window(2, 1, 13), 0..2); } fn lane(role: AgentRole) -> AgentLane { @@ -366,7 +366,7 @@ fn a_huge_argument_payload_is_clipped_not_dumped() { #[test] fn leaving_the_agents_tab_takes_the_keyboard_back_from_an_attached_harness() { - // The bug this pins: `release_harness` was only reached from + // The bug this pins: `release_session` was only reached from // `agents_selection`, which runs only while the Agents tab is being drawn. // It notices the *cursor* moving off the attached session and has nothing to // say once the operator has left the tab altogether — so focus stayed @@ -391,7 +391,7 @@ fn leaving_the_agents_tab_takes_the_keyboard_back_from_an_attached_harness() { terminal.draw(|f| app.draw(f)).expect("draw"); assert_eq!( - app.attached_harness(), + app.attached_session(), None, "keys must not reach a harness the operator has navigated away from" ); diff --git a/src/tui/src/ui/app/render/workflows/node_preview/kinds.rs b/src/tui/src/ui/app/render/workflows/node_preview/kinds.rs index 52c96fb0a..2c92bd451 100644 --- a/src/tui/src/ui/app/render/workflows/node_preview/kinds.rs +++ b/src/tui/src/ui/app/render/workflows/node_preview/kinds.rs @@ -157,7 +157,7 @@ fn agent_lines(config: &Value, defaults: &AgentDefaults) -> Vec> { ), ]), Line::from(Span::styled( - "A fresh, bounded harness session is started for this step.", + "A fresh, bounded agent session is started for this step.", Style::default().add_modifier(Modifier::DIM), )), ]; diff --git a/src/tui/src/ui/app/harness_control.rs b/src/tui/src/ui/app/session_control.rs similarity index 69% rename from src/tui/src/ui/app/harness_control.rs rename to src/tui/src/ui/app/session_control.rs index f0ee62a77..af0d7ffc2 100644 --- a/src/tui/src/ui/app/harness_control.rs +++ b/src/tui/src/ui/app/session_control.rs @@ -1,8 +1,8 @@ -//! Starting harnesses the operator owns, and moving control between them and +//! Starting sessions the operator owns, and moving control between them and //! the orchestrator. //! -//! Two features that turn out to be one. "Unmanaged" is not a kind of harness — -//! it is a harness the operator holds, and dispatch skips anything the operator +//! Two features that turn out to be one. "Unmanaged" is not a kind of session — +//! it is a session the operator holds, and dispatch skips anything the operator //! holds. So spawning one, taking one over, and handing one back are three //! spellings of the same state change, and they live together here. //! @@ -14,35 +14,35 @@ //! [`Cmd`](super::types::Cmd) and travels off-thread. Control flips locally //! first and the brief follows: a handback gated on a socket round-trip would //! fail whenever the uplink is down, which is exactly when an operator most -//! wants to let go of a harness. +//! wants to let go of a session. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use medulla::protocol::HarnessProvider; use crate::ui::composer::Draft; use crate::ui::harness_pane::HarnessChoice; -use crate::worker::pty::HarnessControl; +use crate::worker::pty::SessionControl; use super::types::{ - tab_pos, App, Cmd, HandbackPolicy, HandbackPrompt, HarnessPicker, HarnessPickerStep, + tab_pos, AgentPicker, AgentPickerStep, App, Cmd, HandbackPolicy, HandbackPrompt, PickerPurpose, }; impl App { - /// Open the "start a harness" picker, or spawn directly when the command - /// already named a provider. + /// Open the "start a session" picker, or spawn directly when the command + /// already named a harness type. /// - /// `/harness` with no provider opens the picker rather than guessing: + /// `/session` with no harness type opens the picker rather than guessing: /// starting the wrong CLI in the operator's workspace is not something they /// find out about until it has already done something. - pub(super) fn start_harness_command(&mut self, provider: Option<&str>, path: Option<&str>) { - let Some(harnesses) = self.harnesses.clone() else { - self.set_status("This device is not hosting, so it has no harnesses to start"); + pub(super) fn start_session_command(&mut self, provider: Option<&str>, path: Option<&str>) { + let Some(harnesses) = self.local_sessions.clone() else { + self.set_status("This device is not hosting, so it has no sessions to start"); return; }; match provider.and_then(HarnessProvider::from_wire) { Some(provider) => { let cwd = path.unwrap_or("").to_string(); - self.spawn_harness(HarnessChoice::native(provider), &cwd, false); + self.spawn_session(HarnessChoice::native(provider), &cwd, false); } None => { let choices = harnesses.choices(); @@ -50,10 +50,11 @@ impl App { self.set_status("No harness CLIs found on this device"); return; } - self.harness_picker = Some(HarnessPicker { + self.agent_picker = Some(AgentPicker { + purpose: PickerPurpose::Spawn, choices, index: 0, - step: HarnessPickerStep::Harness, + step: AgentPickerStep::Harness, cwd: path .map(str::to_string) .unwrap_or_else(|| harnesses.workspace.clone()), @@ -68,18 +69,18 @@ impl App { } /// Open the picker from the keyboard shortcut. - pub(crate) fn open_harness_picker(&mut self) { - self.start_harness_command(None, None); + pub(crate) fn open_session_picker(&mut self) { + self.start_session_command(None, None); } - /// Start a harness the operator owns and move the cursor onto it. + /// Start a session the operator owns and move the cursor onto it. /// - /// Selecting the new row matters more than it sounds: a harness that + /// Selecting the new row matters more than it sounds: a session that /// appears somewhere below the fold, with the pane still showing whatever /// was selected before, reads as "nothing happened". - pub(super) fn spawn_harness(&mut self, choice: HarnessChoice, cwd: &str, managed: bool) { - let Some(harnesses) = self.harnesses.clone() else { - self.set_status("This device is not hosting, so it has no harnesses to start"); + pub(super) fn spawn_session(&mut self, choice: HarnessChoice, cwd: &str, managed: bool) { + let Some(harnesses) = self.local_sessions.clone() else { + self.set_status("This device is not hosting, so it has no sessions to start"); return; }; let skip = self.harness_skip_permissions; @@ -87,7 +88,7 @@ impl App { match harnesses.open_unmanaged(&choice, &workspace, skip) { Ok(id) => { self.tab_index = tab_pos("Agents"); - self.select_harness_row(&id); + self.select_session_row(&id); let label = if managed { "managed" } else { "unmanaged" }; let mut status = format!( "Started {} · {label}, the orchestrator will{} use it", @@ -99,13 +100,17 @@ impl App { } // Hand back first, then say what happened. `hand_back_session` // sets its own status, so setting ours before it would show the - // operator "Handed back …" for a harness they just started — + // operator "Handed back …" for a session they just started — // losing the name, the managed/unmanaged confirmation, and any // workspace-remember error this message carries. if managed { self.hand_back_session(&id, None); } self.set_status(status); + // The quick path always leaves a declared agent behind if the + // operator wants one: a session in a directory nothing declares + // is a real thing running that the rail can only list loose. + self.offer_agent_declaration(choice.id(), &workspace); } // Surfaced, never swallowed: a spawn that fails silently leaves the // operator waiting for a pane that is never coming. @@ -115,74 +120,63 @@ impl App { } } - /// Put the rail cursor on the row for `session_id`, if it has one. - fn select_harness_row(&mut self, session_id: &str) { - if let Some(index) = self - .rail_rows() - .iter() - .position(|row| row.session_id() == Some(session_id)) - { - self.agent_index = index; - } - } - - /// Take the selected harness from the orchestrator. - pub(crate) fn take_harness_control(&mut self) { - let Some((harnesses, session)) = self.selected_harness() else { + /// Take the selected session from the orchestrator. + pub(crate) fn take_session_control(&mut self) { + let Some((harnesses, session)) = self.selected_session() else { return; }; - if harnesses.control(&session) == Some(HarnessControl::User) { - self.set_status("You already have this harness"); + if harnesses.control(&session) == Some(SessionControl::User) { + self.set_status("You already have this session"); return; } - harnesses.set_control(&session, HarnessControl::User); + harnesses.set_control(&session, SessionControl::User); if let Some(cwd) = harnesses.sessions.row(&session).map(|row| row.cwd) { - self.pending_cmds.push_back(Cmd::HoldHarness { + self.pending_cmds.push_back(Cmd::HoldSession { workspace: cwd, reason: None, }); } - self.set_status("You have this harness · the orchestrator will not dispatch into it"); + self.set_status("You have this session · the orchestrator will not dispatch into it"); } - /// Give the selected harness back to the orchestrator, with an optional note. - pub(crate) fn hand_harness_back(&mut self, note: Option) { + /// Give the selected session back to the orchestrator, with an optional note. + pub(crate) fn hand_session_back(&mut self, note: Option) { let Some((harnesses, session)) = self.handoff_target() else { return; }; - if harnesses.control(&session) == Some(HarnessControl::Orchestrator) { - self.set_status("The orchestrator already has this harness"); + if harnesses.control(&session) == Some(SessionControl::Orchestrator) { + self.set_status("The orchestrator already has this session"); return; } self.hand_back_session(&session, note); } - /// Toggle who holds the selected harness — the `Ctrl-G` shortcut. + /// Toggle who holds the selected session — the `Ctrl-G` shortcut. /// /// One key for both directions because the rail row and the pane title both /// say which way it will go, so a single "grab or give" is less to remember /// than two chords that each do nothing half the time. - pub(crate) fn toggle_harness_control(&mut self) { - let Some((harnesses, session)) = self.selected_harness() else { + pub(crate) fn toggle_session_control(&mut self) { + let Some((harnesses, session)) = self.selected_session() else { return; }; match harnesses.control(&session) { - Some(HarnessControl::User) => self.hand_back_session(&session, None), - Some(HarnessControl::Orchestrator) => self.take_harness_control(), - None => self.set_status("That harness is gone"), + Some(SessionControl::User) => self.hand_back_session(&session, None), + Some(SessionControl::Orchestrator) => self.take_session_control(), + None => self.set_status("That session is gone"), } } - /// Open the take-control or hand-back prompt depending on who holds the harness. + /// Open the take-control or hand-back prompt depending on who holds the session. /// - /// Enter on a harness row used to attach immediately, which is a control + /// Enter on a session row used to attach immediately, which is a control /// change made by a navigation key: an operator walking the rail with the - /// arrows and pressing Enter to "look closer" took the harness out from + /// arrows and pressing Enter to "look closer" took the session out from /// under the orchestrator without being asked. The question is the same one /// either way — which side of the handover is this? — so it reuses the /// hand-back prompt with the sentence turned around. - pub(crate) fn open_harness_enter_prompt(&mut self) { - let Some((harnesses, session)) = self.selected_harness() else { + pub(crate) fn open_session_enter_prompt(&mut self) { + let Some((harnesses, session)) = self.selected_session() else { return; }; match harnesses.control(&session) { @@ -190,19 +184,19 @@ impl App { // whether to give it back. `took_control` is read, not assumed: // a hold can begin implicitly (focusing in under a `Never` handback // policy), and hardcoding `false` would claim an explicit decision - // the operator never made — the same field `begin_harness_release` + // the operator never made — the same field `begin_session_release` // resolves the same way. - Some(HarnessControl::User) => { + Some(SessionControl::User) => { self.handback_prompt = Some(HandbackPrompt { session, - took_control: self.harness_took_control, + took_control: self.took_control_by_attach, note: Draft::default(), editing_note: false, is_takeover: false, }); } // The orchestrator holds it: typing into it means taking it first. - Some(HarnessControl::Orchestrator) => { + Some(SessionControl::Orchestrator) => { self.handback_prompt = Some(HandbackPrompt { session, took_control: false, @@ -212,69 +206,87 @@ impl App { }); } None => { - self.set_status("That harness is gone"); + self.set_status("That session is gone"); } } } - /// The harness `/handoff` means, without depending on a render having run. + /// The session `/handoff` means, without depending on a render having run. /// - /// [`selected_harness`](Self::selected_harness) reads `harness_pane_session`, + /// [`selected_session`](Self::selected_session) reads `pane_session`, /// which is written inside the Agents pane's draw and cleared at the top of - /// every frame. So `/handoff` typed from any other tab reported "no harness + /// every frame. So `/handoff` typed from any other tab reported "no session /// on this row" while the operator was demonstrably holding one — and with a /// note argument that is worse, because they have just typed a sentence that /// is then thrown away. /// /// In order: the attached session (unambiguous — the keyboard is in it), the - /// harness the last frame resolved, then the single running harness the + /// session the last frame resolved, then the single running session the /// operator holds. Ambiguity is reported, never guessed: handing back the - /// wrong harness puts an agent into a workspace somebody is still using. - fn handoff_target(&mut self) -> Option<(crate::ui::harness_pane::LocalHarnesses, String)> { - let Some(harnesses) = self.harnesses.clone() else { - self.set_status("This device is not hosting, so it has no harnesses"); + /// wrong session puts an agent into a workspace somebody is still using. + fn handoff_target(&mut self) -> Option<(crate::ui::harness_pane::LocalSessions, String)> { + let Some(harnesses) = self.local_sessions.clone() else { + self.set_status("This device is not hosting, so it has no sessions"); return None; }; if let Some(session) = self.harness_focus.attached_to() { return Some((harnesses, session.to_string())); } - if let Some(session) = self.harness_pane_session.clone() { + if let Some(session) = self.pane_session.clone() { return Some((harnesses, session)); } let held: Vec = harnesses .sessions .rows() .into_iter() - .filter(|row| row.control == HarnessControl::User && row.state.is_running()) + .filter(|row| row.control == SessionControl::User && row.state.is_running()) .map(|row| row.id) .collect(); match held.len() { 0 => { - self.set_status("You are not holding any harness"); + self.set_status("You are not holding any session"); None } 1 => Some((harnesses, held[0].clone())), n => { self.set_status(format!( - "You hold {n} harnesses — select one in Agents and press Ctrl-G" + "You hold {n} sessions — select one in Agents and press Ctrl-G" )); None } } } - /// The harness the cursor is on, with the handle needed to act on it. + /// The session the cursor is on, with the handle needed to act on it. /// /// Refuses with a reason rather than silently doing nothing, for the same /// reason the attach chord does: an operator who pressed a key and saw no /// change cannot tell "wrong row" from "broken feature". - fn selected_harness(&mut self) -> Option<(crate::ui::harness_pane::LocalHarnesses, String)> { - let Some(harnesses) = self.harnesses.clone() else { - self.set_status("This device is not hosting, so it has no harnesses"); + fn selected_session(&mut self) -> Option<(crate::ui::harness_pane::LocalSessions, String)> { + // A session on another host is a real session the cursor is really on — + // it is just not one this machine can take (§E7). The hub resolves a + // hold by local workspace path, so there is nothing here to flip, and + // the honest answer names the machine rather than pretending the row is + // empty. Watching it is unaffected: the screen mirror is read-only by + // design either way. + // + // Asked before "is this device hosting", because it is the more specific + // answer and the two are not exclusive: a laptop that hosts nothing can + // still be looking at a remote host's session, and "this device is not + // hosting" would be a true sentence about the wrong machine. + if let Some(agent) = self.pane_remote_session.clone() { + self.set_status(format!( + "{agent} runs on another host — you can watch this session, but \ + taking control is local-only for now" + )); + return None; + } + let Some(harnesses) = self.local_sessions.clone() else { + self.set_status("This device is not hosting, so it has no sessions"); return None; }; - let Some(session) = self.harness_pane_session.clone() else { - self.set_status("No harness on this row — select one to hand it over"); + let Some(session) = self.pane_session.clone() else { + self.set_status("No session on this row — select one to hand it over"); return None; }; Some((harnesses, session)) @@ -286,12 +298,12 @@ impl App { /// `false` means a prompt is now open and the operator is still attached — /// releasing before they answer would move the keyboard out from under the /// question being asked about it. - pub(crate) fn begin_harness_release(&mut self, session: &str) -> bool { + pub(crate) fn begin_session_release(&mut self, session: &str) -> bool { let held = self - .harnesses + .local_sessions .as_ref() .and_then(|harnesses| harnesses.control(session)) - == Some(HarnessControl::User); + == Some(SessionControl::User); if !held { return true; } @@ -302,14 +314,14 @@ impl App { } HandbackPolicy::Never => { self.set_status( - "Released · you still hold this harness (/handoff to give it back)", + "Released · you still hold this session (/handoff to give it back)", ); true } HandbackPolicy::Ask => { self.handback_prompt = Some(HandbackPrompt { session: session.to_string(), - took_control: self.harness_took_control, + took_control: self.took_control_by_attach, note: Draft::default(), editing_note: false, is_takeover: false, @@ -354,13 +366,13 @@ impl App { /// /// The question itself owns the keyboard and holds no field — `y`, `n` and /// `E` are answers, not text — so a paste made while it is up belongs to - /// neither the harness behind it nor the composer, and is dropped. After `E` + /// neither the session behind it nor the composer, and is dropped. After `E` /// the note *is* a text input, and pasting what you were doing into the /// brief the orchestrator receives is exactly what the note is for. /// /// Flattened to one line and inserted at the caret, matching /// [`edit_handback_note`](Self::edit_handback_note): the note is drawn as a - /// single row, and `Enter` there hands the harness back rather than breaking + /// single row, and `Enter` there hands the session back rather than breaking /// the line. pub(super) fn paste_into_handback_note(&mut self, text: &str) { let Some(prompt) = self.handback_prompt.as_mut() else { @@ -379,37 +391,37 @@ impl App { /// Hand `session` back and queue its brief. Every handback path ends here. /// - /// The order matters. The transcript is read while the harness is still + /// The order matters. The transcript is read while the session is still /// ours; control flips next, so the operator gets an answer on the same /// keystroke; the brief is queued last and travels asynchronously. /// - /// That ordering means the orchestrator can dispatch into the harness before + /// That ordering means the orchestrator can dispatch into the session before /// it has read the brief, and that is the right trade. The brief is /// *context*, not permission — gating the flip on a socket round-trip would - /// make handing a harness back fail whenever the uplink is down, which is + /// make handing a session back fail whenever the uplink is down, which is /// exactly when an operator most wants to let go of one. pub(super) fn hand_back_session(&mut self, session: &str, note: Option) { - let Some(harnesses) = self.harnesses.clone() else { + let Some(harnesses) = self.local_sessions.clone() else { return; }; // Read the row first: a session that has already gone is not handed - // back, and flipping control on a corpse would advertise a harness that + // back, and flipping control on a corpse would advertise a session that // does not exist. let Some(row) = harnesses.sessions.row(session) else { - self.set_status("That harness is gone"); + self.set_status("That session is gone"); return; }; let lines = harnesses .sessions .tail_lines(session, medulla::hub::handoff::TRANSCRIPT_LINES); - harnesses.set_control(session, HarnessControl::Orchestrator); - self.harness_took_control = false; + harnesses.set_control(session, SessionControl::Orchestrator); + self.took_control_by_attach = false; let brief = medulla::hub::handoff::normalize( medulla::hub::HarnessHandoff { // Per handback *event*, not per session: a second handback of the - // same harness is new work, and reusing the id would have the + // same session is new work, and reusing the id would have the // orchestrator ignore it as something it already picked up. id: format!("{}-{}", row.id, medulla::clock::now_millis()), at: medulla::clock::now_millis(), @@ -427,44 +439,44 @@ impl App { &lines, ); self.pending_cmds - .push_back(Cmd::HandOffHarness(Box::new(brief))); + .push_back(Cmd::HandOffSession(Box::new(brief))); self.set_status("Handed back · sending the orchestrator your brief"); } } impl App { - /// Route a key while the "start a harness" picker is open. + /// Route a key while the "start a session" picker is open. /// - /// The first step chooses a registered harness. The second step owns text + /// The first step chooses a registered harness type. The second step owns text /// input directly so filtering and filesystem completion update as the /// operator types. - pub(super) fn handle_harness_picker_key(&mut self, event: KeyEvent) { + pub(super) fn handle_agent_picker_key(&mut self, event: KeyEvent) { let code = event.code; let step = self - .harness_picker + .agent_picker .as_ref() .map(|picker| picker.step) - .unwrap_or(HarnessPickerStep::Harness); - if step == HarnessPickerStep::Decision { + .unwrap_or(AgentPickerStep::Harness); + if step == AgentPickerStep::Decision { self.handle_harness_decision_key(event); return; } - if step == HarnessPickerStep::Workspace { + if step == AgentPickerStep::Workspace { self.handle_harness_workspace_key(event); return; } match code { KeyCode::Esc => { - self.harness_picker = None; + self.agent_picker = None; self.set_status("Cancelled"); } KeyCode::Up => { - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.index = picker.index.saturating_sub(1); } } KeyCode::Down => { - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.index = (picker.index + 1).min(picker.choices.len().saturating_sub(1)); } } @@ -483,7 +495,7 @@ impl App { fn handle_harness_decision_key(&mut self, event: KeyEvent) { match event.code { // One step back, not two. Decision is reached *after* the workspace - // is chosen, so returning to the harness list would discard a + // is chosen, so returning to the harness-type list would discard a // workspace the operator never changed and make them reselect both. // Reuses the forward entry point so the hint text and the completion // list are the same ones the step normally opens with. @@ -491,30 +503,30 @@ impl App { self.open_harness_workspace_step(false); } KeyCode::Up | KeyCode::Down => { - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.managed = !picker.managed; } } KeyCode::Enter => { - let Some(workspace) = self.selected_harness_workspace() else { + let Some(workspace) = self.selected_picker_workspace() else { self.set_status("Choose a workspace first"); return; }; let choice = self - .harness_picker + .agent_picker .as_ref() .and_then(|picker| picker.choices.get(picker.index).cloned()); let managed = self - .harness_picker + .agent_picker .as_ref() .map(|p| p.managed) .unwrap_or(false); let Some(choice) = choice else { - self.set_status("Choose a harness first"); + self.set_status("Choose a harness type first"); return; }; - self.harness_picker = None; - self.spawn_harness(choice, &workspace, managed); + self.agent_picker = None; + self.spawn_session(choice, &workspace, managed); } _ => {} } @@ -524,21 +536,21 @@ impl App { fn handle_harness_workspace_key(&mut self, event: KeyEvent) { match event.code { KeyCode::Esc | KeyCode::BackTab => { - if let Some(picker) = &mut self.harness_picker { - picker.step = HarnessPickerStep::Harness; + if let Some(picker) = &mut self.agent_picker { + picker.step = AgentPickerStep::Harness; } - self.set_status("Pick a harness · Enter workspace · Esc cancel"); + self.set_status("Pick a harness type · Enter workspace · Esc cancel"); } // Moving the cursor is the operator choosing a completion over // whatever they entered, however few rows there are to move across. KeyCode::Up => { - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.workspace_index = picker.workspace_index.saturating_sub(1); picker.workspace_picked = !picker.workspace_choices.is_empty(); } } KeyCode::Down => { - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.workspace_index = (picker.workspace_index + 1) .min(picker.workspace_choices.len().saturating_sub(1)); picker.workspace_picked = !picker.workspace_choices.is_empty(); @@ -546,7 +558,7 @@ impl App { } KeyCode::Tab => self.complete_harness_workspace(), KeyCode::Backspace => { - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.workspace_query.pop(); picker.workspace_index = 0; picker.workspace_picked = false; @@ -554,7 +566,7 @@ impl App { self.refresh_harness_workspace_choices(); } KeyCode::Char(character) if is_text_input(event.modifiers) => { - if let Some(picker) = &mut self.harness_picker { + if let Some(picker) = &mut self.agent_picker { picker.workspace_query.push(character); picker.workspace_index = 0; picker.workspace_picked = false; @@ -562,12 +574,33 @@ impl App { self.refresh_harness_workspace_choices(); } KeyCode::Enter => { - if self.selected_harness_workspace().is_none() { + let Some(workspace) = self.selected_picker_workspace() else { self.set_status("Choose an existing directory"); return; + }; + let purpose = self + .agent_picker + .as_ref() + .map(|picker| picker.purpose.clone()) + .unwrap_or(PickerPurpose::Spawn); + // Declaring is finished by naming, not by choosing an owner: + // nothing starts, so there is nobody to own it yet. + if purpose == PickerPurpose::DeclareAgent { + let harness = self + .agent_picker + .as_ref() + .and_then(|picker| picker.choices.get(picker.index)) + .map(|choice| choice.id().to_string()); + let Some(harness) = harness else { + self.set_status("Choose a harness type first"); + return; + }; + self.agent_picker = None; + self.prompt_agent_name(&harness, &workspace); + return; } - if let Some(picker) = &mut self.harness_picker { - picker.step = HarnessPickerStep::Decision; + if let Some(picker) = &mut self.agent_picker { + picker.step = AgentPickerStep::Decision; picker.managed = true; } } @@ -577,7 +610,7 @@ impl App { /// Route a key while the hand-back question is open. /// - /// Enter means yes, because handing back is the safe answer: a harness left + /// Enter means yes, because handing back is the safe answer: a session left /// under a user who has walked away is one the orchestrator can never use, /// and that failure is silent. pub(super) fn handle_handback_key(&mut self, code: KeyCode) { @@ -585,14 +618,14 @@ impl App { return; }; // The takeover direction has no note and nothing to release: the - // operator is not holding the harness yet, so the only two answers are + // operator is not holding the session yet, so the only two answers are // "take it and start typing" and "leave it alone". if prompt.is_takeover { match code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { self.handback_prompt = None; - self.take_harness_control(); - self.attach_to_pane_harness(); + self.take_session_control(); + self.attach_to_pane_session(); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.handback_prompt = None; @@ -612,7 +645,7 @@ impl App { let note = self.handback_note(); self.handback_prompt = None; self.hand_back_session(&session, note); - self.release_harness(); + self.release_session(); } // Back to the question, keeping what was typed: an operator who // pressed Escape meant "stop typing", not "discard my sentence". @@ -635,20 +668,20 @@ impl App { let note = self.handback_note(); self.handback_prompt = None; self.hand_back_session(&session, note); - self.release_harness(); + self.release_session(); } KeyCode::Char('n') | KeyCode::Char('N') => { self.handback_prompt = None; - self.release_harness(); + self.release_session(); self.set_status( - "Released · you still hold this harness (/handoff to give it back)", + "Released · you still hold this session (/handoff to give it back)", ); } // Esc is "I did not mean to leave", so it puts the operator back // where they were rather than picking one of the answers for them. KeyCode::Esc => { self.handback_prompt = None; - self.set_status("Still typing into the harness"); + self.set_status("Still typing into the session"); } _ => {} } diff --git a/src/tui/src/ui/app/session_control_tests.rs b/src/tui/src/ui/app/session_control_tests.rs new file mode 100644 index 000000000..a02f15e7a --- /dev/null +++ b/src/tui/src/ui/app/session_control_tests.rs @@ -0,0 +1,182 @@ +//! Focused tests for the session-control chords: what they classify as text, +//! what they refuse, and how the render pass arms the remote-session refusal in +//! the first place. + +use std::sync::Arc; + +use crossterm::event::KeyModifiers; +use medulla::config::LoadedConfig; +use medulla::runtime::mock::MockRuntime; +use medulla::runtime::Runtime; +use ratatui::backend::TestBackend; +use ratatui::Terminal; + +use super::rail::RailRow; +use super::session_control::is_text_input; +use super::types::{tab_pos, App}; + +#[test] +fn workspace_text_accepts_altgr_but_rejects_control_shortcuts() { + assert!(is_text_input(KeyModifiers::NONE)); + assert!(is_text_input(KeyModifiers::SHIFT)); + assert!(is_text_input(KeyModifiers::CONTROL | KeyModifiers::ALT)); + assert!(!is_text_input(KeyModifiers::CONTROL)); + assert!(!is_text_input(KeyModifiers::ALT)); +} + +fn app() -> App { + let rt: Arc = Arc::new(MockRuntime::demo()); + let mut loaded = LoadedConfig::defaults("medulla.tui.json".into()); + loaded.config.link = Some(medulla::config::LinkConfig::default()); + App::new(rt, loaded) +} + +#[test] +fn taking_a_session_on_another_host_is_refused_by_name() { + // §E7. The hub resolves a hold by *local workspace path*, so there is + // nothing on this machine to flip for a session running on another one — + // the take would silently do nothing. Remote takeover needs the owner → + // machine control frames wired into the hold path, and is a documented + // follow-up (§G). + // + // The refusal has to name the machine. Both "the cursor is on nothing" and + // "the cursor is on someone else's session" leave `pane_session` empty, and + // an operator told "no session on this row" while plainly looking at one + // reads it as a broken feature rather than as a boundary. + let mut app = app(); + app.pane_session = None; + app.pane_remote_session = Some("mac-studio-claude".to_string()); + + app.take_session_control(); + + let status = app.status().to_string(); + assert!( + status.contains("mac-studio-claude") && status.contains("another host"), + "the refusal must name the agent and say why: {status}" + ); + assert!( + status.contains("watch"), + "and must say what the operator CAN do — a remote session is viewable, \ + which is the whole of the screen mirror: {status}" + ); +} + +/// A hosting device with no sessions running on it. +fn hosting(app: &mut App) { + app.local_sessions = Some(crate::ui::harness_pane::LocalSessions { + sessions: crate::worker::pty::PtyManager::new(), + runtimes: Arc::new(std::sync::Mutex::new(Vec::new())), + hub_address: "this-device".to_string(), + env: std::collections::HashMap::new(), + workspace: "/repos/acme".to_string(), + providers: Vec::new(), + custom_harnesses: Vec::new(), + router: None, + attribution: true, + hooks: medulla::harness_hooks::HooksConfig::default(), + log: None, + }); +} + +#[test] +fn the_take_chord_on_an_empty_row_still_says_so() { + // The other side of the same branch: with no remote row recorded, the + // message must stay the plain one. A remote-session sentence on a host row + // or the composer would be worse than the generic answer. + let mut app = app(); + hosting(&mut app); + app.pane_session = None; + app.pane_remote_session = None; + + app.take_session_control(); + + let status = app.status().to_string(); + assert!( + status.contains("No session on this row"), + "unexpected status: {status}" + ); +} + +/// Draw one whole frame, which is what records the pane pointers. +fn draw_once(app: &mut App) { + let mut terminal = Terminal::new(TestBackend::new(120, 40)).expect("a test terminal"); + terminal.draw(|frame| app.draw(frame)).expect("a frame"); +} + +/// The rail index of the first row satisfying `wanted`. +fn row_index(app: &App, wanted: impl Fn(&RailRow) -> bool) -> usize { + app.rail_rows() + .iter() + .position(wanted) + .expect("the demo fixture has such a row") +} + +#[test] +fn selecting_a_session_this_device_does_not_host_arms_the_remote_refusal() { + // The other half of `taking_a_session_on_another_host_is_refused_by_name`: + // that test sets `pane_remote_session` by hand, so nothing pinned the render + // pass that is supposed to set it. Nothing on this device hosts, so every + // session the demo fixture dispatched belongs to another machine — and the + // cursor landing on one is the whole of what arms the refusal. + let mut app = app(); + app.tab_index = tab_pos("Agents"); + app.agent_index = row_index(&app, |row| matches!(row, RailRow::Session(_))); + + draw_once(&mut app); + + let armed = app + .pane_remote_session + .clone() + .expect("a session row this device does not run is a remote session"); + assert!(app.pane_session.is_none(), "and it is not a local one"); + + // And the chord reads what the draw recorded, rather than the generic + // "no session on this row" that an unarmed pointer would produce. + app.take_session_control(); + let status = app.status().to_string(); + assert!( + status.contains(&armed) && status.contains("another host"), + "the refusal names what the draw armed: {status}" + ); +} + +#[test] +fn moving_off_the_row_or_off_the_tab_disarms_it_again() { + // A pointer is only true for the frame that recorded it. Left standing, it + // answers the take chord for a row — or a whole tab — the operator is no + // longer looking at, which is how `Ctrl-G` on Settings ends up talking about + // somebody else's machine. + let mut app = app(); + app.tab_index = tab_pos("Agents"); + app.agent_index = row_index(&app, |row| matches!(row, RailRow::Session(_))); + draw_once(&mut app); + assert!(app.pane_remote_session.is_some(), "armed to begin with"); + + // Off the row: the conversation is not a session at all. + app.agent_index = row_index(&app, |row| matches!(row, RailRow::Lane(_))); + draw_once(&mut app); + assert!( + app.pane_remote_session.is_none(), + "a lane row names no session: {:?}", + app.pane_remote_session + ); + + // Off the tab: nothing on Settings draws the rail, so nothing re-arms it. + app.agent_index = row_index(&app, |row| matches!(row, RailRow::Session(_))); + draw_once(&mut app); + assert!(app.pane_remote_session.is_some(), "armed again"); + app.tab_index = tab_pos("Settings"); + draw_once(&mut app); + assert!( + app.pane_remote_session.is_none(), + "a tab that never drew the row must not answer for it: {:?}", + app.pane_remote_session + ); + + app.take_session_control(); + let status = app.status().to_string(); + assert!( + !status.contains("another host"), + "so the chord falls back to the plain answer: {status}" + ); +} diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs new file mode 100644 index 000000000..3143cfc88 --- /dev/null +++ b/src/tui/src/ui/app/session_focus.rs @@ -0,0 +1,140 @@ +//! Click-through between the orchestrator's conversation and the sessions it +//! started. +//! +//! The orchestrator says "I have started three sessions" and then goes quiet +//! while they work, and until now the only way to reach one was to know which +//! agent it landed on and arrow down to it. So its conversation carries a +//! **sessions-started block** naming each one — the agent, the task, and what it +//! is doing — and selecting an entry moves the rail cursor onto that session's +//! row, which is what makes the pane show its conversation. +//! +//! The two directions are deliberately asymmetric. Going *in* is a pointer +//! gesture (the block is in the transcript, where the pointer already is) or the +//! rail's own arrows; coming *back* is `Ctrl-O`, one chord from anywhere on the +//! tab, because an operator several sessions deep should not have to find the +//! orchestrator's row again. +//! +//! Remote sessions are out of scope here: opening one arms a read-only screen +//! mirror, which is its own phase. + +use super::rail::RailRow; +use super::types::App; + +/// One entry of the sessions-started block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::ui::app) struct StartedSession { + /// The agent the session runs on — what the operator recognises it by. + pub(in crate::ui::app) agent: String, + /// The harness that agent runs, when the tree knows one. + pub(in crate::ui::app) harness: Option, + /// The directory it works in, when the tree knows one. + pub(in crate::ui::app) workspace: Option, + /// The task that created it. Empty for a session with no task of its own. + pub(in crate::ui::app) task_id: String, + /// The session's current state, in the task board's vocabulary. + pub(in crate::ui::app) status: &'static str, + /// The rail row it selects. + pub(in crate::ui::app) row_index: usize, +} + +impl App { + /// The sessions the orchestrator started, in rail order. + /// + /// Read off the rail rather than off the event stream, so the block and the + /// tree cannot disagree about what exists — and so an entry always has a row + /// to select. Only orchestrator-originated sessions are listed: a session a + /// person spun up was not "started by the orchestrator", and putting it in + /// this block would claim the orchestrator did something it did not. + pub(in crate::ui::app) fn started_sessions(&self) -> Vec { + // The agent an entry describes is the row it sits under, which the walk + // is already passing: sessions are grouped under their agent, so the + // last agent row seen is this session's own. That is also where the + // harness and the workspace come from — the session row itself carries + // neither, and an entry that names only an id says less than the rail + // row beside it. + let mut agent: Option = None; + let mut started = Vec::new(); + for (row_index, row) in self.rail_rows().into_iter().enumerate() { + match row { + RailRow::Agent(row) => agent = Some(row), + RailRow::Session(session) => { + if session.origin().is_user() { + continue; + } + let Some(task) = session.task.as_ref() else { + continue; + }; + // Only the agent this session is actually filed under: a + // session with no agent sits outside every group, so the row + // above it describes somebody else. + let owner = agent.as_ref().filter(|owner| { + Some(owner.agent_id.as_str()) == session.agent_id.as_deref() + }); + started.push(StartedSession { + agent: owner + .map(|owner| owner.label()) + .or_else(|| session.agent_id.clone()) + .unwrap_or_default(), + harness: owner.and_then(|owner| owner.harness().map(str::to_string)), + workspace: owner.and_then(|owner| owner.workspace().map(str::to_string)), + task_id: task.task_id.clone(), + status: task.status.label(), + row_index, + }); + } + _ => {} + } + } + started + } + + /// Move focus to the session serving `task_id`, if the rail still lists it. + /// + /// Addressed by task rather than by row index because the rail is rebuilt + /// every frame: a session can end between the click landing and this running, + /// and a stale index would put the cursor on whatever took its place. A task + /// that is no longer served is reported instead. + pub(in crate::ui::app) fn focus_session_for_task(&mut self, task_id: &str) -> bool { + let Some(session) = self + .started_sessions() + .into_iter() + .find(|session| session.task_id == task_id) + else { + self.set_status(format!("No session is running {task_id}")); + return false; + }; + // Safe by construction: the index came from the list this call just + // built, so nothing can have moved between resolving it and using it. + self.tab_index = super::types::tab_pos("Agents"); + self.agent_index = session.row_index; + self.agent_scroll = 0; + self.chat_scroll = 0; + // The rail owns the keyboard on a session row: there is no composer under + // one, so leaving focus on the composer would drive a caret that is not + // drawn. + self.focus_agents_rail(); + self.set_status(format!( + "{} · {} · ^O returns to the orchestrator", + session.agent, session.task_id + )); + true + } + + /// Return to the orchestrator's conversation — the `Ctrl-O` half of §A7. + /// + /// Puts the cursor on the orchestrator's own lane row and hands the keyboard + /// to the composer, because that lane *is* the text box: arriving on it with + /// focus still on the rail would mean pressing one more key before typing. + pub(in crate::ui::app) fn focus_orchestrator(&mut self) { + let Some(index) = self.orchestrator_row_index() else { + self.set_status("No conversation to return to yet"); + return; + }; + self.tab_index = super::types::tab_pos("Agents"); + self.agent_index = index; + self.agent_scroll = 0; + self.chat_scroll = 0; + self.focus_agents_composer(); + self.set_status("Back to the orchestrator"); + } +} diff --git a/src/tui/src/ui/app/session_focus_tests.rs b/src/tui/src/ui/app/session_focus_tests.rs new file mode 100644 index 000000000..5b3fabffe --- /dev/null +++ b/src/tui/src/ui/app/session_focus_tests.rs @@ -0,0 +1,93 @@ +//! Click-through between the orchestrator's conversation and the sessions it +//! started, and the chord back. + +use super::rail::tests::app; +use super::rail::RailRow; +use super::types::tab_pos; + +#[test] +fn the_orchestrator_lists_the_sessions_it_started() { + let app = app(); + let started = app.started_sessions(); + assert!( + !started.is_empty(), + "the demo fixture dispatches at least one task" + ); + for session in &started { + assert!(!session.task_id.is_empty(), "each entry names its task"); + assert!( + matches!( + app.rail_rows().get(session.row_index), + Some(RailRow::Session(_)) + ), + "each entry points at a session row" + ); + } +} + +#[test] +fn opening_an_entry_moves_the_rail_selection_onto_that_session() { + let mut app = app(); + let entry = app + .started_sessions() + .into_iter() + .next() + .expect("a started session"); + + assert!(app.focus_session_for_task(&entry.task_id)); + + assert_eq!(app.tab(), "Agents"); + assert_eq!(app.agent_index(), entry.row_index, "the rail follows"); + assert_eq!( + app.rail_rows() + .get(app.agent_index()) + .and_then(RailRow::task) + .map(|task| task.task_id.clone()), + Some(entry.task_id), + "the pane is now that session's conversation" + ); + // No composer is drawn under a session row, so the keyboard must not be left + // on one. + assert!(app.agents_rail_focused()); + assert!(app.status().contains("^O"), "{}", app.status()); +} + +#[test] +fn a_task_no_session_is_serving_is_reported_by_name() { + let mut app = app(); + assert!(!app.focus_session_for_task("no-such-task")); + assert!(app.status().contains("no-such-task"), "{}", app.status()); + assert!(app.on_orchestrator_lane(), "the cursor did not move"); +} + +#[test] +fn a_session_the_operator_started_is_not_claimed_by_the_orchestrator() { + // The block says what the *orchestrator* started. A user-originated session + // has no task, so it is filtered out twice over — by origin and by task — + // and every entry that survives can name a task. + let app = app(); + assert!(app + .started_sessions() + .iter() + .all(|session| !session.task_id.is_empty())); +} + +#[test] +fn the_chord_back_returns_to_the_orchestrator_and_its_composer() { + let mut app = app(); + let entry = app + .started_sessions() + .into_iter() + .next() + .expect("a started session"); + app.focus_session_for_task(&entry.task_id); + + app.focus_orchestrator(); + + assert_eq!(app.tab_index, tab_pos("Agents")); + assert!(app.on_orchestrator_lane(), "the cursor is on the lane"); + assert!( + app.agents_composer_shown(), + "and the keyboard is in the text box" + ); +} diff --git a/src/tui/src/ui/app/settings_edit/mod.rs b/src/tui/src/ui/app/settings_edit/mod.rs index 6d328a806..dcef80d8f 100644 --- a/src/tui/src/ui/app/settings_edit/mod.rs +++ b/src/tui/src/ui/app/settings_edit/mod.rs @@ -102,7 +102,7 @@ impl App { // always set; there is no "auto" state to fall back to. optional: false, }, - help: "Worker harness processes Medulla may run at once.", + help: "Agent sessions Medulla may run at once.", }, ]); rows diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index 8796f90dc..7b86f54b9 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -138,8 +138,9 @@ impl App { hit_tabs: Vec::new(), hit_tabs_row: 0, hit_agents: None, - hit_harness: None, + hit_session: None, hit_threads: None, + hit_started_sessions: None, hit_context: None, hit_workflow_preview: None, hit_nav: Default::default(), @@ -150,17 +151,18 @@ impl App { last_events_len: 0, link_obs: None, host_obs: None, - harnesses: None, + local_sessions: None, harness_focus: crate::ui::harness_pane::HarnessFocus::default(), - harness_pane_session: None, - selected_harness_session: None, - harness_picker: None, + pane_session: None, + pane_remote_session: None, + rail_session: None, + agent_picker: None, handback_prompt: None, - harness_pointer_grab: None, + pointer_grab: None, hit_handback: Vec::new(), help_scroll: 0, handback_policy, - harness_took_control: false, + took_control_by_attach: false, pending_cmds: std::collections::VecDeque::new(), harness_skip_permissions, copy_capture: None, @@ -334,30 +336,30 @@ impl App { self.host_obs.as_ref() } - /// Attach the live harness sessions this device is running. + /// Attach the live sessions this device is running. /// /// Only called when this machine hosts: without a host nothing runs here, so /// there is no screen to render and no PTY to type into. - pub fn set_local_harnesses(&mut self, harnesses: crate::ui::harness_pane::LocalHarnesses) { - self.harnesses = Some(harnesses); + pub fn set_local_sessions(&mut self, sessions: crate::ui::harness_pane::LocalSessions) { + self.local_sessions = Some(sessions); } - /// The live harness sessions this device is running, if it hosts. - pub fn local_harnesses(&self) -> Option<&crate::ui::harness_pane::LocalHarnesses> { - self.harnesses.as_ref() + /// The live sessions this device is running, if it hosts. + pub fn local_sessions(&self) -> Option<&crate::ui::harness_pane::LocalSessions> { + self.local_sessions.as_ref() } - /// The harness session the last draw resolved for the rail cursor. + /// The session the last draw resolved for the rail cursor. /// /// Inspection seam: it is set during render, so a test that wants to act on - /// "the selected harness" has to be able to see when the cursor has reached + /// "the selected session" has to be able to see when the cursor has reached /// one rather than counting rows it does not control. - pub fn harness_pane_session_for_test(&self) -> Option<&str> { - self.harness_pane_session.as_deref() + pub fn pane_session_for_test(&self) -> Option<&str> { + self.pane_session.as_deref() } - /// The harness session currently receiving the operator's keystrokes. - pub fn attached_harness(&self) -> Option<&str> { + /// The session currently receiving the operator's keystrokes. + pub fn attached_session(&self) -> Option<&str> { self.harness_focus.attached_to() } @@ -368,7 +370,7 @@ impl App { /// that wants to click "inside the pane" or "just outside it" has to be /// able to read it rather than hardcode a layout it does not control. pub fn harness_pane_rect_for_test(&self) -> Option<(Rect, String)> { - self.hit_harness.clone() + self.hit_session.clone() } /// Re-read the runtime snapshot and merge in the host-link observation. @@ -403,10 +405,10 @@ impl App { self.status = s.into(); } - /// Show and arm the harness-kill confirmation as one invariant-preserving + /// Show and arm the session-kill confirmation as one invariant-preserving /// state transition. pub(super) fn arm_kill(&mut self, target: (String, String)) { - self.set_status("Kill this harness? y confirm · any other key cancels"); + self.set_status("Kill this session? y confirm · any other key cancels"); self.kill_armed = Some(target); } @@ -564,20 +566,25 @@ impl App { /// rail, which carries the `+ New session` action and the operator's own /// harness rows as well as the lanes. Indexing the shorter list with it /// reported a lane for rows that name none, and the composer's visibility - /// hangs off this answer — so a harness row claimed a text box that was + /// hangs off this answer — so a session row claimed a text box that was /// never drawn, and every keystroke went into it. pub fn on_orchestrator_lane(&self) -> bool { let lanes = self.lanes(); let rows = self.rail_rows(); match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { - Some(super::rail::RailRow::Agent(row)) => row - .lane_index() - .and_then(|index| lanes.get(index)) + // Only a lane's *own* row is a conversation. `AgentRow` also wraps + // the `+N more` overflow control, which carries the lane index of + // the lane it pages — matching it here would have read that index + // out of a row that is a button, and an overflow row on a rail with + // no folded lanes yet would fall through to the `true` below and + // hand the orchestrator's composer to it. + Some(super::rail::RailRow::Lane(AgentRow::Lane { lane_index })) => lanes + .get(*lane_index) .map(|lane| lane.role == AgentRole::Orchestrator) // An empty lane list means the orchestrator lane is all there is. .unwrap_or(true), - // The action row and the operator's own harnesses are not lanes and - // have no conversation of their own. + // Hosts, agents, sessions, the overflow control and the action row + // are not lanes and have no conversation of their own. Some(_) => false, None => true, } @@ -596,7 +603,7 @@ impl App { pub(in crate::ui::app) fn orchestrator_row_index(&self) -> Option { let lanes = self.lanes(); self.rail_rows().iter().position(|row| match row { - super::rail::RailRow::Agent(AgentRow::Lane { lane_index }) => lanes + super::rail::RailRow::Lane(AgentRow::Lane { lane_index }) => lanes .get(*lane_index) .map(|lane| lane.role == AgentRole::Orchestrator) // Matches the same fallback `on_orchestrator_lane` makes: with diff --git a/src/tui/src/ui/app/state_tests.rs b/src/tui/src/ui/app/state_tests.rs new file mode 100644 index 000000000..799cc731a --- /dev/null +++ b/src/tui/src/ui/app/state_tests.rs @@ -0,0 +1,78 @@ +//! Tests for the derived state the Agents surface reads — currently which rail +//! row counts as the operator's own conversation. + +use std::sync::Arc; + +use medulla::config::LoadedConfig; +use medulla::runtime::mock::MockRuntime; +use medulla::runtime::Runtime; +use medulla::ui::agents::AgentRow; +use medulla::ui::events::{EventEnvelope, TuiEvent}; + +use super::rail::RailRow; +use super::types::App; + +/// An app on the demo runtime with a busy agent, so the rail carries every row +/// kind at once: the orchestrator's lane, an agent, its sessions, the `+N more` +/// overflow control and the `+ new session` action. +fn app_with_a_busy_agent() -> App { + let runtime: Arc = Arc::new(MockRuntime::demo()); + let mut app = App::new(runtime, LoadedConfig::defaults("medulla.tui.json".into())); + let base = app.snapshot.events.len() as u64; + for i in 0..25 { + let seq = base + i; + app.snapshot.events.push(EventEnvelope { + seq, + at: seq as i64 * 1000, + event: TuiEvent::TaskStart { + task_id: format!("dev-t{i}"), + instruction: "x".into(), + depth: 2, + agent_id: Some("dev".into()), + contract: None, + }, + }); + } + app +} + +#[test] +fn only_a_lanes_own_row_is_the_orchestrators_conversation() { + // The composer's visibility hangs off this answer, so every row that is not + // a lane must say no. The dangerous shape is a row that *wraps* a lane + // without being one: `RailRow::Lane` also carries `AgentRow::More`, the + // overflow control, and the `── functions ──` divider, which names no lane + // at all and so used to fall through to the "no lanes yet ⇒ the + // orchestrator is all there is" fallback. + let mut app = app_with_a_busy_agent(); + let rows = app.rail_rows(); + assert!( + rows.iter() + .any(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))), + "25 tasks overflow one page: {rows:?}" + ); + + for (index, row) in rows.iter().enumerate() { + if matches!(row, RailRow::Lane(AgentRow::Lane { .. })) { + continue; + } + app.agent_index = index; + assert!( + !app.on_orchestrator_lane(), + "row {index} ({row:?}) is not a lane and must not read as the orchestrator" + ); + } +} + +#[test] +fn the_orchestrators_own_lane_row_still_is() { + // The other half of the same match: narrowing it must not cost the row it + // exists to recognise. + let mut app = app_with_a_busy_agent(); + let index = app + .orchestrator_row_index() + .expect("the demo runtime folds an orchestrator lane"); + + app.agent_index = index; + assert!(app.on_orchestrator_lane()); +} diff --git a/src/tui/src/ui/app/tests.rs b/src/tui/src/ui/app/tests.rs index 555dd8b3d..3efeab3fe 100644 --- a/src/tui/src/ui/app/tests.rs +++ b/src/tui/src/ui/app/tests.rs @@ -72,18 +72,15 @@ fn every_tab_renders() { #[test] fn drawing_an_intervening_tab_preserves_the_harness_selected_for_changes() { let mut a = app(); - a.selected_harness_session = Some("older-harness".to_owned()); - a.harness_pane_session = Some("older-harness".to_owned()); + a.rail_session = Some("older-harness".to_owned()); + a.pane_session = Some("older-harness".to_owned()); a.tab_index = tab("Workflows"); render(&mut a); + assert_eq!(a.pane_session, None, "hidden panes cannot receive keys"); assert_eq!( - a.harness_pane_session, None, - "hidden panes cannot receive keys" - ); - assert_eq!( - a.selected_harness_session.as_deref(), + a.rail_session.as_deref(), Some("older-harness"), "tab navigation must not discard the Changes repository selection" ); @@ -150,7 +147,7 @@ fn typing_inserts_into_draft() { #[test] fn enter_answers_the_harness_picker_not_the_harness_behind_it() { - use super::types::{HarnessPicker, HarnessPickerStep, WorkspaceChoice}; + use super::types::{AgentPicker, AgentPickerStep, WorkspaceChoice}; use crate::ui::harness_pane::HarnessChoice; let mut a = app(); @@ -159,13 +156,14 @@ fn enter_answers_the_harness_picker_not_the_harness_behind_it() { // it — the state the attach shortcut reads. Opening the picker on top of // that used to lose the very next Enter to the pane underneath, which // attached instead of advancing to the workspace step. - a.harness_pane_session = Some("already-running".to_string()); - a.harness_picker = Some(HarnessPicker { + a.pane_session = Some("already-running".to_string()); + a.agent_picker = Some(AgentPicker { + purpose: super::types::PickerPurpose::Spawn, choices: vec![HarnessChoice::native( medulla::protocol::HarnessProvider::Claude, )], index: 0, - step: HarnessPickerStep::Harness, + step: AgentPickerStep::Harness, cwd: ".".into(), workspace_query: String::new(), workspace_choices: Vec::new(), @@ -179,17 +177,17 @@ fn enter_answers_the_harness_picker_not_the_harness_behind_it() { let cmd = a.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(cmd.is_none()); - assert_eq!(a.attached_harness(), None, "must not attach behind a modal"); + assert_eq!(a.attached_session(), None, "must not attach behind a modal"); assert_eq!( - a.harness_picker.as_ref().map(|picker| picker.step), - Some(HarnessPickerStep::Workspace), + a.agent_picker.as_ref().map(|picker| picker.step), + Some(AgentPickerStep::Workspace), "the picker should have advanced to its workspace step" ); // This app has no local harnesses, so nothing completes the empty query. // Stand a choice in for the completion pass, which is what the workspace // step's Enter reads. - if let Some(picker) = &mut a.harness_picker { + if let Some(picker) = &mut a.agent_picker { picker.workspace_choices = vec![WorkspaceChoice { path: ".".into(), source: "recent", @@ -202,8 +200,8 @@ fn enter_answers_the_harness_picker_not_the_harness_behind_it() { assert!(cmd.is_none()); assert_eq!( - a.harness_picker.as_ref().map(|picker| picker.step), - Some(HarnessPickerStep::Decision), + a.agent_picker.as_ref().map(|picker| picker.step), + Some(AgentPickerStep::Decision), "the picker should have advanced to its decision step" ); } @@ -218,7 +216,7 @@ fn enter_on_a_harness_asks_before_taking_it() { // so — the point being that Enter is consumed by the harness path rather // than returning to the composer or submitting a turn, and that it never // attaches on its own. - a.harness_pane_session = Some("just-exited".to_string()); + a.pane_session = Some("just-exited".to_string()); let cmd = a.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -226,7 +224,7 @@ fn enter_on_a_harness_asks_before_taking_it() { assert!(a.agents_rail_focused()); assert!(a.status().contains("not hosting"), "{}", a.status()); assert!(a.handback_prompt.is_none()); - assert_eq!(a.attached_harness(), None); + assert_eq!(a.attached_session(), None); } #[test] @@ -234,16 +232,13 @@ fn d_on_a_selected_harness_opens_its_changes_tab() { let mut a = app(); a.tab_index = tab("Agents"); a.focus_agents_rail(); - a.harness_pane_session = Some("selected-harness".to_owned()); + a.pane_session = Some("selected-harness".to_owned()); let cmd = a.on_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); assert!(cmd.is_none()); assert_eq!(a.tab(), "Changes"); - assert_eq!( - a.selected_harness_session.as_deref(), - Some("selected-harness") - ); + assert_eq!(a.rail_session.as_deref(), Some("selected-harness")); assert_eq!(a.draft.text, "", "the shortcut must not type into chat"); } @@ -365,10 +360,9 @@ fn select_first_task(app: &mut App) -> Option { app.tab_index = tab("Agents"); let rows = app.rail_rows(); let idx = rows.iter().position(|r| { - matches!( - r, - super::rail::RailRow::Agent(crate::ui::agents::AgentRow::Sub { .. }) - ) + // A dispatched task is a *session* of its agent now, not a sublane of + // its lane: one row type for everything an agent is running. + matches!(r, super::rail::RailRow::Session(session) if session.task.is_some()) })?; app.agent_index = idx; app.retarget_watch() @@ -509,12 +503,7 @@ fn selecting_a_lane_rather_than_a_task_watches_nothing() { let rows = app.rail_rows(); let idx = rows .iter() - .position(|r| { - matches!( - r, - super::rail::RailRow::Agent(crate::ui::agents::AgentRow::Lane { .. }) - ) - }) + .position(|r| matches!(r, super::rail::RailRow::Agent(_))) .expect("the fixture has a lane row"); app.agent_index = idx; assert!(app.retarget_watch().is_none()); diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types.rs index e9b42ed44..112adb8ce 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types.rs @@ -61,7 +61,7 @@ pub const TABS: [&str; 6] = [ /// The Routing tab's left-nav pages. /// /// Ordered by the containment chain. `Hosts` is the machine level the operator -/// registers and steers by hand; `Harnesses` is the runtime level, which is +/// registers and steers by hand; `Harness Types` is the runtime level, which is /// where credentials live — a subscription or an API key is a property of the /// CLI runtime that spends it, not of the machine it happens to sit on; /// `Workspaces` is the folder level, which is what the orchestrator actually @@ -84,7 +84,7 @@ pub const TABS: [&str; 6] = [ /// its name back here and renumbering. pub const ROUTING_SUBPAGES: [&str; 5] = [ "Hosts", - "Harnesses", + "Harness Types", "Agent Templates", "Add Host", "Strategies", @@ -356,21 +356,21 @@ pub enum Cmd { /// The `(worker address, task id)` to start streaming, if any. start: Option<(String, String)>, }, - /// Kill the harness serving a watched task after UI confirmation. + /// Kill the session serving a watched task after UI confirmation. KillTask { - /// The worker address that owns the harness. + /// The worker address that owns the session. worker: String, - /// The dispatched task whose harness should be killed. + /// The dispatched task whose session should be killed. task_id: String, }, - /// Push a handoff brief for a harness the operator just gave back. + /// Push a handoff brief for a session the operator just gave back. /// /// Off the render thread because it does two things that must not block a /// frame: shells out to `git` for the branch, and awaits a socket emit. /// Arrives with `branch`/`project` unset — the dispatcher fills them. - HandOffHarness(Box), - /// Tell the orchestrator the operator has taken the harness in a workspace. - HoldHarness { + HandOffSession(Box), + /// Tell the orchestrator the operator has taken the session in a workspace. + HoldSession { /// The workspace being taken. workspace: String, /// Why, when the operator said. @@ -410,7 +410,7 @@ pub enum Cmd { /// Run an installed workflow on this machine. /// /// Off-thread like every other filesystem/process command: a workflow run - /// dispatches real harness sessions and takes minutes, so doing it on the + /// dispatches real agent sessions and takes minutes, so doing it on the /// render thread would freeze the app for the whole run. #[cfg(feature = "workflows")] RunWorkflow { @@ -423,7 +423,7 @@ pub enum Cmd { }, /// Ask the copilot to change or explain a workflow. /// - /// Off-thread for the same reason a run is: the turn starts a real harness + /// Off-thread for the same reason a run is: the turn starts a real agent /// session, and the pane it reports into has to keep repainting while it /// does. #[cfg(feature = "workflows")] @@ -532,7 +532,7 @@ pub(super) struct ResumePicker { /// An overlay the app can draw over the content pane. /// /// Ordered as they stack, back to front: the two that float over the content, -/// then the harness picker, then the question asked about a harness being +/// then the session picker, then the question asked about a session being /// released, and finally the two that claim a row of their own below it. /// /// Produced by [`App::visible_overlays`], which is the single source of truth @@ -543,9 +543,9 @@ pub(super) enum Overlay { Decisions, /// The agent-template detail popup. TemplatePopup, - /// The "start a harness" picker. - HarnessPicker, - /// The question asked when the operator lets go of a harness. + /// The "start a session" picker. + AgentPicker, + /// The question asked when the operator lets go of a session. HandbackPrompt, /// The shared single-line prompt (Workers add/edit, Agents answer). InlinePrompt, @@ -553,14 +553,31 @@ pub(super) enum Overlay { ResumePicker, } -/// The modal state for the "start a harness" picker overlay. -pub(super) struct HarnessPicker { +/// What the harness-type/workspace picker is being used for. +/// +/// The same two steps — pick a CLI, pick a directory — answer both questions the +/// Agents tab asks, and they differ only in what happens at the end. Declaring an +/// agent writes `harness × workspace` to the config and starts nothing; spawning +/// starts a session and declares nothing. Carrying the intent on the picker keeps +/// one overlay rather than two that would drift apart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum PickerPurpose { + /// Start a session here and now, declaring nothing — the `/session` path. + Spawn, + /// Declare an agent: `harness × workspace`, named on the step after. + DeclareAgent, +} + +/// The modal state for the harness-type/workspace picker overlay. +pub(super) struct AgentPicker { + /// What confirming the last step will do. + pub(super) purpose: PickerPurpose, /// Installed providers and registered presets, in offer order. pub(super) choices: Vec, /// The highlighted row. pub(super) index: usize, /// Which half of the two-step picker owns the keyboard. - pub(super) step: HarnessPickerStep, + pub(super) step: AgentPickerStep, /// Default directory used to seed the editable workspace query. pub(super) cwd: String, /// Inline fuzzy-completion text on the workspace step. @@ -575,16 +592,16 @@ pub(super) struct HarnessPicker { /// that offers a single completion leaves the cursor on row zero however /// deliberately it was moved there. Set by the arrows, cleared whenever the /// query changes, and read by - /// [`selected_harness_workspace`](App::selected_harness_workspace) to decide + /// [`selected_picker_workspace`](App::selected_picker_workspace) to decide /// whether an entered directory outranks the completions listed under it. pub(super) workspace_picked: bool, /// Whether to spawn managed (orchestrator can dispatch) or unmanaged. pub(super) managed: bool, } -/// Active stage of the manual harness launcher. +/// Active stage of the manual session launcher. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum HarnessPickerStep { +pub(super) enum AgentPickerStep { /// Choose an installed CLI or registered preset. Harness, /// Choose managed or unmanaged control mode. @@ -620,7 +637,7 @@ pub(super) struct PointerGrab { pub(super) button: crate::ui::harness_pane::mouse::Button, /// Where that session's pane was when the press landed. /// - /// Carried rather than re-read from `hit_harness` because the grab has to + /// Carried rather than re-read from `hit_session` because the grab has to /// outlive the pane: the click that opened a modal, detached the harness, /// or scrolled the rail can move or remove the rect before the release /// arrives, and the release still has to be encoded against the geometry @@ -628,13 +645,13 @@ pub(super) struct PointerGrab { pub(super) rect: Rect, } -/// The "you still hold this harness" confirmation shown on release. +/// The "you still hold this session" confirmation shown on release. /// /// Modelled on an unsaved-changes prompt, and for the same reason: an operator -/// who took a harness over and walked away has left the orchestrator locked out +/// who took a session over and walked away has left the orchestrator locked out /// of it, and the moment they release the keyboard is the only moment they are /// certainly thinking about it. Silently handing it back would be worse — it -/// would resume dispatch into a harness mid-thought. +/// would resume dispatch into a session mid-thought. pub(super) struct HandbackPrompt { /// The session the operator is releasing. pub(super) session: String, @@ -645,7 +662,7 @@ pub(super) struct HandbackPrompt { /// What the operator wants continued, typed into the prompt. /// /// This is the moment they actually have the context — they are leaving the - /// harness *now* — so it is the one place worth asking. `/handoff ` + /// session *now* — so it is the one place worth asking. `/handoff ` /// exists for the operator who already knows; this is for the one who is /// only reminded by being asked. pub(super) note: crate::ui::composer::Draft, @@ -656,7 +673,7 @@ pub(super) struct HandbackPrompt { /// letter answer the question for them. pub(super) editing_note: bool, /// Which direction the question is about: `true` asks whether to take the - /// harness from the orchestrator, `false` whether to hand it back. + /// session from the orchestrator, `false` whether to hand it back. /// /// One prompt for both because they are the same decision seen from either /// side, and the answer is the same keystroke — but the sentence has to say @@ -665,7 +682,7 @@ pub(super) struct HandbackPrompt { pub(super) is_takeover: bool, } -/// What to do when the operator releases a harness they hold. +/// What to do when the operator releases a session they hold. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum HandbackPolicy { /// Ask, every time. @@ -680,7 +697,7 @@ pub enum HandbackPolicy { impl HandbackPolicy { /// Parse the `[harness].handback` config value, falling back to /// [`Ask`](Self::Ask) for anything unrecognized — a typo in a config file - /// should not silently change who controls a harness. + /// should not silently change who controls a session. pub fn from_config(value: &str) -> Self { match value.trim().to_ascii_lowercase().as_str() { "always" => HandbackPolicy::Always, @@ -707,6 +724,28 @@ pub(super) enum PromptKind { HostEditLabel(String), /// Declare another directory this device may work in. WorkspaceAdd, + /// Name the agent about to be declared for this `harness × workspace`. + /// + /// Blank accepts the id [`suggest_agent_id`](medulla::runtime::suggest_agent_id) + /// minted from the directory, which is how a person refers to the agent + /// anyway — the prompt exists for the case where it is not. + AgentName { + /// The CLI the agent runs. + harness: String, + /// The absolute directory its sessions work in. + workspace: String, + }, + /// Name the session about to be opened under an already-declared agent. + /// + /// A session a person spins up is [`SessionOrigin::User`](crate::worker::pty::SessionOrigin) + /// and is the only kind that carries a name; a dispatched one is labelled + /// from its task. Blank leaves it unnamed rather than inventing one. + SessionName { + /// The agent whose harness type and workspace the session inherits. + agent_id: String, + /// Whether the orchestrator may dispatch into it — ownership at birth. + managed: bool, + }, /// Add a named OpenRouter-backed coding harness. CustomHarnessAdd, /// Edit the custom harness with the given stable id. @@ -837,7 +876,7 @@ pub struct App { pub snapshot: RuntimeSnapshot, /// The active top-level tab index (into [`TABS`]). pub tab_index: usize, - /// Git changes from the selected harness or operator-chosen commit. + /// Git changes from the selected session or operator-chosen commit. pub(super) changes: super::changes::GitChangesState, pub(super) draft: Draft, pub(super) history: Vec, @@ -877,7 +916,7 @@ pub struct App { pub(super) chat_scroll: usize, /// Selected row in the command peek, while it is open. pub(super) command_index: usize, - /// Installed harnesses offered by the Add Host wizard, detected once. + /// Installed harness types offered by the Add Host wizard, detected once. /// /// Detection reads the environment and stat-checks every provider binary on /// `PATH`. The wizard asked on every render frame *and* every keypress, so a @@ -898,7 +937,7 @@ pub struct App { pub(super) template_index: usize, /// OpenRouter-backed harness presets loaded from the active config. pub(super) custom_harnesses: Vec, - /// Selected row on the Routing Harnesses page. + /// Selected row on the Routing Harness Types page. pub(super) custom_harness_index: usize, /// Scroll offset inside the open agent-template popup. pub(super) template_scroll: usize, @@ -945,11 +984,11 @@ pub struct App { /// Which kind of host the Add Host page is offering — a cursor into /// [`AddHostKind::ALL`]. pub(super) add_host_kind: usize, - /// Which harness a new local host will run — a cursor into the detected + /// Which harness type a new local host will run — a cursor into the detected /// provider list. pub(super) add_host_harness: usize, /// Whether the kind picker has been answered, so the arrows move on to the - /// harness list rather than re-picking local versus remote. + /// harness-type list rather than re-picking local versus remote. pub(super) add_host_kind_chosen: bool, /// The active Routing subpage (index into [`ROUTING_SUBPAGES`]). pub(super) routing_index: usize, @@ -1043,12 +1082,25 @@ pub struct App { /// belongs to. A row may wrap onto several lines, so a click resolves /// through this map rather than by adding an offset to a first-row index. pub(super) hit_agents: Option<(Rect, Vec)>, - // Where the embedded harness screen landed, and whose it is. Recorded so a + // Where the embedded session screen landed, and whose it is. Recorded so a // wheel event can be routed to the terminal under the pointer and given // coordinates relative to *its* origin rather than the screen's. - pub(super) hit_harness: Option<(Rect, String)>, + pub(super) hit_session: Option<(Rect, String)>, /// The threads strip's hit box and its first visible row, for click-to-switch. pub(super) hit_threads: Option<(Rect, usize)>, + /// Where the orchestrator's conversation drew, and the task each of its + /// visible lines opens (§A7) — `None` for the lines that are transcript + /// rather than a session entry. + /// + /// One slot per drawn row rather than a dense list, because the entries are + /// interleaved with the conversation: each one sits under the turn that + /// started it, so the block is no longer contiguous and an offset from its + /// top no longer identifies an entry. + /// + /// Tasks rather than row indices: the rail is rebuilt every frame, so an + /// index recorded during the draw can name a different row by the time the + /// click lands. A task id either still has a session or does not. + pub(super) hit_started_sessions: Option<(Rect, Vec>)>, pub(super) hit_context: Option, /// The selected workflow step's preview, for pointer-wheel scrolling. pub(super) hit_workflow_preview: Option, @@ -1084,56 +1136,66 @@ pub struct App { // move on the host's own schedule, and the snapshot is the *runtime's* // picture of the world — the host is a peer to it, not part of it. pub(super) host_obs: Option, - // The live harness sessions this device is running. `None` when this machine + // The live sessions this device is running. `None` when this machine // does not host, in which case the Agents tab has no local screen to show // and falls back to a remote worker's streamed one, or to the transcript. - pub(super) harnesses: Option, - // Which of the TUI and the selected harness owns the keyboard. Reset to + pub(super) local_sessions: Option, + // Which of the TUI and the selected session owns the keyboard. Reset to // `Chrome` whenever the attached session stops being the selected one, so - // the operator's keys can never land in a harness they are not looking at. + // the operator's keys can never land in a session they are not looking at. pub(super) harness_focus: crate::ui::harness_pane::HarnessFocus, - // The harness session the Agents pane resolved on the last draw, and the + // The session the Agents pane resolved on the last draw, and the // only one the attach chord can act on. Recorded during render because that // is where the rail cursor is turned into a selection; cleared at the top of // every draw so it can never name a pane that is no longer on screen. - pub(super) harness_pane_session: Option, - // The harness that took the press of the button currently held down, if any. + pub(super) pane_session: Option, + // The session that took the press of the button currently held down, if any. // A terminal grabs the pointer for the whole gesture: whoever received the // press receives the drags and the release too, wherever the pointer has // wandered to since. Without the grab a release outside the pane — or one // swallowed by a modal the click itself opened — never reaches the child, // which goes on believing the button is still down and misplaces everything // it draws in response to the pointer afterwards. - pub(super) harness_pointer_grab: Option, + pub(super) pointer_grab: Option, // Where the hand-back question drew each of its answers, and the key each // one stands for. Recorded during the draw so a click can be answered by // replaying the keystroke rather than by a second copy of the routing: the // two would drift, and the direction they would drift in is a pointer that // hands a harness back when the operator meant to keep it. pub(super) hit_handback: Vec<(Rect, crossterm::event::KeyCode)>, - // The harness selected on the Agents rail, retained while another tab is - // visible. Unlike `harness_pane_session`, this is navigation state rather - // than a keyboard-routing capability: Changes uses it to keep following + // The agent behind a selected session row that this device does NOT run, + // recorded alongside `pane_session` on the same draw. + // + // Its only purpose is to tell "the cursor is not on a session" apart from + // "the cursor is on somebody else's session", which `pane_session` cannot: + // both leave it `None`. Taking control resolves through the local workspace + // path, so a remote session can be watched but not taken (§E7), and an + // operator who presses the take chord on one deserves that answer rather + // than "no session on this row". + pub(super) pane_remote_session: Option, + // The session selected on the Agents rail, retained while another tab is + // visible. Unlike `pane_session`, this is navigation state rather than a + // keyboard-routing capability: Changes uses it to keep following // the repository the operator selected after an intervening tab draw. - pub(super) selected_harness_session: Option, - /// The "start a harness" picker, while it is open. - pub(super) harness_picker: Option, - /// The "you still hold this harness" confirmation, while it is open. + pub(super) rail_session: Option, + /// The "start a session" picker, while it is open. + pub(super) agent_picker: Option, + /// The "you still hold this session" confirmation, while it is open. pub(super) handback_prompt: Option, /// How far the Help page is scrolled, in lines. pub(super) help_scroll: u16, - /// What releasing a held harness does, from `[harness].handback`. + /// What releasing a held session does, from `[harness].handback`. pub(super) handback_policy: HandbackPolicy, - /// Whether attaching is what took control of the current harness. + /// Whether attaching is what took control of the current session. /// /// Distinguishes "you picked this up by focusing in" from "you asked for it /// with /takecontrol", which the release prompt words differently: the /// second was a decision, and re-asking about it as though it were an /// accident is how a confirmation becomes noise. - pub(super) harness_took_control: bool, + pub(super) took_control_by_attach: bool, /// Commands raised by synchronous input handlers, drained by the event loop. /// - /// The key and mouse handlers that move harness control cannot return a + /// The key and mouse handlers that move session control cannot return a /// [`Cmd`] — `handle_handback_key` returns `()`, `handle_harness_key` /// returns `bool`, and the mouse path returns nothing — and threading an /// `Option` back through all three would be a wide, test-breaking @@ -1141,7 +1203,7 @@ pub struct App { /// it right after the event that produced it. Commands run in submission /// order. pub(super) pending_cmds: std::collections::VecDeque, - /// Whether operator-started harnesses launch with the permission-bypass + /// Whether operator-started sessions launch with the permission-bypass /// flag, from `[harness].skipPermissions`. pub(super) harness_skip_permissions: bool, } diff --git a/src/tui/src/ui/harness_pane/mod.rs b/src/tui/src/ui/harness_pane/mod.rs index d0548d36a..f64215324 100644 --- a/src/tui/src/ui/harness_pane/mod.rs +++ b/src/tui/src/ui/harness_pane/mod.rs @@ -13,7 +13,7 @@ //! shows them, and an *attached* screen lets the operator answer them. //! //! Responsibilities: -//! - [`LocalHarnesses`] — resolving "what is the cursor on" to a live session; +//! - [`LocalSessions`] — resolving "what is the cursor on" to a live session; //! - [`HarnessFocus`] — which of the TUI and the harness owns the keyboard; //! - [`keys`] — encoding a crossterm key back into the bytes a terminal sends; //! - [`spawn`] — starting a harness the orchestrator will not dispatch into, @@ -33,7 +33,7 @@ mod types; #[cfg(test)] mod tests; -pub use types::{HarnessChoice, HarnessFocus, LocalHarnesses}; +pub use types::{HarnessChoice, HarnessFocus, LocalSessions}; /// How the focus chord is written in hints and titles. /// @@ -47,7 +47,7 @@ pub use types::{HarnessChoice, HarnessFocus, LocalHarnesses}; /// terminals do not deliver this key the way it is written. pub const FOCUS_CHORD_LABEL: &str = "Ctrl-]"; -impl LocalHarnesses { +impl LocalSessions { /// The live session serving `task_id`, if one is. /// /// `None` once the task settles: the runtime drops the record then, so a diff --git a/src/tui/src/ui/harness_pane/spawn.rs b/src/tui/src/ui/harness_pane/spawn.rs index 8605dd0b5..ea9a0f80c 100644 --- a/src/tui/src/ui/harness_pane/spawn.rs +++ b/src/tui/src/ui/harness_pane/spawn.rs @@ -9,25 +9,25 @@ //! //! [`claim_idle`]: crate::worker::pty::PtyManager::claim_idle -use crate::worker::pty::{HarnessControl, LaunchSpec, SessionOrigin}; +use crate::worker::pty::{LaunchSpec, SessionControl, SessionOrigin}; -use super::{HarnessChoice, LocalHarnesses}; +use super::{HarnessChoice, LocalSessions}; -impl LocalHarnesses { +impl LocalSessions { /// Who currently holds `session_id`. - pub fn control(&self, session_id: &str) -> Option { + pub fn control(&self, session_id: &str) -> Option { self.sessions.control(session_id) } /// Hand `session_id` to `control`; `false` when no such session exists. - pub fn set_control(&self, session_id: &str, control: HarnessControl) -> bool { + pub fn set_control(&self, session_id: &str, control: SessionControl) -> bool { self.sessions.set_control(session_id, control) } /// Start a harness the operator owns, returning its session id. /// /// `cwd` is where the child runs; an empty string means the host's - /// workspace. The session opens [`HarnessControl::User`]-held, which is the + /// workspace. The session opens [`SessionControl::User`]-held, which is the /// whole of "unmanaged" — dispatch skips it until it is handed over. /// /// # Errors @@ -86,6 +86,11 @@ impl LocalHarnesses { self.sessions.open(LaunchSpec { provider, + // The preset is the agent, not the CLI under it: a declaration for a + // preset records the preset's id, so a session that recorded only + // `claude` could never be matched back to the agent that declared + // it and was listed as belonging to none. + preset: choice.preset.as_ref().map(|preset| preset.id.clone()), bin, cwd, env, @@ -98,7 +103,7 @@ impl LocalHarnesses { label: format!("you:{}", choice.id()), model, session_id: None, - control: HarnessControl::User, + control: SessionControl::User, // A person asked for this one, so it is theirs by origin as well as // by control — and it stays user-originated even after they hand it // to the orchestrator, which is the case the two fields exist to diff --git a/src/tui/src/ui/harness_pane/tests/mod.rs b/src/tui/src/ui/harness_pane/tests/mod.rs index acd145c2b..d318a5635 100644 --- a/src/tui/src/ui/harness_pane/tests/mod.rs +++ b/src/tui/src/ui/harness_pane/tests/mod.rs @@ -2,7 +2,7 @@ //! repo's 500-line ceiling: this module covers key encoding, mouse-wheel //! encoding, and focus; [`buttons`] covers click/drag/release encoding and the //! per-mode gate on it; [`session`] drives a real child on a real -//! pseudo-terminal to cover the session-facing half of [`super::LocalHarnesses`]. +//! pseudo-terminal to cover the session-facing half of [`super::LocalSessions`]. //! //! The encoder is where a mistake is invisible until an operator is sitting in //! front of a harness that ignores their arrow keys, so every family it emits is diff --git a/src/tui/src/ui/harness_pane/tests/origin.rs b/src/tui/src/ui/harness_pane/tests/origin.rs index 5d29f0a00..8b10e7ca4 100644 --- a/src/tui/src/ui/harness_pane/tests/origin.rs +++ b/src/tui/src/ui/harness_pane/tests/origin.rs @@ -12,13 +12,13 @@ use std::collections::HashMap; use medulla::protocol::HarnessProvider; -use crate::worker::pty::{HarnessControl, PtyManager}; +use crate::worker::pty::{PtyManager, SessionControl}; use super::session::harnesses; -/// A [`LocalHarnesses`](super::super::LocalHarnesses) whose "codex" is +/// A [`LocalSessions`](super::super::LocalSessions) whose "codex" is /// `/bin/sh`, so opening one starts a real pty client and nothing else. -fn shell_harnesses(sessions: PtyManager) -> super::super::LocalHarnesses { +fn shell_harnesses(sessions: PtyManager) -> super::super::LocalSessions { let mut harnesses = harnesses(sessions); let mut env = HashMap::new(); if let Ok(path) = std::env::var("PATH") { @@ -31,7 +31,7 @@ fn shell_harnesses(sessions: PtyManager) -> super::super::LocalHarnesses { } /// The picker's codex entry. -fn codex(harnesses: &super::super::LocalHarnesses) -> super::super::HarnessChoice { +fn codex(harnesses: &super::super::LocalSessions) -> super::super::HarnessChoice { harnesses .choices() .into_iter() @@ -51,7 +51,7 @@ fn a_session_the_operator_opens_is_user_originated() { let row = sessions.row(&id).expect("the session exists"); assert!(row.origin.is_user(), "a person asked for this one"); - assert_eq!(row.control, HarnessControl::User, "and holds it"); + assert_eq!(row.control, SessionControl::User, "and holds it"); assert_eq!(row.name, None, "the picker has no name prompt yet"); sessions.close(&id); diff --git a/src/tui/src/ui/harness_pane/tests/session.rs b/src/tui/src/ui/harness_pane/tests/session.rs index c218787c1..32747ddda 100644 --- a/src/tui/src/ui/harness_pane/tests/session.rs +++ b/src/tui/src/ui/harness_pane/tests/session.rs @@ -1,4 +1,4 @@ -//! Tests for the session-facing half of [`LocalHarnesses`], against a real +//! Tests for the session-facing half of [`LocalSessions`], against a real //! child on a real pseudo-terminal. //! //! `/bin/sh` stands in for a coding agent: it is a genuine pty client with a @@ -14,10 +14,10 @@ use std::time::{Duration, Instant}; use medulla::protocol::HarnessProvider; -use crate::worker::pty::{HarnessControl, LaunchSpec, PtyManager}; +use crate::worker::pty::{LaunchSpec, PtyManager, SessionControl}; use super::super::HarnessChoice; -use super::super::LocalHarnesses; +use super::super::LocalSessions; /// A spec that runs `sh -c