Skip to content

Commit 57001f8

Browse files
authored
Merge pull request #1364 from AgentWorkforce/claude/relay-e2e-tic-tac-toe-hfjyvv
fix(cli,broker): unbreak the attach panes — clamp the status line, reap dead agents
2 parents 4ce87e4 + d5ce5d8 commit 57001f8

14 files changed

Lines changed: 1647 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- The published CLI now actually reports telemetry. The npm package is plain `tsc` output with no key injection step, and the bun standalone's `--define` targeted a literal `process.env.AGENT_RELAY_POSTHOG_KEY` that the code never read (it used a computed `process.env[name]` lookup), so **both** installable artifacts shipped with telemetry silently disabled — every `cli_command_run`, `workflow_run`, `cloud_auth`, `agent_relay_tool_call`, `setup_init`, `swarm_run`, and `bridge_spawn` event was dropped. Only the Rust broker was reporting.
2929
- Opting out of telemetry (`AGENT_RELAY_TELEMETRY_DISABLED` or `DO_NOT_TRACK`) now keeps your cloud identity out of child process environments, including identity an ancestor process or your shell had already exported. The identity env vars — one of which carries your email — previously reached every spawned process, including third-party harness CLIs, even when opted out.
3030
- Identity forwarding to the Relaycast gateway is no longer gated on the local process carrying a PostHog key. An npm-installed CLI bakes no key, so it previously forwarded no identity at all and every hosted event fell back to being keyed on the workspace. Forwarding now follows the telemetry preference alone.
31+
- `node agent list` no longer reports an exited agent as `working` indefinitely; the broker now reaps a worker whose harness has gone away or never started.
32+
- `node agent attach --mode drive|passthrough` truncates its status line to the terminal width, so a narrow pane no longer stacks status lines over the agent's output.
3133
- `node agent attach --mode view` now exits on the first Ctrl-C instead of waiting for a WebSocket close handshake.
3234
- The broker now sends its anonymous telemetry id (`X-Agent-Relay-Distinct-Id`) and origin actor with its Relaycast requests, so hosted usage can be attributed to an install instead of only to a workspace. The id header is omitted when telemetry is opted out; requests and origin actor are unaffected.
3335
- The broker now reads its telemetry preference and machine-id files from `AGENT_RELAY_DATA_DIR` when set, matching the CLI. It previously only read `~/.agentworkforce/relay/telemetry.json`, so an opt-out written by `agent-relay telemetry disable` under a configured data directory was ignored.

crates/broker/src/runtime/tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ async fn make_worker_registry_with_worker(name: &str) -> WorkerRegistry {
107107
stdin,
108108
harness_pid: None,
109109
spawned_at: Instant::now(),
110+
// Ready, so the orphan sweep's readiness deadline never applies to
111+
// these fixtures.
112+
ready_at: Some(Instant::now()),
110113
last_activity_at: Instant::now(),
111114
context_budget_pct: None,
112115
state: AgentWorkState::Working,

crates/broker/src/runtime/worker_events.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,10 @@ impl BrokerRuntime {
848848
if let Some(pid) = payload_pid {
849849
h.harness_pid = Some(pid);
850850
}
851+
// Records that the harness actually came up, so
852+
// `reap_exited` can tell a slow start from one that
853+
// never happened.
854+
h.ready_at.get_or_insert_with(Instant::now);
851855
(
852856
h.spec.provider.clone(),
853857
h.spec.cli.clone(),

crates/broker/src/worker.rs

Lines changed: 225 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,83 @@ const APP_SERVER_AUTH_ENV_KEYS: [&str; 4] = [
4242
const DEFAULT_RELEASE_GRACE: Duration = Duration::from_secs(2);
4343
const APP_SERVER_RELEASE_GRACE: Duration = Duration::from_secs(35);
4444

45+
/// How long a worker may go without reporting `worker_ready` before the broker
46+
/// treats its harness as failed-to-start.
47+
///
48+
/// The worker process emits `worker_ready` itself — the PTY runtime even has a
49+
/// 25s fallback that fires when readiness detection times out
50+
/// (`pty_worker::STARTUP_READY_TIMEOUT`). So silence past this deadline does not
51+
/// mean "slow": it means the worker died, or wedged, before it could report.
52+
/// The margin over that 25s fallback is deliberately generous — reaping a
53+
/// healthy-but-slow agent is far worse than listing a dead one a little longer.
54+
const WORKER_READY_DEADLINE: Duration = Duration::from_secs(90);
55+
56+
/// How long to wait for a SIGKILLed orphan wrapper to be reaped before giving
57+
/// up. Bounded so a wrapper stuck in uninterruptible sleep cannot stall the
58+
/// maintenance tick, which also drives delivery retries.
59+
const ORPHAN_REAP_TIMEOUT: Duration = Duration::from_secs(2);
60+
61+
/// Why a worker was reaped despite its wrapper process still being alive.
62+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63+
pub(crate) enum OrphanedWorker {
64+
/// The harness pid the worker reported is gone.
65+
HarnessExited,
66+
/// `worker_ready` never arrived within [`WORKER_READY_DEADLINE`].
67+
NeverReady,
68+
}
69+
70+
impl OrphanedWorker {
71+
fn reason(self) -> &'static str {
72+
match self {
73+
OrphanedWorker::HarnessExited => "harness_exited",
74+
OrphanedWorker::NeverReady => "harness_never_ready",
75+
}
76+
}
77+
}
78+
79+
/// True when `pid` no longer names a live process.
80+
///
81+
/// Safety: `kill(pid, 0)` is a POSIX-safe probe — it performs the permission
82+
/// and existence checks without delivering a signal. `ESRCH` means the process
83+
/// is gone; every other error (notably `EPERM`) means it exists.
84+
#[cfg(unix)]
85+
fn pid_is_gone(pid: u32) -> bool {
86+
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
87+
ret == -1 && std::io::Error::last_os_error().raw_os_error().unwrap_or(0) == libc::ESRCH
88+
}
89+
90+
#[cfg(not(unix))]
91+
fn pid_is_gone(_pid: u32) -> bool {
92+
false
93+
}
94+
95+
/// Decide whether a worker is dead even though its wrapper process is alive.
96+
///
97+
/// The wrapper (`agent-relay-broker pty …`) can outlive the harness it hosts:
98+
/// its stdin reader blocks on a pipe the broker never closes, so a wrapper whose
99+
/// harness exited at startup can sit in `futex_do_wait` indefinitely. Reaping on
100+
/// the wrapper alone therefore leaves the agent listed as `working` forever, with
101+
/// `last_activity` frozen at the moment it died. Judge liveness by the harness.
102+
pub(crate) fn orphaned_worker(
103+
harness_pid: Option<u32>,
104+
ready_at: Option<Instant>,
105+
spawned_at: Instant,
106+
now: Instant,
107+
) -> Option<OrphanedWorker> {
108+
if let Some(pid) = harness_pid {
109+
if pid_is_gone(pid) {
110+
return Some(OrphanedWorker::HarnessExited);
111+
}
112+
// A live harness pid is proof of life; never apply the readiness
113+
// deadline to one that is plainly running.
114+
return None;
115+
}
116+
if ready_at.is_none() && now.saturating_duration_since(spawned_at) > WORKER_READY_DEADLINE {
117+
return Some(OrphanedWorker::NeverReady);
118+
}
119+
None
120+
}
121+
45122
// Working/idle activity inference from PTY output comes from the
46123
// harness-agnostic `relay-pty` crate.
47124
pub(crate) use relay_pty::detection;
@@ -55,6 +132,9 @@ pub(crate) struct WorkerHandle {
55132
pub(crate) stdin: ChildStdin,
56133
pub(crate) harness_pid: Option<u32>,
57134
pub(crate) spawned_at: Instant,
135+
/// When the worker reported `worker_ready`. `None` means the harness has
136+
/// never come up — see `WORKER_READY_DEADLINE` in `reap_exited`.
137+
pub(crate) ready_at: Option<Instant>,
58138
pub(crate) last_activity_at: Instant,
59139
pub(crate) context_budget_pct: Option<u8>,
60140
pub(crate) state: AgentWorkState,
@@ -232,16 +312,7 @@ impl WorkerRegistry {
232312
#[cfg(unix)]
233313
{
234314
match handle.child.id() {
235-
// Safety: kill(pid, 0) is a POSIX-safe probe that checks process
236-
// existence without sending a signal. ESRCH => the process is gone.
237-
Some(pid) => {
238-
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
239-
if ret == -1 {
240-
std::io::Error::last_os_error().raw_os_error().unwrap_or(0) != libc::ESRCH
241-
} else {
242-
true
243-
}
244-
}
315+
Some(pid) => !pid_is_gone(pid),
245316
// `id()` returns None once the child has been waited/reaped.
246317
None => false,
247318
}
@@ -867,6 +938,7 @@ impl WorkerRegistry {
867938
stdin,
868939
harness_pid: initial_harness_pid,
869940
spawned_at: Instant::now(),
941+
ready_at: None,
870942
last_activity_at: Instant::now(),
871943
context_budget_pct: None,
872944
state: AgentWorkState::Working,
@@ -1003,24 +1075,13 @@ impl WorkerRegistry {
10031075
#[cfg(unix)]
10041076
{
10051077
if let Some(pid) = handle.child.id() {
1006-
// Safety: kill(pid, 0) is a POSIX-safe probe that checks
1007-
// process existence without sending a signal. ESRCH means
1008-
// the process no longer exists.
1009-
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
1010-
if ret == -1 {
1011-
let errno = std::io::Error::last_os_error()
1012-
.raw_os_error()
1013-
.unwrap_or(0);
1014-
if errno == libc::ESRCH {
1015-
tracing::info!(
1016-
worker = %name,
1017-
pid = pid,
1018-
"reap_exited: kill(0) says ESRCH — process gone"
1019-
);
1020-
(None, true)
1021-
} else {
1022-
(None, false)
1023-
}
1078+
if pid_is_gone(pid) {
1079+
tracing::info!(
1080+
worker = %name,
1081+
pid = pid,
1082+
"reap_exited: kill(0) says ESRCH — process gone"
1083+
);
1084+
(None, true)
10241085
} else {
10251086
(None, false)
10261087
}
@@ -1048,6 +1109,62 @@ impl WorkerRegistry {
10481109
} else {
10491110
(None, false)
10501111
};
1112+
// The wrapper can outlive its harness. When it does, judge the agent
1113+
// by the harness and tear the orphaned wrapper down — otherwise the
1114+
// agent is listed as `working` forever.
1115+
let orphaned = if status.is_none() && !gone_via_kill0 {
1116+
self.workers.get(&name).and_then(|handle| {
1117+
orphaned_worker(
1118+
handle.harness_pid,
1119+
handle.ready_at,
1120+
handle.spawned_at,
1121+
Instant::now(),
1122+
)
1123+
})
1124+
} else {
1125+
None
1126+
};
1127+
if let Some(orphan) = orphaned {
1128+
let reason = self
1129+
.workers
1130+
.get(&name)
1131+
.and_then(|handle| handle.exit_reason.clone())
1132+
.or_else(|| Some(orphan.reason().to_string()));
1133+
if let Some(handle) = self.workers.get_mut(&name) {
1134+
tracing::warn!(
1135+
worker = %name,
1136+
wrapper_pid = ?handle.child.id(),
1137+
harness_pid = ?handle.harness_pid,
1138+
reason = orphan.reason(),
1139+
"reap_exited: harness gone but wrapper alive — killing orphaned wrapper"
1140+
);
1141+
// SIGKILL *and* reap. Dropping the `Child` without waiting
1142+
// leaves the wrapper to tokio's best-effort background
1143+
// reaper, so a run of failed agent starts accumulates
1144+
// zombies. SIGKILL cannot be caught, so this returns
1145+
// promptly — but the deadline keeps a wrapper wedged in
1146+
// uninterruptible sleep from stalling the maintenance tick,
1147+
// which also drives delivery retries.
1148+
if let Err(error) = handle.child.start_kill() {
1149+
tracing::warn!(worker = %name, %error, "failed to signal orphaned wrapper");
1150+
}
1151+
match timeout(ORPHAN_REAP_TIMEOUT, handle.child.wait()).await {
1152+
Ok(Ok(_)) => {}
1153+
Ok(Err(error)) => {
1154+
tracing::warn!(worker = %name, %error, "orphaned wrapper wait failed")
1155+
}
1156+
Err(_) => tracing::warn!(
1157+
worker = %name,
1158+
timeout_ms = ORPHAN_REAP_TIMEOUT.as_millis(),
1159+
"orphaned wrapper did not exit before the reap deadline"
1160+
),
1161+
}
1162+
}
1163+
self.workers.remove(&name);
1164+
self.initial_tasks.remove(&name);
1165+
exited.push((name, None, None, reason));
1166+
continue;
1167+
}
10511168
if let Some(status) = status {
10521169
let code = status.code();
10531170
#[cfg(unix)]
@@ -1898,6 +2015,86 @@ mod tests {
18982015
assert!(reg.list(&HashMap::new()).is_empty());
18992016
}
19002017

2018+
// The wrapper process can outlive the harness it hosts, so reaping on the
2019+
// wrapper alone leaves a dead agent listed as `working` forever.
2020+
mod orphaned_worker {
2021+
use super::*;
2022+
2023+
/// A pid that cannot be live. `kill(0)` on pid 0 addresses the caller's
2024+
/// own process group, so use an unassigned high pid instead.
2025+
fn dead_pid() -> u32 {
2026+
// Above the default pid_max; never allocated.
2027+
0x7FFF_FFFF
2028+
}
2029+
2030+
fn live_pid() -> u32 {
2031+
std::process::id()
2032+
}
2033+
2034+
#[test]
2035+
fn reports_harness_exited_when_the_reported_pid_is_gone() {
2036+
let now = Instant::now();
2037+
assert_eq!(
2038+
orphaned_worker(Some(dead_pid()), Some(now), now, now),
2039+
Some(OrphanedWorker::HarnessExited)
2040+
);
2041+
}
2042+
2043+
#[test]
2044+
fn leaves_a_worker_with_a_live_harness_alone() {
2045+
let now = Instant::now();
2046+
assert_eq!(orphaned_worker(Some(live_pid()), Some(now), now, now), None);
2047+
}
2048+
2049+
// A live harness pid is proof of life even if `worker_ready` was missed,
2050+
// so the readiness deadline must not apply to it.
2051+
#[test]
2052+
fn a_live_harness_is_never_reaped_for_missing_readiness() {
2053+
let spawned = Instant::now();
2054+
let now = spawned + WORKER_READY_DEADLINE + Duration::from_secs(60);
2055+
assert_eq!(orphaned_worker(Some(live_pid()), None, spawned, now), None);
2056+
}
2057+
2058+
#[test]
2059+
fn reports_never_ready_only_after_the_deadline() {
2060+
let spawned = Instant::now();
2061+
// Still inside the window — a slow harness must be left to boot.
2062+
assert_eq!(
2063+
orphaned_worker(None, None, spawned, spawned + Duration::from_secs(30)),
2064+
None
2065+
);
2066+
assert_eq!(
2067+
orphaned_worker(None, None, spawned, spawned + WORKER_READY_DEADLINE),
2068+
None
2069+
);
2070+
assert_eq!(
2071+
orphaned_worker(
2072+
None,
2073+
None,
2074+
spawned,
2075+
spawned + WORKER_READY_DEADLINE + Duration::from_secs(1)
2076+
),
2077+
Some(OrphanedWorker::NeverReady)
2078+
);
2079+
}
2080+
2081+
// A worker that reported ready and has no pid to probe (non-PTY
2082+
// runtimes) must never be reaped by the deadline.
2083+
#[test]
2084+
fn a_ready_worker_without_a_pid_is_never_reaped() {
2085+
let spawned = Instant::now();
2086+
let now = spawned + WORKER_READY_DEADLINE * 10;
2087+
assert_eq!(orphaned_worker(None, Some(spawned), spawned, now), None);
2088+
}
2089+
2090+
#[test]
2091+
fn the_deadline_clears_the_pty_runtime_startup_fallback() {
2092+
// `pty_worker::STARTUP_READY_TIMEOUT` is 25s; the broker must wait
2093+
// comfortably longer than the worker's own fallback.
2094+
assert!(WORKER_READY_DEADLINE > Duration::from_secs(25) * 3);
2095+
}
2096+
}
2097+
19012098
#[test]
19022099
fn has_worker_returns_false_for_unknown() {
19032100
let reg = make_registry(vec![]);

0 commit comments

Comments
 (0)