Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 5 additions & 16 deletions src/tui/src/app_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,9 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> {
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.
let local_host_spawner = hosting
// Only meaningful while this device hosts: with hosting off there are no
// host options to read declared harnesses from.
let local_host_harnesses = hosting
.then(|| {
crate::local_host::options_from_config_with_custom_and_hooks(
&loaded.config.host,
Expand All @@ -566,18 +566,7 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> {
},
)
.ok()
.map(|options| {
crate::local_host::LocalHostSpawner::new(
local_network.clone(),
harness_sessions.clone(),
options,
env.clone(),
host_runtimes.clone(),
started_hosts.clone(),
declared_local_hosts.clone(),
loaded.config.fleet.agent_declarations.clone(),
)
})
.map(crate::local_host::LocalHostHarnesses::new)
})
.flatten();
let local_dispatch = crate::hub_relay::LocalDispatch {
Expand Down Expand Up @@ -665,7 +654,7 @@ pub(crate) async fn run_tui(raw: &[String]) -> anyhow::Result<()> {
&mut terminal,
runtime.clone(),
SessionWiring {
local_hosts: local_host_spawner.clone(),
local_hosts: local_host_harnesses.clone(),
loaded: loaded.clone(),
startup_status: status.take(),
link_obs: link_obs.clone(),
Expand Down
84 changes: 29 additions & 55 deletions src/tui/src/event_loop/cmd_dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub(super) fn run_cmd(
runtime: &Arc<dyn Runtime>,
_workflows_config: &medulla::config::WorkflowsConfig,
msg_tx: &tokio::sync::mpsc::UnboundedSender<AppMsg>,
local_hosts: Option<&crate::local_host::LocalHostSpawner>,
local_hosts: Option<&crate::local_host::LocalHostHarnesses>,
) {
let cmd = match feedback::run_feedback_cmd(cmd, runtime, msg_tx) {
Some(cmd) => *cmd,
Expand Down Expand Up @@ -191,70 +191,44 @@ pub(super) fn run_cmd(
let _ = tx.send(AppMsg::Status(status));
});
}
Cmd::StartLocalHost { host, index } => {
let Some(spawner) = local_hosts.cloned() else {
let _ = msg_tx.send(AppMsg::Status(
"This device is not hosting, so a local host cannot start here".to_string(),
));
return;
};
Cmd::WorkerOp(op) => {
let rt = runtime.clone();
let tx = msg_tx.clone();
tokio::spawn(async move {
// Start it first, register it second. A roster entry whose
// address nothing answers on is the failure this whole feature
// exists to avoid — the orchestrator would dispatch to it and
// the task would vanish.
let specs = match spawner.spawn(&host, index) {
Ok(specs) => specs,
Err(error) => {
let _ =
tx.send(AppMsg::Status(format!("Local host did not start: {error}")));
return;
}
};
// The host's first declared agent. The registry op below is
// keyed by address and replaces any entry sharing one, so
// registering the siblings here would leave exactly one anyway —
// a host added mid-run advertises its default agent until the
// add path is agent-keyed rather than address-keyed. Every agent
// is advertised on the next launch, where the roster is built
// from the declarations directly.
let Some(spec) = specs.into_iter().next() else {
let _ = tx.send(AppMsg::Status(
"Local host started, but declares no agent".to_string(),
));
return;
};
let workspace = spec
.workspace
.as_ref()
.map(|workspace| workspace.path.clone())
.unwrap_or_default();
// Registered through the same op a remote add uses, so both
// kinds reach the roster by one path.
let status = match rt
.worker_op(medulla::runtime::WorkerOp::Add {
address: Some(spec.address.clone()),
handle: None,
label: Some(spec.name.clone()),
harness: Some(spec.harness.clone()),
})
.await
{
Ok(()) => format!("Local host running · {workspace}"),
Err(e) => format!("Started, but not registered: {e}"),
let status = match rt.worker_op(op).await {
Ok(()) => "Worker registry updated".to_string(),
Err(e) => e.to_string(),
};
let _ = tx.send(AppMsg::Status(status));
});
}
Cmd::WorkerOp(op) => {
Cmd::WorkerOps(ops) => {
let rt = runtime.clone();
let tx = msg_tx.clone();
tokio::spawn(async move {
let status = match rt.worker_op(op).await {
Ok(()) => "Worker registry updated".to_string(),
Err(e) => e.to_string(),
let total = ops.len();
let mut applied = 0usize;
let mut failure = None;
for op in ops {
// Stop at the first failure rather than pressing on. The
// ops are one operator action, and continuing past a
// refusal would half-remove a host — some agents gone, the
// rest still routed to — which is the state hardest to
// reason about from the screen.
match rt.worker_op(op).await {
Ok(()) => applied += 1,
Err(e) => {
failure = Some(e.to_string());
break;
}
}
}
let status = match failure {
None => "Worker registry updated".to_string(),
Some(e) if applied == 0 => e,
// Says what landed as well as what stopped it: the operator
// is looking at a list that is now partly changed.
Some(e) => format!("Removed {applied} of {total}, then: {e}"),
};
let _ = tx.send(AppMsg::Status(status));
});
Expand Down
2 changes: 1 addition & 1 deletion src/tui/src/event_loop/cmd_dispatch/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async fn run(
model: (!workflows_config.default_model.is_empty())
.then(|| workflows_config.default_model.clone()),
// The same presets this session's primary host advertises (see
// `LocalHostSpawner::custom_harnesses`), so an `agent` step naming a
// `LocalHostHarnesses::custom_harnesses`), so an `agent` step naming a
// custom harness preset does not fail with "not configured on this
// host" purely because this one-shot daemon started with none.
custom_harnesses: custom_harnesses.to_vec(),
Expand Down
8 changes: 4 additions & 4 deletions src/tui/src/event_loop/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@ pub(crate) enum SessionExit {
pub(crate) struct SessionWiring {
/// The loaded configuration for this session.
pub loaded: medulla::config::LoadedConfig,
/// Starts a host on this device after launch. `None` when this device is
/// not hosting — there is then no bus binding or session manager to hand a
/// new host, and the command says so rather than half-starting one.
pub local_hosts: Option<crate::local_host::LocalHostSpawner>,
/// The custom harnesses this device's primary host declares, for a
/// workflow `agent` step to resolve a harness name against. `None` when this
/// device is not hosting — there are then no host options to read them from.
pub local_hosts: Option<crate::local_host::LocalHostHarnesses>,
/// A note to show on the status line at startup, if any.
pub startup_status: Option<String>,
/// The tiny.place presence observation, when that service is running.
Expand Down
4 changes: 2 additions & 2 deletions src/tui/src/hub_relay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,8 @@ fn a_roster_remembered_from_a_hosting_run_is_dropped_when_hosting_is_off() {
#[test]
fn a_host_added_after_launch_is_not_remembered_as_a_remote_peer() {
// The sink filters at *save* time, so a launch-time snapshot of the local
// addresses did not know about a host started mid-session through
// `LocalHostSpawner`. Its device-local entry was written into the saved
// addresses did not know about a host that joined the local list after that
// snapshot was taken. Its device-local entry was written into the saved
// roster and would be advertised on a later run at an address nothing binds.
Comment thread
sanil-23 marked this conversation as resolved.
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path();
Expand Down
118 changes: 13 additions & 105 deletions src/tui/src/local_host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,117 +471,25 @@ pub(crate) fn start(
.map(Some)
}

/// Starts a host on this device after the app is already running.
/// The custom harnesses this device's hosting configuration declares.
///
/// Everything a host needs to exist — the in-process bus, the session manager,
/// the daemon options — is built once at launch and owned by the app loop. A
/// host declared later has no way to reach any of it, which is why adding one
/// used to mean restarting. This carries exactly those pieces to wherever the
/// command is handled.
/// What is left of a larger type. It used to start a host on this device after
/// launch, for the Add Host wizard's "local" kind — a harness plus a directory,
/// which is what an *agent* is now, declared from the host tree instead. Only
/// one reader outlived that: a workflow's `agent` step may name a custom
/// harness, and it resolves the name against this list.
///
/// Cheap to clone: every field is already shared.
/// Cheap to clone: the options behind it are already shared.
#[derive(Clone)]
pub(crate) struct LocalHostSpawner {
/// The bus the hub dispatches over.
network: LocalBridgeNetwork,
/// The session manager the UI reads screens from and types into. Shared, so
/// a host started now is as watchable as one started at launch.
sessions: PtyManager,
/// The primary's options, used as the template every extra inherits.
pub(crate) struct LocalHostHarnesses {
/// The primary host's options, which carry the declared custom harnesses.
options: EmbeddedDaemonOptions,
/// The process environment, for provider detection and the host switch.
env: HashMap<String, String>,
/// The runtimes the harness pane resolves tasks against. A new host's
/// runtime is pushed here or its screen would never be found.
runtimes: std::sync::Arc<std::sync::Mutex<Vec<medulla::daemon::DaemonRuntime>>>,
/// The started hosts, kept alive for the session. Dropping a `LocalHost`
/// stops it, so a spawner that did not hold them would start a host and
/// immediately kill it.
started: std::sync::Arc<std::sync::Mutex<Vec<LocalHost>>>,
/// The agent declarations a newly started host reads its roster entries
/// from. Carried rather than re-read so a host started now and one started
/// at launch are built from the same list.
declared: Vec<AgentDeclaration>,
/// 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 {
/// Build a spawner over the pieces the app loop owns.
///
/// Long by construction: every argument is a distinct piece of process-wide
/// state the app loop owns and a host started later has no other way to
/// reach. Grouping them into a struct would only rename the same list.
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
network: LocalBridgeNetwork,
sessions: PtyManager,
options: EmbeddedDaemonOptions,
env: HashMap<String, String>,
runtimes: std::sync::Arc<std::sync::Mutex<Vec<medulla::daemon::DaemonRuntime>>>,
started: std::sync::Arc<std::sync::Mutex<Vec<LocalHost>>>,
local_hosts: medulla::hub::SharedLocalHosts,
declared: Vec<AgentDeclaration>,
) -> Self {
Self {
network,
sessions,
options,
env,
runtimes,
started,
local_hosts,
declared,
}
}

/// Start `config` now and return the roster entries describing its declared
/// agents — one per agent, so a host is registered as everything it runs
/// rather than as one entry standing in for all of them.
///
/// `index` is the entry's position within `[[hosts]]`, which is the basis
/// [`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 —
/// an address the roster remembered that nothing would ever bind again.
pub(crate) fn spawn(
&self,
config: &HostSection,
index: usize,
) -> Result<Vec<WorkerSpec>, String> {
let host = start_at(
config,
&self.env,
&self.network,
extra_options(&self.options, config)?,
self.sessions.clone(),
extra_host_address(config, index),
false,
&self.declared,
)?;
let specs = host.specs().to_vec();
// 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("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")
.push(host.runtime());
self.started.lock().expect("started hosts").push(host);
Ok(specs)
impl LocalHostHarnesses {
/// Hold a host's options for the sake of the harnesses they declare.
pub(crate) fn new(options: EmbeddedDaemonOptions) -> Self {
Self { options }
}

/// The custom-harness presets this device's primary host was started with.
Expand Down
1 change: 0 additions & 1 deletion src/tui/src/ui/app/commands/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ impl App {
self.save_custom_harness(Some(&id), &text);
None
}
PromptKind::LocalHostWorkspace(harness) => self.add_local_host(harness, &text),
PromptKind::RejectProposal {
workflow,
proposal_id,
Expand Down
Loading
Loading