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() {