diff --git a/crates/stella-observatory/src/assets/index.html b/crates/stella-observatory/src/assets/index.html index cba9e0e73..33426258a 100644 --- a/crates/stella-observatory/src/assets/index.html +++ b/crates/stella-observatory/src/assets/index.html @@ -1271,6 +1271,13 @@

${fmtInt(runs)} undated ${runs === 1 ? "run" : "runs"} if (e.type === "stage") return `
stage · ${esc(e.label ?? "")}
`; if (e.type === "speculation_discarded") return `
◌ speculative ${esc(e.name ?? "")} discarded · ${esc(e.reason ?? "")}
`; + /* A parked span explains a wall-clock gap that contains no other events + (#1857) — without its own arm it would fall through to "answer" and draw + a blank row exactly where the transcript most needs to say something. */ + if (e.type === "turn_parked") + return `
⏳ parked · ${esc(e.description ?? "")} · every ${esc(String(e.poll_interval_secs ?? "?"))}s, up to ${esc(String(e.deadline_secs ?? "?"))}s
`; + if (e.type === "turn_woken") + return `
▶ woke · ${e.reason === "changed" ? "the watched state changed" : e.reason === "deadline_expired" ? "the deadline expired with no change" : esc(e.reason ?? "")} · ${esc(String(e.polls_used ?? 0))} probe(s)
`; const head = e.type === "tool_start" ? `▸ ${esc(e.name ?? "")}` : e.type === "tool_result" ? `${e.ok ? "✓" : "✕"} result${e.duration_ms != null ? ` · ${fmtMs(e.duration_ms)}` : ""}${e.speculated ? " · speculated" : ""}` : diff --git a/crates/stella-observatory/src/db.rs b/crates/stella-observatory/src/db.rs index f55b14a4e..cb1d07c1c 100644 --- a/crates/stella-observatory/src/db.rs +++ b/crates/stella-observatory/src/db.rs @@ -395,7 +395,8 @@ impl Observatory { WHERE execution_id = ?1 AND seq > ?2 AND event_type IN ('stage', 'text', 'reasoning', 'tool_start', - 'tool_result', 'speculation_discarded') + 'tool_result', 'speculation_discarded', + 'turn_parked', 'turn_woken') ORDER BY seq ASC"; let mut stmt = match conn.prepare(sql) { Ok(stmt) => stmt, @@ -1228,6 +1229,19 @@ fn journal_entry(row: Value, full: bool) -> Value { out["name"] = payload["name"].clone(); out["reason"] = payload["reason"].clone(); } + // A parked span is the one thing that explains a wall-clock gap with + // no events in it (#1857). Without its payload the row would say a + // park happened but not what was waited on or for how long — which + // is the entire question an operator opens this transcript to ask. + "turn_parked" => { + out["description"] = payload["description"].clone(); + out["poll_interval_secs"] = payload["poll_interval_secs"].clone(); + out["deadline_secs"] = payload["deadline_secs"].clone(); + } + "turn_woken" => { + out["reason"] = payload["reason"].clone(); + out["polls_used"] = payload["polls_used"].clone(); + } _ => {} } out diff --git a/crates/stella-protocol/src/event/tests.rs b/crates/stella-protocol/src/event/tests.rs index 30d80d2d0..72cfda5eb 100644 --- a/crates/stella-protocol/src/event/tests.rs +++ b/crates/stella-protocol/src/event/tests.rs @@ -1486,4 +1486,4 @@ fn a_known_event_wire_format_is_unchanged_by_the_fallback() { assert!(matches!(back, AgentEvent::Text { text } if text == "hello")); } -mod tag_table; \ No newline at end of file +mod tag_table; diff --git a/crates/stella-tui/src/fleet_dashboard.rs b/crates/stella-tui/src/fleet_dashboard.rs index f01f5c0a8..9af0f8ff8 100644 --- a/crates/stella-tui/src/fleet_dashboard.rs +++ b/crates/stella-tui/src/fleet_dashboard.rs @@ -211,6 +211,13 @@ enum LastAction { Message(String), /// Waiting on a human — approval, a secret, a decision. Blocked(String), + /// Parked on an engine-side wait (#1857): the worker is probing external + /// state on its own clock and will resume itself. + /// + /// Deliberately not [`LastAction::Blocked`], which means a human must act. + /// A park needs nobody — reading it as blocked would send an operator + /// looking for an approval prompt that does not exist. + Parked(String), /// A non-retryable error headline. Error(String), } @@ -295,6 +302,7 @@ impl TaskRow { LastAction::Thinking => "thinking…".to_string(), LastAction::Message(s) => s.clone(), LastAction::Blocked(reason) => format!("waiting: {reason}"), + LastAction::Parked(desc) => format!("parked: {desc}"), LastAction::Error(msg) => msg.clone(), } } @@ -457,6 +465,19 @@ impl FleetBoard { row.action = LastAction::Message(line); } } + // A park with no arm here would freeze the row on whatever tool + // ran last, so an operator watching a fleet would read a worker + // that is deliberately waiting as one that is stuck mid-tool. + // The status stays whatever it was — a park is the worker running, + // not blocked (nobody has to act) and not terminal. + AgentEvent::TurnParked { description, .. } => { + row.action = LastAction::Parked(first_line(description)); + } + // The wake carries no subject of its own; the next tool or message + // repaints the row a beat later, so clearing to `Idle` here would + // only flicker. Holding the park until then reads as "it woke and + // is picking back up", which is what happened. + AgentEvent::TurnWoken { .. } => {} AgentEvent::AskUser { question, .. } => { row.action = LastAction::Blocked(first_line(question)); if !row.status.is_terminal() {