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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Broker: `spawn:<harness>` capabilities now declare `metadata.configured.node_kind: "broker"`, so Cloud keeps placing spawns on unclaimed CLI brokers instead of refusing them until `agent-relay node claim`. An already-declared kind (e.g. `desktop`) is preserved.
- Codex 0.16x workers no longer dead-letter their initial task with `initial_injection_incomplete` when the composer scrolls long input. The broker now checks that the end of each typed chunk is visible and ignores Codex's `↑`/`↓` scroll markers.
- `relay agent rotate <name>`, and `agent register` for a name that already exists, now issue a new token through the audited owner takeover instead of failing with "already exists", so a lost token can always be replaced.
- `install.sh`'s source-build fallback no longer deletes the install root (and with it saved workspaces and Relaycast credentials); the checkout now lives in its `source/` subdirectory.
Expand Down
146 changes: 136 additions & 10 deletions crates/broker/src/node_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,32 @@ pub(crate) fn handler_unavailable_result(invocation_id: &str) -> ActionResult {
}
}

/// `metadata.configured.node_kind` this broker declares on its spawn capacity.
/// Cloud placement refuses an unclaimed node that states no kind.
const BROKER_NODE_KIND: &str = "broker";

/// Declare `configured.node_kind: "broker"` on a `spawn:<harness>` capability,
/// leaving any kind the manifest already states (e.g. `"desktop"`) untouched.
/// Non-spawn capabilities, and a non-object `configured`, pass through as-is.
fn spawn_capability_metadata(
name: &str,
metadata: Option<BTreeMap<String, serde_json::Value>>,
) -> Option<BTreeMap<String, serde_json::Value>> {
if !name.starts_with("spawn:") {
return metadata;
}
let mut metadata = metadata.unwrap_or_default();
let configured = metadata
.entry("configured".to_owned())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let serde_json::Value::Object(configured) = configured {
configured
.entry("node_kind")
.or_insert_with(|| serde_json::Value::String(BROKER_NODE_KIND.to_owned()));
}
Some(metadata)
}

pub(crate) fn build_node_register(
manifest: &NodeManifest,
default_node_id: &str,
Expand All @@ -1587,12 +1613,15 @@ pub(crate) fn build_node_register(
kind: capability.kind.clone(),
global: (capability.name == "task.run").then_some(true),
queue: None,
metadata: capability.metadata.as_ref().map(|metadata| {
metadata
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<BTreeMap<_, _>>()
}),
metadata: spawn_capability_metadata(
&capability.name,
capability.metadata.as_ref().map(|metadata| {
metadata
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<BTreeMap<_, _>>()
}),
),
})
.collect::<Vec<_>>();
capabilities.push(FleetCapability {
Expand Down Expand Up @@ -6759,6 +6788,103 @@ mod tests {
"the pending registration must remain in flight"
);
}
fn spawn_manifest(capabilities: Vec<crate::protocol::NodeCapabilityManifest>) -> NodeManifest {
NodeManifest {
name: "builder".to_string(),
node_id: Some("node-manifest".to_string()),
capabilities,
max_agents: None,
tags: None,
repo_keys: None,
version: None,
}
}

fn capability(
name: &str,
metadata: Option<HashMap<String, Value>>,
) -> crate::protocol::NodeCapabilityManifest {
crate::protocol::NodeCapabilityManifest {
name: name.to_string(),
kind: Some("capacity".to_string()),
metadata,
}
}

#[test]
fn build_node_register_declares_broker_node_kind_on_every_spawn_capability() {
let manifest = spawn_manifest(vec![
capability("spawn:claude", None),
capability("spawn:codex", None),
capability("release", None),
]);
let register =
build_node_register(&manifest, "node-default", "host-default", "broker/1", None);
let wire = serde_json::to_value(&register).expect("register serializes");
for capability in wire["capabilities"].as_array().unwrap() {
let name = capability["name"].as_str().unwrap();
if name.starts_with("spawn:") {
assert_eq!(
capability["metadata"]["configured"]["node_kind"], "broker",
"{name} must declare node_kind broker on the wire"
);
} else {
assert!(
capability.get("metadata").is_none(),
"{name} must not gain spawn metadata"
);
}
}
// The heartbeat descriptor reuses the registered capabilities.
let heartbeat = FleetLoadSnapshot::default().heartbeat(&register);
let spawn = heartbeat
.capabilities
.iter()
.find(|capability| capability.name == "spawn:claude")
.unwrap();
assert_eq!(
spawn.metadata.as_ref().unwrap()["configured"]["node_kind"],
"broker"
);
}

#[test]
fn build_node_register_preserves_declared_node_kind_and_configured_facts() {
let manifest = spawn_manifest(vec![capability(
"spawn:codex",
Some(HashMap::from([
(
"configured".to_string(),
json!({"node_kind": "desktop", "machine": "Finn-Mac-Mini"}),
),
("live".to_string(), json!({"version": "1.0.0"})),
])),
)]);
let register =
build_node_register(&manifest, "node-default", "host-default", "broker/1", None);
let metadata = register.capabilities[0].metadata.as_ref().unwrap();
assert_eq!(
metadata["configured"],
json!({"node_kind": "desktop", "machine": "Finn-Mac-Mini"})
);
assert_eq!(metadata["live"], json!({"version": "1.0.0"}));

// Facts without a kind keep their values and gain the broker default.
let manifest = spawn_manifest(vec![capability(
"spawn:codex",
Some(HashMap::from([(
"configured".to_string(),
json!({"teammates": true}),
)])),
)]);
let register =
build_node_register(&manifest, "node-default", "host-default", "broker/1", None);
assert_eq!(
register.capabilities[0].metadata.as_ref().unwrap()["configured"],
json!({"teammates": true, "node_kind": "broker"})
);
}

#[test]
fn build_node_register_prefers_manifest_identity() {
let manifest = NodeManifest {
Expand Down Expand Up @@ -6789,10 +6915,10 @@ mod tests {
);
assert_eq!(
register.capabilities[0].metadata,
Some(BTreeMap::from([(
"cli".to_string(),
Value::String("codex".to_string())
)]))
Some(BTreeMap::from([
("cli".to_string(), Value::String("codex".to_string())),
("configured".to_string(), json!({"node_kind": "broker"})),
]))
);
assert_eq!(
register.capabilities.last(),
Expand Down
13 changes: 13 additions & 0 deletions tests/relayflows/cases/5310-broker-node-kind/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"id": "5310-broker-node-kind",
"kind": "feature",
"title": "Declare node_kind broker on every spawn capability the broker registers",
"runner": { "command": ["node", "tests/relayflows/cases/5310-broker-node-kind/run.mjs"] },
"requirements": ["broker-linux-x64"],
"timeoutSeconds": 900,
"expected": {
"base": { "outcome": "absent", "signature": "spawn_capabilities_state_no_node_kind" },
"head": { "outcome": "fixed", "signature": "spawn_capabilities_declare_broker_node_kind" }
}
}
Loading
Loading