Skip to content
Draft
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
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,25 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased - Patch]
## [Unreleased - Minor]

### Added

- `agent-relay workspace restore` returns to the recorded previous workspace, while `workspace rebind <name>` explicitly pins a project's next broker start without changing the machine-global active workspace.

### Changed

- `workspace create` warns on stderr when it changes the active workspace and records the prior name; named switches now record the same restore point.
- `agent-relay node status` reports whether the broker workspace came from a command-line flag, environment variable, repository pin, machine-global active workspace, or first-run creation.

### Fixed

- `agent-relay node up` resolves its installed broker through canonical package-manager links and Relay's user install directories, so mise-managed and minimal-`PATH` launches no longer fail when the broker binary is already installed.
- `agent-relay up` / `node up` use one precedence ladder: `--workspace-key` → workspace environment variables → repository pin → machine-global active workspace → creating one. A fresh project joins the active workspace instead of silently creating another, and startup announces the winning source.
- Enrolled-node restarts preserve the repository-pinned workspace while resuming the enrolled identity. A conflicting enrollment stops startup, names both non-secret sources, and points to `workspace rebind <name>` as the recovery path.
- `agent-relay node agent spawn` and fleet `spawn:<harness>` actions now verify that the worker process survives startup before reporting success, and report its exit status and log path when launch fails.
- First-run telemetry notices are written to stderr so JSON stdout remains parseable.
- Detached `node up --background` surfaces early child failures and stops polling when the child exits without trying to kill an already dead process.

## [11.4.1] - 2026-08-03

Expand Down
106 changes: 92 additions & 14 deletions crates/broker/src/runtime/fleet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ impl BrokerRuntime {
let action_control_dedup_key =
relaycast_spawn_control_dedup_key(workspace_id.as_str(), name.as_str());

super::relaycast_events::spawn_worker_from_request(
let spawn_result = super::relaycast_events::spawn_worker_from_request(
name.clone(),
cli,
task,
Expand Down Expand Up @@ -452,19 +452,12 @@ impl BrokerRuntime {
.await;

self.publish_fleet_load(true).await;

// `spawn_worker_from_request` does not return a result; treat presence of
// the worker as success so the engine's invocation resolves.
if self.workers.workers.contains_key(&name) {
self.reply_action_output(
&invoke.invocation_id,
json!({ "spawned": true, "name": name.as_str() }),
)
.await;
} else {
self.reply_action_error(&invoke.invocation_id, "spawn_failed")
.await;
}
self.send_fleet_action_result(fleet_spawn_action_result(
&invoke.invocation_id,
&name,
spawn_result,
))
.await;
}

/// Run a `release` node action, routing by the invoke's agent_name (then
Expand Down Expand Up @@ -575,6 +568,27 @@ impl BrokerRuntime {
}
}

fn fleet_spawn_action_result(
invocation_id: &str,
name: &WorkerName,
spawn_result: Result<()>,
) -> ActionResult {
let result = match spawn_result {
Ok(()) => ActionResultPayload::Output(ActionResultOutput {
output: json!({ "spawned": true, "name": name.as_str() }),
}),
Err(error) => ActionResultPayload::Error(ActionResultError {
error: format!("spawn_failed: {error}"),
}),
};
ActionResult {
v: FLEET_WIRE_VERSION,
id: None,
invocation_id: invocation_id.to_string(),
result,
}
}

#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct FlushPendingRelayResult {
pub(super) flushed: usize,
Expand Down Expand Up @@ -1147,6 +1161,70 @@ mod tests {
use super::*;
use crate::protocol::PtyHarnessConfig;

#[cfg(unix)]
#[tokio::test]
async fn fleet_spawn_result_uses_verified_failure_not_registry_presence() {
let temp = tempfile::tempdir().expect("test tempdir");
let (event_tx, _event_rx) = mpsc::channel::<WorkerEvent>(4);
let mut workers = WorkerRegistry::new(
event_tx,
Vec::new(),
temp.path().join("worker-logs"),
Instant::now(),
);
let mut child = tokio::process::Command::new("sh")
.args(["-c", "exit 19"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("repro child should spawn");
let stdin = child.stdin.take().expect("repro child stdin");
child.wait().await.expect("repro child should exit");
let name = WorkerName::from("fleet-spawn-repro-1430");
let mut spec = test_agent_spec(None, None);
spec.name = name.clone();
workers.workers.insert(
name.clone(),
WorkerHandle {
spec,
parent: Some("Relaycast".to_string()),
workspace_id: None,
child,
stdin,
harness_pid: None,
spawned_at: Instant::now(),
ready_at: None,
last_activity_at: Instant::now(),
context_budget_pct: None,
state: crate::worker::AgentWorkState::Working,
exit_reason: None,
},
);

assert!(!workers.is_worker_live(&name));
assert!(workers.workers.contains_key(&name));

let result = fleet_spawn_action_result(
"inv-failed-1430",
&name,
Err(anyhow::anyhow!(
"agent '{name}' process exited during startup (exit status: 19); see worker log /tmp/{name}.log"
)),
);

let ActionResultPayload::Error(error) = result.result else {
panic!("a verified spawn failure must not produce spawned:true");
};
assert_eq!(result.invocation_id, "inv-failed-1430");
assert_eq!(
error.error,
format!(
"spawn_failed: agent '{name}' process exited during startup (exit status: 19); see worker log /tmp/{name}.log"
)
);
}

fn test_agent_spec(session_id: Option<&str>, harness_session_id: Option<&str>) -> AgentSpec {
AgentSpec {
name: WorkerName::from("agent-a"),
Expand Down
110 changes: 106 additions & 4 deletions crates/broker/src/runtime/relaycast_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ pub(super) async fn release_worker_locally(
/// engine-dispatched spawn exits after its task identically to a local HTTP
/// spawn. `control_dedup_key` carries the firehose control dedup key so the
/// local spawn-echo dedup behaves identically.
/// Returns only after `WorkerRegistry::spawn` has completed its process
/// stability probe, preserving the detailed launch error for the action result.
#[allow(clippy::too_many_arguments)]
pub(super) async fn spawn_worker_from_request(
name: WorkerName,
Expand All @@ -343,7 +345,7 @@ pub(super) async fn spawn_worker_from_request(
session_ref: Option<String>,
hosted_agent_event_tx: &mpsc::Sender<HostedAgentEvent>,
pty_observability: &mut HashMap<WorkerName, PtyObservabilityState>,
) {
) -> Result<()> {
let workspace_http = &workspace_state.http_client;
eprintln!(
"[agent-relay] received spawn request for '{}' (cli: {})",
Expand All @@ -362,7 +364,7 @@ pub(super) async fn spawn_worker_from_request(
"[agent-relay] ignoring spawn request for '{}' (broker self)",
name
);
return;
anyhow::bail!("agent '{name}' is the broker self");
}
let local_spawn_echo_key = relaycast_spawn_control_dedup_key(workspace_id, &name);
if relaycast_ws_should_apply_local_spawn_echo_dedup(control_dedup_key, &local_spawn_echo_key)
Expand All @@ -377,7 +379,7 @@ pub(super) async fn spawn_worker_from_request(
"[agent-relay] dropping duplicate spawn request for '{}'",
name
);
return;
anyhow::bail!("duplicate spawn request for agent '{name}'");
}
let task = task.filter(|value| !value.trim().is_empty());
// Carry the requested model through so the launched CLI is
Expand All @@ -396,7 +398,7 @@ pub(super) async fn spawn_worker_from_request(
"[agent-relay] rejecting spawn request for '{}': {}",
name, error
);
return;
return Err(anyhow::anyhow!(error));
}
};
let runtime = harness_config
Expand Down Expand Up @@ -650,6 +652,7 @@ pub(super) async fn spawn_worker_from_request(
.await;
tracing::info!(child = %name, pid = ?pid, "spawned worker via relaycast WS");
eprintln!("[agent-relay] spawned worker '{}' via relaycast", name);
Ok(())
}
Err(e) => {
let msg = e.to_string();
Expand All @@ -659,6 +662,7 @@ pub(super) async fn spawn_worker_from_request(
tracing::error!(child = %name, error = %e, "failed to spawn worker via relaycast WS");
eprintln!("[agent-relay] failed to spawn '{}': {}", name, e);
}
Err(e)
}
}
}
Expand All @@ -668,6 +672,104 @@ mod tests {
use super::*;
use ::relaycast::WsEvent;

#[cfg(unix)]
#[tokio::test]
async fn spawn_request_returns_the_verified_process_failure() {
let temp = tempfile::tempdir().expect("test tempdir");
let (worker_event_tx, _worker_event_rx) = mpsc::channel::<WorkerEvent>(4);
let mut workers = WorkerRegistry::new(
worker_event_tx,
Vec::new(),
temp.path().join("worker-logs"),
Instant::now(),
);
let workspace_id = WorkspaceId::from("ws_test_1430".to_string());
let (ws_control_tx, _ws_control_rx) = mpsc::channel::<WsControl>(4);
let workspace = RelayWorkspace {
workspace_id: workspace_id.clone(),
workspace_alias: None,
relay_workspace_key: "rk_live_test".to_string(),
self_name: "broker".to_string(),
self_agent_id: AgentId::from("agent_broker".to_string()),
self_names: HashSet::from(["broker".to_string()]),
self_agent_ids: HashSet::from([AgentId::from("agent_broker".to_string())]),
http_client: RelaycastHttpClient::new(
Some("http://127.0.0.1:9".to_string()),
"rk_live_test",
"broker",
"codex",
),
ws_control_tx,
};
let paths = ensure_ephemeral_paths(temp.path(), "fleet-spawn-1430")
.expect("ephemeral runtime paths");
let mut state = broker::BrokerState::default();
let telemetry = TelemetryClient::default();
let (sdk_out_tx, _sdk_out_rx) = mpsc::channel(4);
let mut dedup = DedupCache::new(Duration::from_secs(60), 16);
let mut agent_spawn_count = 0;
let (fleet_control_tx, _fleet_control_rx) = mpsc::channel(4);
let mut fleet_delivery_book = FleetDeliveryBook::default();
let (hosted_agent_event_tx, _hosted_agent_event_rx) = mpsc::channel(4);
let mut pty_observability = HashMap::new();
let name = WorkerName::from("failed-native-worker-1430");
let ws_value = json!({
"token": "at_live_test_worker",
"agent": {
"harnessConfig": {
"runtime": "native",
"command": "sh",
"args": ["-c", "sleep 0.05; exit 23"],
"sessionId": "native-failed-1430"
}
}
});
let control_key = relaycast_spawn_control_dedup_key(&workspace_id, &name);

let error = spawn_worker_from_request(
name.clone(),
"codex".to_string(),
None,
None,
None,
false,
&ws_value,
&workspace_id,
Some(&control_key),
&workspace,
&mut workers,
&mut state,
&paths,
&telemetry,
&sdk_out_tx,
&mut dedup,
&mut agent_spawn_count,
&fleet_control_tx,
&mut fleet_delivery_book,
"test-node",
Some("inv-failed-1430".to_string()),
None,
&hosted_agent_event_tx,
&mut pty_observability,
)
.await
.expect_err("a sidecar that exits during the stability window must fail the spawn");

let message = error.to_string();
assert!(
message.contains("process exited during startup"),
"{message}"
);
assert!(message.contains("exit status: 23"), "{message}");
assert!(
message.contains("failed-native-worker-1430.log"),
"{message}"
);
assert!(!workers.has_worker(&name));
assert_eq!(agent_spawn_count, 0);
assert!(!state.agents.contains_key(&name));
}

#[test]
fn relaycast_harness_config_accepts_inline_config() {
let value = json!({
Expand Down
Loading
Loading