Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8d96e21
feat(cli): add Cloud multiplayer room commands
Jul 23, 2026
11df645
feat(cli): add cloud integration access
Jul 23, 2026
96cca10
style: auto-format with Prettier
github-actions[bot] Jul 23, 2026
48572b3
fix(cli): preserve refreshed auth for cleanup
Jul 23, 2026
f9f7451
fix(cli): expose integration catalog globally
Jul 23, 2026
255554d
test(fleet): reserve the broker API port
Jul 23, 2026
13c8685
fix(cli): scope credentials and fleet ports
Jul 23, 2026
ce8520c
fix(cli): clean up failed broker startups
Jul 23, 2026
71b38f7
fix(node): allocate fleet ports atomically
Jul 24, 2026
66fb0bd
refactor(cloud): simplify rooms to trusted participants
Jul 24, 2026
b95c855
fix(fleet): resume requested CLI sessions
Jul 24, 2026
3fa9b60
fix(fleet): harden Codex resume parsing
Jul 24, 2026
f3aeb29
fix(fleet): order Codex resume before variadic options
Jul 24, 2026
4ffc103
fix(fleet): distinguish Codex image resume forms
Jul 24, 2026
458cf1c
fix(fleet): advertise handlers before first spawn
Jul 24, 2026
a75d541
test(fleet): await action provider registration
Jul 24, 2026
1d8e493
fix(fleet): carry resume session into worker
Jul 24, 2026
232a278
fix(fleet): allow immediate agent respawn
Jul 24, 2026
aa4cac0
refactor(cli): use generic Relay Room invite tokens
Jul 24, 2026
4ccd206
style: auto-format with Prettier
github-actions[bot] Jul 24, 2026
9689e64
docs(cli): document Relay Room invite tokens
Jul 24, 2026
072097d
Merge remote-tracking branch 'origin/main' into feat/herdr-cloud-room…
Jul 25, 2026
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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- `agent-relay cloud room` can invite trusted full room participants through explicit secret sinks, manage members, and establish revocable per-device Relaycast sessions without sharing the workspace key.
- `agent-relay cloud integration` exposes the existing Cloud integration catalog, connection, and disconnection lifecycle from the CLI; connected providers remain available through Relayfile's normal setup, mount, and writeback flow.
- `agent-relay agent me|presence` use scoped agent credentials for room-safe identity and presence checks.

### Fixed

- Codex PTY workers now receive initial Relay tasks in one bulk write, preventing full-screen input redraws from delaying task submission for minutes.
- `agent-relay node up` now binds an OS-assigned API port atomically by default, preventing concurrent Fleet nodes from racing over a probed port; `AGENT_RELAY_BROKER_PORT` remains an explicit stable-port override.
- Newly connected Fleet brokers now advertise their spawn/release handlers immediately, so the first remote spawn is dispatched instead of remaining queued until load changes.
- `agent-relay fleet spawn --session-ref` now passes the requested session to Claude and Codex as a real resume operation, and a released agent name can be reused immediately instead of being suppressed as a duplicate spawn.

## [11.1.1] - 2026-07-23

Expand Down
2 changes: 1 addition & 1 deletion crates/broker/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ pub(crate) struct InitCommand {
#[arg(long, default_value = "general")]
pub(crate) channels: String,

/// Optional HTTP API port for dashboard proxy (0 = disabled)
/// Optional HTTP API port for dashboard proxy (0 = atomically OS-assigned).
#[arg(long, default_value = "0")]
pub(crate) api_port: u16,

Expand Down
17 changes: 16 additions & 1 deletion crates/broker/src/node_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,12 @@ fn handle_disconnected_command(
resume_cursor,
}) => {
load.max_agents = manifest.max_agents.unwrap_or(load.max_agents);
// This control client is the broker provider, which owns the
// node's spawn/release capacity as soon as its socket connects.
// A fresh node has no workers yet, so no load transition would
// otherwise publish the first `handlers_live=true` snapshot and
// the engine would queue the very first spawn indefinitely.
load.handlers_live = true;
*registration = Some(build_node_register(
&manifest,
&config.node_id,
Expand Down Expand Up @@ -1543,6 +1549,7 @@ async fn run_connected_once(
match command {
Some(FleetControlCommand::RegisterNode { manifest, resume_cursor }) => {
load.max_agents = manifest.max_agents.unwrap_or(load.max_agents);
load.handlers_live = true;
let mut next = build_node_register(&manifest, &config.node_id, &config.node_name, &config.broker_version, resume_cursor);
next.provider = Some(provider.clone());
node_register = next.clone();
Expand Down Expand Up @@ -2830,7 +2837,15 @@ mod tests {
let register = next_node_to_server(&mut ws).await;
assert!(matches!(register, BrokerToRelaycast::NodeRegister(_)));
let heartbeat = next_node_to_server(&mut ws).await;
assert!(matches!(heartbeat, BrokerToRelaycast::NodeHeartbeat(_)));
match heartbeat {
BrokerToRelaycast::NodeHeartbeat(heartbeat) => {
assert!(
heartbeat.handlers_live,
"the broker provider must advertise capacity before the first spawn"
);
}
other => panic!("expected initial node heartbeat, got {other:?}"),
}

ws.send(Message::Text(
serde_json::to_string(&RelaycastToBroker::Deliver(Deliver {
Expand Down
115 changes: 107 additions & 8 deletions crates/broker/src/runtime/fleet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,13 @@ impl BrokerRuntime {
// not correlated to the agent and a resumable `spawn:<harness>` (when
// `harnessConfig.session_id` is set) silently becomes a fresh spawn.
let session_ref = super::relaycast_events::relaycast_spawn_session_ref(&ws_value);
// `action.invoke` is the authoritative control request, not a
// workspace-firehose echo of a local spawn. Mark it with the same
// control key the echo guard derives so a later release + respawn of
// the same agent name is not suppressed by the five-minute
// name-scoped echo cache.
let action_control_dedup_key =
relaycast_spawn_control_dedup_key(workspace_id.as_str(), name.as_str());

super::relaycast_events::spawn_worker_from_request(
name.clone(),
Expand All @@ -425,7 +432,7 @@ impl BrokerRuntime {
exit_after_task,
&ws_value,
&workspace_id,
None,
Some(&action_control_dedup_key),
&workspace_state,
&mut self.workers,
&mut self.state,
Expand Down Expand Up @@ -1408,6 +1415,21 @@ mod tests {
.expect("valid explicit flag"));
}

#[test]
fn action_invoke_spawn_control_key_allows_immediate_name_reuse() {
let local_key = relaycast_spawn_control_dedup_key("ws_1", "worker-a");

// Each node action is already correlated by its invocation id. Passing
// the matching control key tells the legacy firehose echo guard not to
// consume or reject the reusable worker name.
for _ in 0..2 {
assert!(!relaycast_ws_should_apply_local_spawn_echo_dedup(
Some(local_key.as_str()),
&local_key,
));
}
}

#[test]
fn fleet_initial_session_ref_prefers_explicit_spec_session() {
let spec = test_agent_spec(Some("session-spec"), Some("session-harness"));
Expand All @@ -1425,25 +1447,24 @@ mod tests {

#[tokio::test]
async fn action_invoke_spawn_seeds_authoritative_cursor_before_resumed_delivery() {
// An `action.invoke` spawn carrying `harnessConfig.session_id` must
// forward a non-None session_ref (and the invocation id) into the node
// `agent.register` it emits, so the spawn resumes the session and the
// invocation is correlated to the agent (Bug 2). Previously both were
// hardcoded to None on this path.
// The Fleet CLI sends `session_ref` at the top level. It must be
// forwarded with the invocation id into the node `agent.register`, so
// the spawn resumes the session and the invocation is correlated to
// the agent.
let ws_value = json!({
"session_ref": "sess-resume-7",
"agent": {
"harnessConfig": {
"runtime": "pty",
"command": "codex",
"sessionId": "sess-resume-7",
}
}
});
let session_ref = super::super::relaycast_events::relaycast_spawn_session_ref(&ws_value);
assert_eq!(
session_ref.as_deref(),
Some("sess-resume-7"),
"session ref must be derived from harnessConfig.session_id"
"session ref must be derived from the action input"
);

// Drive the exact registration step the spawn path uses and capture the
Expand Down Expand Up @@ -1659,6 +1680,84 @@ mod tests {
None
);
}

#[test]
fn relaycast_spawn_session_ref_supports_action_and_harness_shapes() {
let explicit = json!({
"session_ref": " session-explicit ",
"agent": {
"harnessConfig": {
"runtime": "pty",
"command": "codex",
"sessionId": "session-harness",
}
}
});
assert_eq!(
super::super::relaycast_events::relaycast_spawn_session_ref(&explicit).as_deref(),
Some("session-explicit"),
"the Fleet action field must take precedence over its compatibility fallback"
);

let nested_camel = json!({"agent": {"sessionRef": "session-nested"}});
assert_eq!(
super::super::relaycast_events::relaycast_spawn_session_ref(&nested_camel).as_deref(),
Some("session-nested")
);

let harness_only = json!({
"agent": {
"harnessConfig": {
"runtime": "pty",
"command": "codex",
"sessionId": "session-harness",
}
}
});
assert_eq!(
super::super::relaycast_events::relaycast_spawn_session_ref(&harness_only).as_deref(),
Some("session-harness")
);
}

#[test]
fn relaycast_spawn_spec_session_id_prefers_requested_resume() {
assert_eq!(
super::super::relaycast_events::relaycast_spawn_spec_session_id(
"codex",
Some(" requested-session "),
Some("harness-session"),
)
.as_deref(),
Some("requested-session")
);
assert_eq!(
super::super::relaycast_events::relaycast_spawn_spec_session_id(
"claude",
None,
Some(" harness-session "),
)
.as_deref(),
Some("harness-session")
);
assert_eq!(
super::super::relaycast_events::relaycast_spawn_spec_session_id(
"codex",
Some(" "),
None,
),
None
);
assert_eq!(
super::super::relaycast_events::relaycast_spawn_spec_session_id(
"pool",
Some("metadata-only-session"),
None,
),
None,
"custom capacity harnesses retain session_ref metadata without receiving Codex/Claude argv"
);
}
#[tokio::test]
async fn prune_fleet_inventory_entry_publishes_without_removed_agent() {
let (tx, mut rx) = mpsc::channel(4);
Expand Down
67 changes: 60 additions & 7 deletions crates/broker/src/runtime/relaycast_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,37 @@ impl BrokerRuntime {
}
}

/// Derive the initial session ref for a spawn request from its `ws_value`,
/// mirroring `spawn_worker_from_request`'s own `session_id` derivation (the
/// harness config's `session_id`). Returns `None` when the harness config is
/// absent or invalid, or carries no session id. Used by the node `action.invoke`
/// spawn path to forward a resumable session ref into `agent.register`, matching
/// the sidecar's `fleet_initial_session_ref(&spec)`.
/// Derive the initial session ref for a spawn request from its `ws_value`.
///
/// Fleet CLI/API callers send `session_ref` as a top-level action input, while
/// older firehose-style payloads may carry it under `agent` or in
/// `harnessConfig.session_id`. Prefer the explicit action field and retain the
/// harness fallback so both shapes resume the worker and register the same
/// session with the node control plane.
pub(super) fn relaycast_spawn_session_ref(ws_value: &Value) -> Option<String> {
let explicit = ["session_ref", "sessionRef"]
.iter()
.find_map(|key| {
ws_value
.get(*key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
.or_else(|| {
let agent = ws_value.get("agent")?;
["session_ref", "sessionRef"].iter().find_map(|key| {
agent
.get(*key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
});
if let Some(session_ref) = explicit {
return Some(session_ref.to_string());
}

relaycast_harness_config(ws_value)
.ok()
.flatten()
Expand All @@ -40,6 +64,30 @@ pub(super) fn relaycast_spawn_session_ref(ws_value: &Value) -> Option<String> {
.map(ToOwned::to_owned)
}

pub(super) fn relaycast_spawn_spec_session_id(
cli: &str,
session_ref: Option<&str>,
harness_session_id: Option<&str>,
) -> Option<String> {
let normalized_cli = crate::cli::command_parse::normalize_cli_name(cli);
let supports_resume = normalized_cli == "codex"
|| normalized_cli == "claude"
|| normalized_cli.starts_with("claude:");
supports_resume
.then_some(session_ref)
.flatten()
.and_then(|value| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
})
.or_else(|| {
harness_session_id.and_then(|value| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
})
})
}

fn relaycast_harness_config(value: &Value) -> Result<Option<ResolvedHarnessConfig>, String> {
let agent = value.get("agent");
let harness_id = agent
Expand Down Expand Up @@ -355,10 +403,15 @@ pub(super) async fn spawn_worker_from_request(
.as_ref()
.map(ResolvedHarnessConfig::runtime)
.unwrap_or(AgentRuntime::Pty);
let session_id = harness_config
let harness_session_id = harness_config
.as_ref()
.and_then(ResolvedHarnessConfig::session_id)
.map(ToOwned::to_owned);
let session_id = relaycast_spawn_spec_session_id(
&cli,
session_ref.as_deref(),
harness_session_id.as_deref(),
);

tracing::info!(name = %name, cli = %cli, task = ?task, channel = ?channel, "handling spawn request from relaycast WS");
let channels = channel
Expand Down
Loading
Loading