From d7a59b12d428812b4c54d9fff997e36e758919f6 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 21:22:55 -0700 Subject: [PATCH 1/3] feat(stella-serve): count parked waits in the turn tally, so a stall is not read as a hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TallyFold::observe` dropped `AgentEvent::TurnParked`/`TurnWoken` into its inert `_ => {}` arm — precisely the distinction the tally exists to make. `TurnTally::stages` documents itself as the progress axis ("a turn whose stages stopped advancing while a reverse request's wait climbs is wedged"), but a parked turn stops advancing stages ON PURPOSE: it probes external state on the engine's own clock, zero model calls, until the state changes or the deadline expires. So a host reading the tally saw a stages-stall it could not tell apart from a hang, and the one signal that explained it was on the stream and discarded. Four counters, all additive and zero-skipping so a record written before this parses unchanged: - `parked_spans` / `parked_spans_woken` — counted apart rather than assumed equal, because the engine's park loop returns WITHOUT a wake when the turn is cancelled or a soft stop is latched (`driver::waiting`). A span left open means the turn ended inside the wait, which is a different diagnosis from "waited and came back". `TurnTally::ended_parked` names it. - `parked_polls` — the park's own progress axis, the direct analogue of `stages` for a turn deliberately making no stage progress. - `parked_deadline_secs` — the licence to sit still, and the only honest duration this fold can produce: it counts, it never reads a clock. `Metrics` gains `parked_spans_total` / `parked_deadline_secs_total` for the same reason one layer up — a scrape that sees `model_calls_total` flat and `turn_duration_ms_total` climbing otherwise has no way to tell a fleet waiting on external state from one that is stuck. Content-free by construction: `TurnParked.description` is tool-authored free text and is never read, only the counts and the closed `WakeReason` token are touched, and a new test pins that two parks differing only in their prose are indistinguishable in the tally. The existing `payload_events_are_inert` test is untouched. `TurnTally` rides `ServeEvent`, not `ServerFrame`, so it reaches no `docs/wire` artifact and no schema regen is required. Refs #1857, #1471 Closes #2006 --- crates/stella-serve/src/observe/event.rs | 65 +++++++++++++ crates/stella-serve/src/observe/metrics.rs | 25 +++++ crates/stella-serve/src/observe/tally.rs | 106 +++++++++++++++++++++ 3 files changed, 196 insertions(+) diff --git a/crates/stella-serve/src/observe/event.rs b/crates/stella-serve/src/observe/event.rs index eb64fa8df..b017eb1a3 100644 --- a/crates/stella-serve/src/observe/event.rs +++ b/crates/stella-serve/src/observe/event.rs @@ -555,6 +555,58 @@ pub struct TurnTally { /// operator can tell "the client is gone" from "the turn was quiet". #[serde(default, skip_serializing_if = "is_zero_u64")] pub frames_dropped: u64, + + /// Parked waits this turn opened (`AgentEvent::TurnParked`, #1471/#1857). + /// + /// This is the field that keeps [`Self::stages`] honest. A parked turn + /// stops advancing stages **on purpose** — it is probing external state on + /// the engine's own clock, with zero model calls, until the state changes + /// or the deadline expires. Without this count a host reading a + /// stages-stall cannot tell a deliberate wait from a hang, which is the + /// one question the tally exists to answer (#2006). + #[serde(default, skip_serializing_if = "is_zero_u32")] + pub parked_spans: u32, + /// Parked waits that closed with an `AgentEvent::TurnWoken`. + /// + /// Deliberately counted apart from [`Self::parked_spans`] rather than + /// assumed equal to it: the engine's park loop returns early — without a + /// wake — when the turn is cancelled or a soft stop is latched + /// (`stella-core::driver::waiting`). So a turn can settle with a span + /// still open, and "ended while parked" is a different diagnosis from + /// "parked and resumed". See [`Self::ended_parked`]. + #[serde(default, skip_serializing_if = "is_zero_u32")] + pub parked_spans_woken: u32, + /// Engine-side probes spent while parked, summed from + /// `AgentEvent::TurnWoken.polls_used`. + /// + /// The park's own progress axis — the direct analogue of [`Self::stages`] + /// for a turn that is deliberately making no stage progress. A park whose + /// probes are climbing is working; one at zero probes woke immediately. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub parked_polls: u64, + /// Seconds this turn was *licensed* to sit still — the sum of every + /// span's `AgentEvent::TurnParked.deadline_secs`. + /// + /// The duration half of the wedged-versus-waiting question, and the only + /// honest one this fold can produce: it counts, it does not read a clock, + /// so elapsed park time is not available here. A 30-minute stall under a + /// 1800s licence is the wait working as designed; the same stall with + /// this at zero is a hang. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub parked_deadline_secs: u64, +} + +impl TurnTally { + /// Whether the turn settled with a parked wait still open — a span that + /// never saw its `TurnWoken` because the turn was cancelled or soft-stopped + /// out of the park. + /// + /// The distinction a host wants when `stages` has not moved: `true` means + /// the turn ended *in* the wait, `false` with [`Self::parked_spans`] + /// non-zero means it waited and came back. + pub fn ended_parked(&self) -> bool { + self.parked_spans > self.parked_spans_woken + } } /// `skip_serializing_if` for a counter that is zero on every healthy turn — @@ -563,6 +615,12 @@ fn is_zero_u64(n: &u64) -> bool { *n == 0 } +/// [`is_zero_u64`]'s twin for the `u32` counters — same contract: a turn that +/// never parked serializes exactly as it did before park accounting existed. +fn is_zero_u32(n: &u32) -> bool { + *n == 0 +} + /// One thing the server did, at a boundary. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] @@ -902,6 +960,13 @@ mod tests { speculation_discarded: 1, loop_detections: 0, frames_dropped: 0, + // Non-zero so the round trip actually exercises the park + // counters: their `skip_serializing_if` means a zeroed + // tally would prove nothing about them. + parked_spans: 2, + parked_spans_woken: 1, + parked_polls: 37, + parked_deadline_secs: 2400, }, }, ]; diff --git a/crates/stella-serve/src/observe/metrics.rs b/crates/stella-serve/src/observe/metrics.rs index 7c6dde703..0e8688b99 100644 --- a/crates/stella-serve/src/observe/metrics.rs +++ b/crates/stella-serve/src/observe/metrics.rs @@ -74,6 +74,15 @@ pub struct Metrics { tools_failed_total: AtomicU64, speculation_discarded_total: AtomicU64, loop_detections_total: AtomicU64, + /// Parked waits opened across all settled turns (#2006). Without it a + /// scrape sees `model_calls_total` flat and `turn_duration_ms_total` + /// climbing and has no way to tell a fleet that is waiting on external + /// state from one that is stuck — the same blind spot `TurnTally` closes + /// one layer down. + parked_spans_total: AtomicU64, + /// Seconds those spans were licensed to sit still — the denominator that + /// makes a long, quiet turn defensible rather than alarming. + parked_deadline_secs_total: AtomicU64, reverse_dispatched_provider_total: AtomicU64, reverse_dispatched_tool_total: AtomicU64, @@ -139,6 +148,8 @@ pub struct Snapshot { pub tools_failed_total: u64, pub speculation_discarded_total: u64, pub loop_detections_total: u64, + pub parked_spans_total: u64, + pub parked_deadline_secs_total: u64, pub reverse_dispatched_provider_total: u64, pub reverse_dispatched_tool_total: u64, @@ -211,6 +222,8 @@ impl Metrics { tools_failed_total: self.tools_failed_total.load(ORDER), speculation_discarded_total: self.speculation_discarded_total.load(ORDER), loop_detections_total: self.loop_detections_total.load(ORDER), + parked_spans_total: self.parked_spans_total.load(ORDER), + parked_deadline_secs_total: self.parked_deadline_secs_total.load(ORDER), reverse_dispatched_provider_total: self.reverse_dispatched_provider_total.load(ORDER), reverse_dispatched_tool_total: self.reverse_dispatched_tool_total.load(ORDER), @@ -316,6 +329,10 @@ impl Observer for Metrics { .fetch_add(u64::from(tally.speculation_discarded), ORDER); self.loop_detections_total .fetch_add(u64::from(tally.loop_detections), ORDER); + self.parked_spans_total + .fetch_add(u64::from(tally.parked_spans), ORDER); + self.parked_deadline_secs_total + .fetch_add(tally.parked_deadline_secs, ORDER); } ServeEvent::TurnReclaimed { .. } => { self.turns_reclaimed_total.fetch_add(1, ORDER); @@ -555,6 +572,10 @@ mod tests { speculation_discarded: 4, loop_detections: 1, frames_dropped: 0, + parked_spans: 2, + parked_spans_woken: 1, + parked_polls: 60, + parked_deadline_secs: 2400, }, }); let snap = metrics.snapshot(); @@ -567,6 +588,10 @@ mod tests { assert_eq!(snap.tools_failed_total, 1); assert_eq!(snap.speculation_discarded_total, 4); assert_eq!(snap.loop_detections_total, 1); + // A scrape that cannot see the parks reads this turn's quiet stretch + // as a stall (#2006). + assert_eq!(snap.parked_spans_total, 2); + assert_eq!(snap.parked_deadline_secs_total, 2400); } /// The registry gauge tracks occupancy, and refusals are separated by diff --git a/crates/stella-serve/src/observe/tally.rs b/crates/stella-serve/src/observe/tally.rs index a0840031a..8d776cd9b 100644 --- a/crates/stella-serve/src/observe/tally.rs +++ b/crates/stella-serve/src/observe/tally.rs @@ -75,6 +75,27 @@ impl TallyFold { AgentEvent::LoopDetected { .. } => { tally.loop_detections = tally.loop_detections.saturating_add(1); } + // The park axis (#1471, #1857) — the one signal that keeps + // `stages` honest. A parked turn stops advancing stages on + // purpose, so a host that cannot see the park reads a deliberate + // wait as a hang; the events were on this stream and this fold + // was dropping them (#2006). `description` is tool-authored free + // text and is deliberately NOT read here: this is a counter, and + // the crate's no-content property does not get an exception. + AgentEvent::TurnParked { deadline_secs, .. } => { + tally.parked_spans = tally.parked_spans.saturating_add(1); + tally.parked_deadline_secs = + tally.parked_deadline_secs.saturating_add(*deadline_secs); + } + // Counted apart from the park rather than assumed to match it: the + // engine's park loop returns without a wake when the turn is + // cancelled or soft-stopped, so a turn can settle mid-span and + // `TurnTally::ended_parked` is what tells the two apart. `reason` + // is a closed token, but the count is all this needs. + AgentEvent::TurnWoken { polls_used, .. } => { + tally.parked_spans_woken = tally.parked_spans_woken.saturating_add(1); + tally.parked_polls = tally.parked_polls.saturating_add(*polls_used); + } // Everything else — text, deltas, reasoning, budget ticks — is // either payload we deliberately never record or a signal with no // counterpart here. `AgentEvent` is forward-compatible, so an @@ -188,6 +209,91 @@ mod tests { assert_eq!(fold.finish(), TurnTally::default()); } + fn parked(description: &str, deadline_secs: u64) -> AgentEvent { + AgentEvent::TurnParked { + description: description.to_string(), + poll_interval_secs: 30, + deadline_secs, + } + } + + /// The witness for #2006. A turn that parked and resumed makes no stage + /// progress *on purpose*; before park accounting existed its tally was + /// byte-identical to a turn that simply stalled, so the one question the + /// tally exists to answer had no answer. + #[test] + fn a_parked_turn_is_distinguishable_from_a_wedged_one() { + let mut wedged = TallyFold::default(); + wedged.observe(&AgentEvent::Stage { + name: StageKind::Execute, + }); + + let mut waiting = TallyFold::default(); + waiting.observe(&AgentEvent::Stage { + name: StageKind::Execute, + }); + waiting.observe(&parked("CI for branch main settles", 1800)); + waiting.observe(&AgentEvent::TurnWoken { + reason: "changed".to_string(), + polls_used: 41, + }); + + let wedged = wedged.finish(); + let waiting = waiting.finish(); + assert_eq!( + wedged.stages, waiting.stages, + "the premise: both turns advanced exactly one stage" + ); + assert_ne!( + wedged, waiting, + "a park must leave a mark, or a deliberate wait reads as a hang" + ); + assert_eq!(waiting.parked_spans, 1); + assert_eq!(waiting.parked_spans_woken, 1); + assert_eq!(waiting.parked_polls, 41); + assert_eq!( + waiting.parked_deadline_secs, 1800, + "the licence to sit still is what makes the stall defensible" + ); + assert!( + !waiting.ended_parked(), + "this park closed — the turn waited and came back" + ); + assert!( + !wedged.ended_parked(), + "a turn that never parked never ends parked" + ); + } + + /// The engine's park loop returns *without* a `TurnWoken` when the turn is + /// cancelled or a soft stop is latched, so the two counts genuinely + /// diverge and the open span is the sharper diagnosis. + #[test] + fn a_turn_that_settles_mid_park_leaves_the_span_open() { + let mut fold = TallyFold::default(); + fold.observe(&parked("the deploy finishes", 600)); + let tally = fold.finish(); + assert_eq!(tally.parked_spans, 1); + assert_eq!(tally.parked_spans_woken, 0); + assert!( + tally.ended_parked(), + "a span with no wake means the turn ended inside the wait" + ); + } + + /// The park is counted, never quoted. `TurnParked.description` is + /// tool-authored free text, and the same rule that keeps `Text` out of + /// this fold keeps it out too — two parks that differ only in their prose + /// must be indistinguishable here. + #[test] + fn the_park_description_never_reaches_the_tally() { + let mut one = TallyFold::default(); + one.observe(&parked("CI for branch main settles", 900)); + let mut other = TallyFold::default(); + other.observe(&parked("s3://bucket/customer-export.csv appears", 900)); + assert_eq!(one.finish(), other.finish()); + } + /// The fold must survive a turn long enough to overflow a `u32` counter /// without panicking in a release-mode-divergent way. #[test] From 1e4c5f4a037c8d3178f8fa84cf035b6c89a54c5b Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 21:26:14 -0700 Subject: [PATCH 2/3] refactor(stella-tui): split deck.rs's pure event classifiers into deck/classify.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deck.rs` sat at 1492 of the 1500-line guard, and #2007's parked-wait clock needs about fourteen lines in it. The sanctioned move is to extract a coherent cluster rather than raise a ceiling, exactly as `prompt_queue.rs` was split out of this same file for the same reason. The cluster is the file's "Event → derived attributes" section: four pure functions over `&AgentEvent` that hold no state — `event_intensity`, `status_from_event`, `trace_of` and `snip`. They were private, called only from within `deck.rs`, so the cut needs no visibility change beyond the `pub(super)` a child module requires. A pure move: the function bodies are byte-identical, `deck.rs` re-imports the four names so every call site is unchanged, and no test changed. deck.rs drops to 1207 lines. Refs #2007 --- crates/stella-tui/src/deck.rs | 295 +----------------------- crates/stella-tui/src/deck/classify.rs | 305 +++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 290 deletions(-) create mode 100644 crates/stella-tui/src/deck/classify.rs diff --git a/crates/stella-tui/src/deck.rs b/crates/stella-tui/src/deck.rs index c09682194..9b918eb23 100644 --- a/crates/stella-tui/src/deck.rs +++ b/crates/stella-tui/src/deck.rs @@ -1197,296 +1197,11 @@ impl ActivitySpark { } } -// ── Event → derived attributes ────────────────────────────────────────────── - -/// Activity intensity for the sparkline, by event kind. Edits and tool calls -/// read as "hot"; streaming text as "warm"; metering ticks as "cool". -fn event_intensity(ev: &AgentEvent) -> u8 { - match ev { - AgentEvent::FileChange { .. } => 255, - AgentEvent::ToolStart { .. } | AgentEvent::ToolResult { .. } => 210, - AgentEvent::Stage { .. } => 170, - AgentEvent::Text { .. } | AgentEvent::TextDelta { .. } => 130, - AgentEvent::Reasoning { .. } => 90, - AgentEvent::Commit { .. } | AgentEvent::Pr { .. } => 230, - AgentEvent::BudgetTick { .. } | AgentEvent::StepUsage { .. } => 60, - AgentEvent::Error { .. } => 255, - // A proof step is a decision the run reached, not work it did. It is - // real activity on the rail and none on the sparkline — pitched with - // the stage boundaries it interleaves with, so a well-proven turn does - // not read as busier than an unproven one doing the same edits. - AgentEvent::Proof { .. } => 170, - // A sub-agent bracket is a boundary, pitched with `Stage` for the - // same reason: the child's real work already registers through its - // forwarded tool calls and metering, so pricing the bracket as work - // too would double-count one child as a burst of activity. - AgentEvent::SubAgent { .. } => 170, - // Explicit rather than falling through the wildcard: an undecodable - // event is real activity, so it should register on the sparkline, but - // this build cannot know whether it was hot (an edit) or cool (a - // metering tick). Cool-but-present is the honest reading, and it keeps - // a burst of future events from impersonating heavy edit activity. - AgentEvent::Unknown { .. } => 60, - // A park is the turn deliberately idling — the coolest honest signal, - // pitched with the metering ticks so a long wait never reads as work. - AgentEvent::TurnParked { .. } | AgentEvent::TurnWoken { .. } => 60, - _ => 110, - } -} - -/// Lifecycle status implied by an event, or `None` if it doesn't move the -/// agent's lifecycle. -fn status_from_event(ev: &AgentEvent) -> Option { - match ev { - AgentEvent::Complete { .. } => Some(AgentStatus::Done), - AgentEvent::Error { retryable, .. } => Some(if *retryable { - AgentStatus::Running - } else { - AgentStatus::Failed - }), - // Both user-response gates block the agent until answered — a scope - // review is just as much "needs input" as an ask-user question. - AgentEvent::AskUser { .. } - | AgentEvent::ScopeReview { .. } - | AgentEvent::HunkReview { .. } => Some(AgentStatus::WaitingInput), - AgentEvent::Stage { .. } - | AgentEvent::Text { .. } - | AgentEvent::TextDelta { .. } - | AgentEvent::Reasoning { .. } - | AgentEvent::ToolStart { .. } - | AgentEvent::ToolResult { .. } - // A child turn is the parent working, so the lane stays Running - // rather than falling through to "no lifecycle change". Explicit - // because the wildcard below would otherwise let a long child run — - // whose own narration is filtered out — read as an idle agent. - | AgentEvent::SubAgent { .. } - // A parked turn is the engine actively probing on its own clock — - // alive, not waiting on the user — and the wake precedes the next - // model call. Explicit for the same reason as `SubAgent`: a long - // park emits nothing else, and the lane must not read as dead. - | AgentEvent::TurnParked { .. } - | AgentEvent::TurnWoken { .. } => Some(AgentStatus::Running), - _ => None, - } -} - -/// A trace kind + short human summary for one event. -/// One trace line for a proof step — the same facts the rail folds, in the -/// order they were observed, for the reader who wants the history the rail -/// deliberately discards. -fn trace_of(ev: &AgentEvent) -> (TraceKind, String) { - use stella_protocol::ToolOutput; - match ev { - // An event from a newer stella: name it, claim nothing about it. - AgentEvent::Unknown { event_type, .. } => { - (TraceKind::Other, format!("unrecognized `{event_type}`")) - } - AgentEvent::Stage { name } => (TraceKind::Stage, format!("{name:?}").to_lowercase()), - AgentEvent::Text { text } => (TraceKind::Text, snip(text)), - // Mapped for completeness; `apply_event` never traces deltas (one - // row per token would churn the capped ring — see the guard there). - AgentEvent::TextDelta { delta } => (TraceKind::Text, snip(delta)), - AgentEvent::Reasoning { delta } => (TraceKind::Reasoning, snip(delta)), - AgentEvent::ToolStart { call } => (TraceKind::Tool, format!("{}()", call.name)), - AgentEvent::SpeculationDiscarded { name, reason, .. } => { - (TraceKind::Tool, format!("discarded {name} ({reason})")) - } - AgentEvent::LoopDetected { - kind, - repeats, - aborted, - .. - } => ( - TraceKind::Other, - format!( - "loop {kind} ×{repeats}{}", - if *aborted { - " — aborted" - } else { - " — steered" - } - ), - ), - AgentEvent::BudgetDenied { - spent_usd, - limit_usd, - .. - } => ( - TraceKind::Other, - format!("budget denied ${spent_usd:.4}/${limit_usd:.2}"), - ), - AgentEvent::RetriesExhausted { - attempts, - retryable, - .. - } => ( - TraceKind::Other, - if *retryable { - format!("retries exhausted ({attempts})") - } else { - format!( - "terminal failure, not retryable ({attempts} attempt{})", - if *attempts == 1 { "" } else { "s" } - ) - }, - ), - AgentEvent::PolicyDecision { kind, subject, .. } => { - (TraceKind::Other, format!("policy {kind:?}: {subject}")) - } - AgentEvent::ToolResult { - output, - duration_ms, - .. - } => { - let ok = matches!(output, ToolOutput::Ok { .. }); - ( - TraceKind::Tool, - format!("{} in {duration_ms}ms", if ok { "ok" } else { "err" }), - ) - } - AgentEvent::FileChange { - path, - kind, - added, - removed, - .. - } => ( - TraceKind::File, - format!("{kind:?} {path} +{added}/-{removed}").to_lowercase(), - ), - AgentEvent::BudgetTick { spent_usd, .. } => (TraceKind::Budget, format!("${spent_usd:.4}")), - AgentEvent::StepUsage { - model, cost_usd, .. - } => (TraceKind::Budget, format!("{model} ${cost_usd:.4}")), - AgentEvent::ContextRecall { frames, tokens, .. } => ( - TraceKind::Context, - format!("{} frames, {tokens} tok", frames.len()), - ), - AgentEvent::ContextWrite { - upserts, - superseded, - .. - } => (TraceKind::Context, format!("+{upserts} ~{superseded}")), - // Receipts are filtered out of the trace ring above (apply_event's - // guard); these arms exist only to keep this mapping total. - AgentEvent::BlockRegistered { kind, .. } => { - (TraceKind::Context, format!("block {kind:?}").to_lowercase()) - } - AgentEvent::StepManifest { step, blocks, .. } => ( - TraceKind::Context, - format!("manifest step {step}: {} blocks", blocks.len()), - ), - // Traced under Verdict, the kind that already means "what this run - // established": the steps and the verdict are one story, and the trace - // log is where a reader reconstructs how the rail got where it is. - AgentEvent::Proof { step } => (TraceKind::Verdict, crate::proof::proof_trace(step)), - AgentEvent::Verdict { passed, .. } => ( - TraceKind::Verdict, - if *passed { - "passed".into() - } else { - "failed".into() - }, - ), - AgentEvent::GoalVerdict { met, round, .. } => ( - TraceKind::Verdict, - format!("round {round} {}", if *met { "met" } else { "unmet" }), - ), - AgentEvent::MediaProgress { kind, .. } => { - (TraceKind::Media, format!("{kind:?}").to_lowercase()) - } - AgentEvent::MediaComplete { artifact } => (TraceKind::Media, artifact.label.clone()), - AgentEvent::Commit { message, .. } => (TraceKind::Vcs, snip(message)), - AgentEvent::Pr { status, .. } => (TraceKind::Vcs, format!("pr {status:?}").to_lowercase()), - AgentEvent::TaskUpdate { tasks } => { - let done = tasks.iter().filter(|t| !t.status.is_open()).count(); - (TraceKind::Other, format!("tasks {done}/{}", tasks.len())) - } - // A sub-agent bracket is the only trace of a child turn — its own - // events are filtered at the parent boundary — so it names the child - // and, on the way out, what the parent saved by not carrying its work. - AgentEvent::SubAgent { phase } => { - use stella_protocol::SubAgentPhase; - ( - TraceKind::Other, - match phase { - SubAgentPhase::Started { agent_id, .. } => format!("sub-agent {agent_id} ↴"), - SubAgentPhase::Finished { - agent_id, - status, - absorbed_messages, - .. - } => format!( - "sub-agent {agent_id} {} ({absorbed_messages} msgs absorbed)", - format!("{status:?}").to_lowercase() - ), - }, - ) - } - AgentEvent::ProviderFallback { from, to, .. } => { - (TraceKind::Other, format!("fallback {from}→{to}")) - } - AgentEvent::Retry { attempt, .. } => (TraceKind::Other, format!("retry #{attempt}")), - AgentEvent::Steered { text } => ( - TraceKind::Other, - format!("steer: {}", text.chars().take(40).collect::()), - ), - AgentEvent::TurnParked { - description, - poll_interval_secs, - deadline_secs, - } => ( - TraceKind::Other, - format!( - "parked: {} (every {poll_interval_secs}s, up to {deadline_secs}s)", - description.chars().take(40).collect::() - ), - ), - AgentEvent::TurnWoken { reason, polls_used } => ( - TraceKind::Other, - format!("woke: {reason} after {polls_used} probes"), - ), - AgentEvent::Compaction { - before_tokens, - after_tokens, - .. - } => ( - TraceKind::Other, - format!("compact {before_tokens}→{after_tokens}"), - ), - AgentEvent::UsageIncomplete { reason, .. } => { - (TraceKind::Other, format!("usage incomplete: {reason:?}")) - } - AgentEvent::ScopeReview { proposal } => (TraceKind::Stage, snip(&proposal.summary)), - AgentEvent::HunkReview { proposal } => ( - TraceKind::Stage, - format!( - "review {} hunk{} from {}", - proposal.hunks.len(), - if proposal.hunks.len() == 1 { "" } else { "s" }, - proposal.tool - ), - ), - AgentEvent::AskUser { question, .. } => (TraceKind::Other, snip(question)), - AgentEvent::Error { message, .. } => (TraceKind::Error, snip(message)), - AgentEvent::Complete { model, cost_usd } => { - (TraceKind::Complete, format!("{model} ${cost_usd:.4}")) - } - } -} - -/// A one-line, length-capped snip of free text for a trace row. -fn snip(text: &str) -> String { - const MAX: usize = 80; - let flat = text.replace(['\n', '\r'], " "); - let flat = flat.trim(); - if flat.chars().count() <= MAX { - flat.to_string() - } else { - let head: String = flat.chars().take(MAX - 1).collect(); - format!("{head}…") - } -} +// The pure event → derived-attribute classifiers moved to their own module +// when #2007's parked-wait clock pushed this file against its size ceiling; +// re-imported so call sites hold. +mod classify; +use classify::{event_intensity, snip, status_from_event, trace_of}; #[cfg(test)] mod tests; diff --git a/crates/stella-tui/src/deck/classify.rs b/crates/stella-tui/src/deck/classify.rs new file mode 100644 index 000000000..1baa79c98 --- /dev/null +++ b/crates/stella-tui/src/deck/classify.rs @@ -0,0 +1,305 @@ +//! Pure `AgentEvent` classifiers: the derived attributes the workspace fold +//! reads off an event without holding any state of its own — sparkline +//! intensity, implied lifecycle status, and the unified trace row. +//! +//! Split out of `deck.rs` when #2007's parked-wait clock pushed that file +//! against its 1500-line ceiling — the same `prompt_queue.rs` move, for the +//! same reason: relieve the ratchet by extracting a coherent cluster, never by +//! raising it. A pure move; the four functions are byte-identical to the ones +//! they replaced apart from the `pub(super)` a child module needs, and +//! [`super`] re-imports them so every call site is unchanged. + +use stella_protocol::AgentEvent; + +use super::TraceKind; +use crate::envelope::AgentStatus; + +/// Activity intensity for the sparkline, by event kind. Edits and tool calls +/// read as "hot"; streaming text as "warm"; metering ticks as "cool". +pub(super) fn event_intensity(ev: &AgentEvent) -> u8 { + match ev { + AgentEvent::FileChange { .. } => 255, + AgentEvent::ToolStart { .. } | AgentEvent::ToolResult { .. } => 210, + AgentEvent::Stage { .. } => 170, + AgentEvent::Text { .. } | AgentEvent::TextDelta { .. } => 130, + AgentEvent::Reasoning { .. } => 90, + AgentEvent::Commit { .. } | AgentEvent::Pr { .. } => 230, + AgentEvent::BudgetTick { .. } | AgentEvent::StepUsage { .. } => 60, + AgentEvent::Error { .. } => 255, + // A proof step is a decision the run reached, not work it did. It is + // real activity on the rail and none on the sparkline — pitched with + // the stage boundaries it interleaves with, so a well-proven turn does + // not read as busier than an unproven one doing the same edits. + AgentEvent::Proof { .. } => 170, + // A sub-agent bracket is a boundary, pitched with `Stage` for the + // same reason: the child's real work already registers through its + // forwarded tool calls and metering, so pricing the bracket as work + // too would double-count one child as a burst of activity. + AgentEvent::SubAgent { .. } => 170, + // Explicit rather than falling through the wildcard: an undecodable + // event is real activity, so it should register on the sparkline, but + // this build cannot know whether it was hot (an edit) or cool (a + // metering tick). Cool-but-present is the honest reading, and it keeps + // a burst of future events from impersonating heavy edit activity. + AgentEvent::Unknown { .. } => 60, + // A park is the turn deliberately idling — the coolest honest signal, + // pitched with the metering ticks so a long wait never reads as work. + AgentEvent::TurnParked { .. } | AgentEvent::TurnWoken { .. } => 60, + _ => 110, + } +} + +/// Lifecycle status implied by an event, or `None` if it doesn't move the +/// agent's lifecycle. +pub(super) fn status_from_event(ev: &AgentEvent) -> Option { + match ev { + AgentEvent::Complete { .. } => Some(AgentStatus::Done), + AgentEvent::Error { retryable, .. } => Some(if *retryable { + AgentStatus::Running + } else { + AgentStatus::Failed + }), + // Both user-response gates block the agent until answered — a scope + // review is just as much "needs input" as an ask-user question. + AgentEvent::AskUser { .. } + | AgentEvent::ScopeReview { .. } + | AgentEvent::HunkReview { .. } => Some(AgentStatus::WaitingInput), + AgentEvent::Stage { .. } + | AgentEvent::Text { .. } + | AgentEvent::TextDelta { .. } + | AgentEvent::Reasoning { .. } + | AgentEvent::ToolStart { .. } + | AgentEvent::ToolResult { .. } + // A child turn is the parent working, so the lane stays Running + // rather than falling through to "no lifecycle change". Explicit + // because the wildcard below would otherwise let a long child run — + // whose own narration is filtered out — read as an idle agent. + | AgentEvent::SubAgent { .. } + // A parked turn is the engine actively probing on its own clock — + // alive, not waiting on the user — and the wake precedes the next + // model call. Explicit for the same reason as `SubAgent`: a long + // park emits nothing else, and the lane must not read as dead. + | AgentEvent::TurnParked { .. } + | AgentEvent::TurnWoken { .. } => Some(AgentStatus::Running), + _ => None, + } +} + +/// A trace kind + short human summary for one event. +/// One trace line for a proof step — the same facts the rail folds, in the +/// order they were observed, for the reader who wants the history the rail +/// deliberately discards. +pub(super) fn trace_of(ev: &AgentEvent) -> (TraceKind, String) { + use stella_protocol::ToolOutput; + match ev { + // An event from a newer stella: name it, claim nothing about it. + AgentEvent::Unknown { event_type, .. } => { + (TraceKind::Other, format!("unrecognized `{event_type}`")) + } + AgentEvent::Stage { name } => (TraceKind::Stage, format!("{name:?}").to_lowercase()), + AgentEvent::Text { text } => (TraceKind::Text, snip(text)), + // Mapped for completeness; `apply_event` never traces deltas (one + // row per token would churn the capped ring — see the guard there). + AgentEvent::TextDelta { delta } => (TraceKind::Text, snip(delta)), + AgentEvent::Reasoning { delta } => (TraceKind::Reasoning, snip(delta)), + AgentEvent::ToolStart { call } => (TraceKind::Tool, format!("{}()", call.name)), + AgentEvent::SpeculationDiscarded { name, reason, .. } => { + (TraceKind::Tool, format!("discarded {name} ({reason})")) + } + AgentEvent::LoopDetected { + kind, + repeats, + aborted, + .. + } => ( + TraceKind::Other, + format!( + "loop {kind} ×{repeats}{}", + if *aborted { + " — aborted" + } else { + " — steered" + } + ), + ), + AgentEvent::BudgetDenied { + spent_usd, + limit_usd, + .. + } => ( + TraceKind::Other, + format!("budget denied ${spent_usd:.4}/${limit_usd:.2}"), + ), + AgentEvent::RetriesExhausted { + attempts, + retryable, + .. + } => ( + TraceKind::Other, + if *retryable { + format!("retries exhausted ({attempts})") + } else { + format!( + "terminal failure, not retryable ({attempts} attempt{})", + if *attempts == 1 { "" } else { "s" } + ) + }, + ), + AgentEvent::PolicyDecision { kind, subject, .. } => { + (TraceKind::Other, format!("policy {kind:?}: {subject}")) + } + AgentEvent::ToolResult { + output, + duration_ms, + .. + } => { + let ok = matches!(output, ToolOutput::Ok { .. }); + ( + TraceKind::Tool, + format!("{} in {duration_ms}ms", if ok { "ok" } else { "err" }), + ) + } + AgentEvent::FileChange { + path, + kind, + added, + removed, + .. + } => ( + TraceKind::File, + format!("{kind:?} {path} +{added}/-{removed}").to_lowercase(), + ), + AgentEvent::BudgetTick { spent_usd, .. } => (TraceKind::Budget, format!("${spent_usd:.4}")), + AgentEvent::StepUsage { + model, cost_usd, .. + } => (TraceKind::Budget, format!("{model} ${cost_usd:.4}")), + AgentEvent::ContextRecall { frames, tokens, .. } => ( + TraceKind::Context, + format!("{} frames, {tokens} tok", frames.len()), + ), + AgentEvent::ContextWrite { + upserts, + superseded, + .. + } => (TraceKind::Context, format!("+{upserts} ~{superseded}")), + // Receipts are filtered out of the trace ring above (apply_event's + // guard); these arms exist only to keep this mapping total. + AgentEvent::BlockRegistered { kind, .. } => { + (TraceKind::Context, format!("block {kind:?}").to_lowercase()) + } + AgentEvent::StepManifest { step, blocks, .. } => ( + TraceKind::Context, + format!("manifest step {step}: {} blocks", blocks.len()), + ), + // Traced under Verdict, the kind that already means "what this run + // established": the steps and the verdict are one story, and the trace + // log is where a reader reconstructs how the rail got where it is. + AgentEvent::Proof { step } => (TraceKind::Verdict, crate::proof::proof_trace(step)), + AgentEvent::Verdict { passed, .. } => ( + TraceKind::Verdict, + if *passed { + "passed".into() + } else { + "failed".into() + }, + ), + AgentEvent::GoalVerdict { met, round, .. } => ( + TraceKind::Verdict, + format!("round {round} {}", if *met { "met" } else { "unmet" }), + ), + AgentEvent::MediaProgress { kind, .. } => { + (TraceKind::Media, format!("{kind:?}").to_lowercase()) + } + AgentEvent::MediaComplete { artifact } => (TraceKind::Media, artifact.label.clone()), + AgentEvent::Commit { message, .. } => (TraceKind::Vcs, snip(message)), + AgentEvent::Pr { status, .. } => (TraceKind::Vcs, format!("pr {status:?}").to_lowercase()), + AgentEvent::TaskUpdate { tasks } => { + let done = tasks.iter().filter(|t| !t.status.is_open()).count(); + (TraceKind::Other, format!("tasks {done}/{}", tasks.len())) + } + // A sub-agent bracket is the only trace of a child turn — its own + // events are filtered at the parent boundary — so it names the child + // and, on the way out, what the parent saved by not carrying its work. + AgentEvent::SubAgent { phase } => { + use stella_protocol::SubAgentPhase; + ( + TraceKind::Other, + match phase { + SubAgentPhase::Started { agent_id, .. } => format!("sub-agent {agent_id} ↴"), + SubAgentPhase::Finished { + agent_id, + status, + absorbed_messages, + .. + } => format!( + "sub-agent {agent_id} {} ({absorbed_messages} msgs absorbed)", + format!("{status:?}").to_lowercase() + ), + }, + ) + } + AgentEvent::ProviderFallback { from, to, .. } => { + (TraceKind::Other, format!("fallback {from}→{to}")) + } + AgentEvent::Retry { attempt, .. } => (TraceKind::Other, format!("retry #{attempt}")), + AgentEvent::Steered { text } => ( + TraceKind::Other, + format!("steer: {}", text.chars().take(40).collect::()), + ), + AgentEvent::TurnParked { + description, + poll_interval_secs, + deadline_secs, + } => ( + TraceKind::Other, + format!( + "parked: {} (every {poll_interval_secs}s, up to {deadline_secs}s)", + description.chars().take(40).collect::() + ), + ), + AgentEvent::TurnWoken { reason, polls_used } => ( + TraceKind::Other, + format!("woke: {reason} after {polls_used} probes"), + ), + AgentEvent::Compaction { + before_tokens, + after_tokens, + .. + } => ( + TraceKind::Other, + format!("compact {before_tokens}→{after_tokens}"), + ), + AgentEvent::UsageIncomplete { reason, .. } => { + (TraceKind::Other, format!("usage incomplete: {reason:?}")) + } + AgentEvent::ScopeReview { proposal } => (TraceKind::Stage, snip(&proposal.summary)), + AgentEvent::HunkReview { proposal } => ( + TraceKind::Stage, + format!( + "review {} hunk{} from {}", + proposal.hunks.len(), + if proposal.hunks.len() == 1 { "" } else { "s" }, + proposal.tool + ), + ), + AgentEvent::AskUser { question, .. } => (TraceKind::Other, snip(question)), + AgentEvent::Error { message, .. } => (TraceKind::Error, snip(message)), + AgentEvent::Complete { model, cost_usd } => { + (TraceKind::Complete, format!("{model} ${cost_usd:.4}")) + } + } +} + +/// A one-line, length-capped snip of free text for a trace row. +pub(super) fn snip(text: &str) -> String { + const MAX: usize = 80; + let flat = text.replace(['\n', '\r'], " "); + let flat = flat.trim(); + if flat.chars().count() <= MAX { + flat.to_string() + } else { + let head: String = flat.chars().take(MAX - 1).collect(); + format!("{head}…") + } +} + From 00c0a7f46111da2226c908853b1e52f73cafa7f5 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 21:37:17 -0700 Subject: [PATCH 3/3] =?UTF-8?q?feat(stella-tui):=20a=20live=20parked-wait?= =?UTF-8?q?=20heartbeat=20=E2=80=94=20show=20the=20clock=20running=20down,?= =?UTF-8?q?=20not=20just=20the=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1994 shipped the chip; this is the clock. A park can last up to its deadline (30 minutes is an ordinary `deadline_secs`) and the engine emits *nothing* for the whole span — it sleeps, replays a read-only probe, and loops. So the deck drew `⏳ parked until CI for branch main settles · every 30s, up to 1800s` and then sat motionless for half an hour. The row stated the BUDGET and never the ELAPSED, so a park ten seconds old and one twenty-nine minutes into its deadline read identically, and a genuinely wedged engine read like both. Option 1 of the three the issue weighed, split so that no clock is ever read inside a fold: - `SessionModel::parked: Option` — the pure *what*, set by `TurnParked` and cleared by `TurnWoken`. A turn ending closes an open span too, because `driver::waiting` returns WITHOUT a wake when the turn is cancelled or soft-stopped; a retryable error is mid-flight and leaves it alone, the same reading the proof rail and plan take of that event. L-T1 is untouched: `replay(&log) == replay(&log)` still holds, and `a_parked_wait_folds_into_typed_entries_not_narration` is unchanged. - `deck::AgentEntry::parked_since_ms` — the *when*, stamped from the deck's injected `now_ms` exactly as `turn_started_ms` is. Nothing clears it; `AgentEntry::live_park` gates on the pure fold instead, so a leftover stamp is inert rather than a resurrected chip (there is a test for precisely that). - `render_hud` grows the chip: `⏳ parked 4:12 / 30:00 · CI for branch main settles`. It takes elapsed as a plain number, so it reads no clock and a golden frame can pin it. The countdown lands on the stat box rather than in the transcript on purpose: a transcript is a log of things that happened, and the ⏳ row already written to scrollback has to keep reading as history after the wake — not freeze holding a counter that stopped. The settled park still reads correctly in scrollback, unchanged. Deck goldens are undisturbed: a session with no open park renders byte-for-byte as before, which is also asserted directly. `deck.rs` was at 1492 of the 1500-line guard, so its pure event classifiers moved to `deck/classify.rs` first (previous commit) rather than the ceiling moving. `views/session.rs` is at its own exact ceiling, so its one call site changed in place, one line for one line. Refs #1857, #1471 Closes #2007 --- crates/stella-tui/src/deck.rs | 33 +++++++++- crates/stella-tui/src/deck/classify.rs | 1 - crates/stella-tui/src/deck/tests.rs | 84 ++++++++++++++++++++++++ crates/stella-tui/src/model.rs | 46 +++++++++++++ crates/stella-tui/src/model/tests.rs | 83 ++++++++++++++++++++++++ crates/stella-tui/src/render.rs | 74 ++++++++++++++++++++- crates/stella-tui/src/render/tests.rs | 90 ++++++++++++++++++++++++++ crates/stella-tui/src/views/session.rs | 2 +- 8 files changed, 408 insertions(+), 5 deletions(-) diff --git a/crates/stella-tui/src/deck.rs b/crates/stella-tui/src/deck.rs index 9b918eb23..02036aa12 100644 --- a/crates/stella-tui/src/deck.rs +++ b/crates/stella-tui/src/deck.rs @@ -26,7 +26,7 @@ use stella_protocol::{ }; use crate::envelope::{AgentId, AgentMeta, AgentStatus, Inbound}; -use crate::model::SessionModel; +use crate::model::{OpenPark, SessionModel}; /// The top-level tabs of the deck. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -179,6 +179,17 @@ pub struct AgentEntry { /// turn begins. `None` before any turn has finished, so the header clock /// reads zero at rest. pub last_turn_ms: Option, + /// Deck-clock ms at which the most recent parked wait began (#2007). + /// + /// Stamped like [`Self::turn_started_ms`] and for the same reason: a park + /// runs up to its deadline with the engine emitting **nothing at all**, so + /// the ⏳ countdown has no event to ride and has to count on the deck's own + /// clock. Whether a park is currently *open* is deliberately not this + /// field's business — that is `SessionModel::parked`, folded purely from + /// the event stream — so a stamp left behind by a park that already woke is + /// inert rather than wrong. [`Self::live_park`] is the only place the pure + /// "what" and the stamped "since when" are joined. + pub parked_since_ms: Option, /// [`Self::tokens_out`] as it stood when the live turn began — snapshotted /// with `turn_started_ms` so the progress bar's tok/s divides only the /// turn's own output by the turn's own elapsed (cumulative session tokens @@ -246,6 +257,7 @@ impl AgentEntry { activity: ActivitySpark::new(ACTIVITY_WINDOW), turn_started_ms: None, last_turn_ms: None, + parked_since_ms: None, turn_start_tokens_out: 0, active_task: None, witness_phase_ms: WitnessPhaseStamps::default(), @@ -278,6 +290,18 @@ impl AgentEntry { } } + /// The parked wait currently open and how long it has been running in ms, + /// or `None` when this agent is not parked (#2007). + /// + /// The join the ⏳ chip needs, and the one place the purely-folded "is it + /// parked, and for what" meets the stamped "since when". Gating on the + /// fold rather than on the stamp is what makes a leftover + /// [`Self::parked_since_ms`] harmless. + pub fn live_park(&self, now_ms: u64) -> Option<(&OpenPark, u64)> { + let park = self.model.parked.as_ref()?; + Some((park, now_ms.saturating_sub(self.parked_since_ms?))) + } + /// Spend per hour, or `0.0` before any wall-clock has elapsed. pub fn usd_per_hour(&self, now_ms: u64) -> f64 { let secs = self.elapsed_ms(now_ms) as f64 / 1000.0; @@ -772,6 +796,13 @@ impl WorkspaceModel { entry.status = status; } match event { + // Stamp the park's arrival. This is the whole out-of-band + // half of #2007: the engine goes silent for the length of the + // wait, so the countdown has no later event to ride and the + // deck's own clock is the only source of elapsed. Whether the + // span is still open stays with the pure fold, so nothing + // clears this — see `AgentEntry::live_park`. + AgentEvent::TurnParked { .. } => entry.parked_since_ms = Some(now), AgentEvent::StepUsage { input_tokens, output_tokens, diff --git a/crates/stella-tui/src/deck/classify.rs b/crates/stella-tui/src/deck/classify.rs index 1baa79c98..1489dc9de 100644 --- a/crates/stella-tui/src/deck/classify.rs +++ b/crates/stella-tui/src/deck/classify.rs @@ -302,4 +302,3 @@ pub(super) fn snip(text: &str) -> String { format!("{head}…") } } - diff --git a/crates/stella-tui/src/deck/tests.rs b/crates/stella-tui/src/deck/tests.rs index a88d286ad..39a5f4363 100644 --- a/crates/stella-tui/src/deck/tests.rs +++ b/crates/stella-tui/src/deck/tests.rs @@ -902,3 +902,87 @@ fn a_bang_shell_event_for_an_unknown_lane_is_dropped_not_auto_registered() { "and none is misrouted" ); } + +/// The witness for #2007. A parked wait runs up to its deadline with the +/// engine emitting **nothing at all** — one `TurnParked` in, one `TurnWoken` +/// out, and up to half an hour of silence between them. So the countdown +/// cannot ride an event; it has to run on the deck's own clock, and this is +/// the assertion that it does: elapsed advances with `now_ms` alone, with no +/// further input of any kind. +#[test] +fn the_park_clock_advances_on_the_decks_own_clock_with_no_further_events() { + let mut w = WorkspaceModel::new(); + w.apply_inbound(®("lead")); + w.now_ms = 1_000; + w.apply_inbound(&ev( + "lead", + AgentEvent::TurnParked { + description: "CI for branch main settles".into(), + poll_interval_secs: 30, + deadline_secs: 1_800, + }, + )); + + let (park, elapsed) = w.agents[0] + .live_park(w.now_ms) + .expect("the lane is parked the moment the event lands"); + assert_eq!(elapsed, 0, "the park has only just begun"); + assert_eq!(park.deadline_secs, 1_800); + + // Not one further event — only the shell's clock tick, which is all a + // parked engine gives us. + w.now_ms = 253_000; + let (_, elapsed) = w.agents[0].live_park(w.now_ms).expect("still parked"); + assert_eq!( + elapsed, 252_000, + "the chip counts up with no new event; before #2007 there was nothing \ + here to count" + ); + + w.now_ms = 300_000; + w.apply_inbound(&ev( + "lead", + AgentEvent::TurnWoken { + reason: "changed".into(), + polls_used: 8, + }, + )); + assert!( + w.agents[0].live_park(w.now_ms).is_none(), + "the wake stops the clock" + ); +} + +/// The stamp is deliberately never cleared, so the *gate* has to be the pure +/// fold. A park that already settled must not resurrect as a live chip just +/// because the lane still remembers when the last one started. +#[test] +fn a_stale_park_stamp_cannot_resurrect_a_settled_park() { + let mut w = WorkspaceModel::new(); + w.apply_inbound(®("lead")); + w.now_ms = 1_000; + w.apply_inbound(&ev( + "lead", + AgentEvent::TurnParked { + description: "CI settles".into(), + poll_interval_secs: 30, + deadline_secs: 600, + }, + )); + w.apply_inbound(&ev( + "lead", + AgentEvent::TurnWoken { + reason: "deadline_expired".into(), + polls_used: 20, + }, + )); + + assert!( + w.agents[0].parked_since_ms.is_some(), + "the stamp is left behind by design — nothing clears it" + ); + assert!( + w.agents[0].live_park(999_999).is_none(), + "and it is inert, because `live_park` gates on the pure fold" + ); +} diff --git a/crates/stella-tui/src/model.rs b/crates/stella-tui/src/model.rs index a88504fb5..87f88ddf8 100644 --- a/crates/stella-tui/src/model.rs +++ b/crates/stella-tui/src/model.rs @@ -149,6 +149,35 @@ pub struct SessionModel { /// `OUTPUT_BUDGET` so an unbounded stream can't grow per-frame render /// cost; the authoritative `Text` entry is never capped by this. pub streaming_text: String, + /// The parked wait currently open (#1471, #2007), or `None` when the turn + /// is not parked. Set by `TurnParked`, cleared by `TurnWoken` and by the + /// turn ending — a park that is cancelled or soft-stopped out of never + /// gets its wake, so `Complete`/`Error` have to close the span too. + /// + /// The *what* of a live park; deliberately not the *when*. A park lasts up + /// to its deadline with the engine emitting nothing at all, so the deck's + /// countdown needs a clock — and reading one here would break the property + /// this whole fold rests on (`replay(&log) == replay(&log)`, L-T1). The + /// timestamp is stamped outside, from the deck's injected clock + /// (`deck::AgentEntry::parked_since_ms`), exactly as `turn_started_ms` is. + pub parked: Option, +} + +/// A parked wait that has not woken yet — the live half of the ⏳ chip. +/// +/// Carries the park's own parameters rather than a reference into the +/// transcript, because the row and the chip answer different questions: the +/// transcript row is the log of a thing that happened, this is current state, +/// and the settled row must keep reading as history once the wake lands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenPark { + /// What the wait is for, as the tool described it. + pub description: String, + /// Seconds between engine-side probes of the watched state. + pub poll_interval_secs: u64, + /// Seconds the park may last before it wakes with a timeout — the + /// denominator of the countdown. + pub deadline_secs: u64, } /// A pending `ask_user` question. The renderer contract is binding: present @@ -663,12 +692,20 @@ impl SessionModel { poll_interval_secs: *poll_interval_secs, deadline_secs: *deadline_secs, }); + // …and the live state the row cannot carry: the transcript is + // a log, and a countdown is not a thing that happened (#2007). + self.parked = Some(OpenPark { + description: description.clone(), + poll_interval_secs: *poll_interval_secs, + deadline_secs: *deadline_secs, + }); } AgentEvent::TurnWoken { reason, polls_used } => { self.transcript.push(TranscriptEntry::Woken { reason: reason.clone(), polls_used: *polls_used, }); + self.parked = None; } AgentEvent::Compaction { before_tokens, @@ -936,6 +973,12 @@ impl SessionModel { self.pending_scope_review = None; self.pending_ask_user = None; self.pending_hunk_review = None; + // A park that the turn was cancelled or soft-stopped out of + // never gets its `TurnWoken`, so the span has to close here or + // the ⏳ chip counts up forever on a dead turn (#2007). + if !*retryable { + self.parked = None; + } // An aborted model call never commits its text — without // this the un-committed preview would linger indefinitely. self.streaming_text.clear(); @@ -962,6 +1005,9 @@ impl SessionModel { self.pending_scope_review = None; self.pending_ask_user = None; self.pending_hunk_review = None; + // The turn is over; a span still open here was one the turn + // never woke from (#2007). + self.parked = None; self.streaming_text.clear(); self.transcript.push(TranscriptEntry::Complete { model: model.clone(), diff --git a/crates/stella-tui/src/model/tests.rs b/crates/stella-tui/src/model/tests.rs index 30a841fd5..846d3adbc 100644 --- a/crates/stella-tui/src/model/tests.rs +++ b/crates/stella-tui/src/model/tests.rs @@ -59,6 +59,89 @@ fn a_parked_wait_folds_into_typed_entries_not_narration() { } } +/// The live half of #2007: alongside the scrollback rows, the fold carries +/// *whether a park is open right now* and what it is waiting on. +/// +/// The transcript cannot answer that on its own — it is a log, so both a park +/// that is still running and one that woke an hour ago leave the same ⏳ row. +/// The chip needs current state, and this is the pure half of it (the clock is +/// stamped outside, in `deck::AgentEntry::parked_since_ms`). +#[test] +fn an_open_park_is_live_state_and_the_wake_closes_it() { + let mut model = SessionModel::new(); + assert!(model.parked.is_none(), "a fresh model is not parked"); + + model.apply(&AgentEvent::TurnParked { + description: "CI for branch main settles".into(), + poll_interval_secs: 30, + deadline_secs: 1800, + }); + let park = model.parked.as_ref().expect("the park is open"); + assert_eq!(park.description, "CI for branch main settles"); + assert_eq!(park.poll_interval_secs, 30); + assert_eq!(park.deadline_secs, 1800, "the countdown's denominator"); + + model.apply(&AgentEvent::TurnWoken { + reason: "changed".into(), + polls_used: 41, + }); + assert!(model.parked.is_none(), "the wake closes the span"); + // …and the scrollback still reads as history afterwards. + assert!( + matches!(model.transcript.last(), Some(TranscriptEntry::Woken { .. })), + "{:?}", + model.transcript + ); +} + +/// A park the turn was cancelled or soft-stopped out of never gets its +/// `TurnWoken` — `driver::waiting` returns early on both paths. Without a +/// close here the ⏳ chip would count up forever on a turn that is over. +#[test] +fn a_turn_that_ends_mid_park_closes_the_span_anyway() { + for terminal in [ + AgentEvent::Complete { + model: "m".into(), + cost_usd: 0.0, + }, + AgentEvent::Error { + message: "cancelled".into(), + retryable: false, + }, + ] { + let mut model = SessionModel::new(); + model.apply(&AgentEvent::TurnParked { + description: "the deploy finishes".into(), + poll_interval_secs: 10, + deadline_secs: 600, + }); + assert!(model.parked.is_some()); + model.apply(&terminal); + assert!( + model.parked.is_none(), + "a turn ending mid-park must close the span: {terminal:?}" + ); + } +} + +/// A *retryable* error is a warning mid-flight, not the end of the turn, so it +/// must leave an open park alone — the same reading the proof rail and the +/// plan take of the identical event. +#[test] +fn a_retryable_error_does_not_close_an_open_park() { + let mut model = SessionModel::new(); + model.apply(&AgentEvent::TurnParked { + description: "CI settles".into(), + poll_interval_secs: 10, + deadline_secs: 600, + }); + model.apply(&AgentEvent::Error { + message: "429".into(), + retryable: true, + }); + assert!(model.parked.is_some(), "the wait is still running"); +} + #[test] fn streaming_text_deltas_coalesce_into_one_entry() { let mut model = SessionModel::new(); diff --git a/crates/stella-tui/src/render.rs b/crates/stella-tui/src/render.rs index 8e2971ea5..1cb2ed6fa 100644 --- a/crates/stella-tui/src/render.rs +++ b/crates/stella-tui/src/render.rs @@ -25,7 +25,7 @@ use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap}; use crate::composer::SlashMenu; -use crate::model::{AskUserPrompt, FileState, Hud, InlineDiffRef}; +use crate::model::{AskUserPrompt, FileState, Hud, InlineDiffRef, OpenPark}; use crate::textline::{self, budget_mode_label, stage_label}; mod entry; @@ -69,7 +69,21 @@ pub(crate) const HUD_H: u16 = 3; /// statline's own meter row, not here. They were briefly duplicated in this /// box as a second set of gauges; two renderings of the same four numbers, in /// two different bar glyphs, on one frame is worse than either alone. -pub(crate) fn render_hud(hud: &Hud, area: Rect, buf: &mut Buffer) { +/// +/// `parked` is the live parked-wait chip (#2007): the open wait and how long +/// it has been running, from [`crate::deck::AgentEntry::live_park`]. It belongs +/// on this box rather than in the transcript because it is *current state*, and +/// a transcript is a log of things that happened — the ⏳ row already written +/// to scrollback must keep reading as history after the wake, not freeze +/// holding a counter that stopped. The elapsed arrives as a plain number so +/// this stays a pure function of its arguments: it reads no clock, which is +/// also what lets a golden frame pin it. +pub(crate) fn render_hud( + hud: &Hud, + parked: Option<(&OpenPark, u64)>, + area: Rect, + buf: &mut Buffer, +) { let label = Style::new().fg(theme::TEXT_TERTIARY); let mut spans: Vec> = vec![ Span::styled("stage ", label), @@ -105,6 +119,29 @@ pub(crate) fn render_hud(hud: &Hud, area: Rect, buf: &mut Buffer) { Style::new().fg(theme::OK).add_modifier(Modifier::BOLD), )); } + // The live park, when there is one. Elapsed against the deadline is the + // whole point: the transcript row states the *budget* ("up to 1800s") and + // then sits motionless for half an hour, so a park that started ten + // seconds ago and one twenty-nine minutes into its deadline used to read + // identically — and a genuinely wedged engine read like both. + if let Some((park, elapsed_ms)) = parked { + spans.push(Span::styled( + " ⏳ parked ", + Style::new().fg(theme::ACCENT).add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!( + "{} / {}", + clock_ms(elapsed_ms), + clock_ms(park.deadline_secs.saturating_mul(1000)) + ), + Style::new().fg(theme::ACCENT), + )); + spans.push(Span::styled( + format!(" · {}", park_subject(&park.description)), + label, + )); + } let block = Block::default() .borders(Borders::ALL) .border_style(theme::rule()) @@ -114,6 +151,39 @@ pub(crate) fn render_hud(hud: &Hud, area: Rect, buf: &mut Buffer) { .render(area, buf); } +/// `M:SS`, minutes growing past two digits, rolling to `H:MM:SS` past an hour +/// — the parked-wait chip's clock. +/// +/// Both halves of the chip render through this so elapsed and deadline are +/// always comparable at a glance; a `4:12 / 30:00` where the two sides used +/// different units would be worse than no countdown at all. +fn clock_ms(ms: u64) -> String { + let secs = ms / 1000; + let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + +/// The park's subject, capped for a single-line stat box. +/// +/// The full description already sits one row away in the transcript's ⏳ entry, +/// so this only has to say *which* wait is running; a tool free to write a +/// paragraph must not be able to push the clock off the box. +fn park_subject(description: &str) -> String { + const MAX: usize = 40; + let flat = description.replace(['\n', '\r'], " "); + let flat = flat.trim(); + if flat.chars().count() <= MAX { + flat.to_string() + } else { + let head: String = flat.chars().take(MAX - 1).collect(); + format!("{head}…") + } +} + /// [`render_transcript`] for a caller that already materialized just the /// visible window (the deck's fold cache clones ≤ one viewport of lines per /// frame instead of the whole history); `total` sizes the title. `hint`, when diff --git a/crates/stella-tui/src/render/tests.rs b/crates/stella-tui/src/render/tests.rs index c1bf87dc0..24aa2e3a4 100644 --- a/crates/stella-tui/src/render/tests.rs +++ b/crates/stella-tui/src/render/tests.rs @@ -221,3 +221,93 @@ fn sample_entries() -> Vec { }, ] } + +/// The stat box's content row, flattened. +/// +/// `⏳` is double-width, so flattening the buffer leaves its trailing filler +/// cell in the string; the assertions below match on the clock rather than on +/// the glyph's spacing for that reason. +fn hud_row(parked: Option<(&OpenPark, u64)>) -> String { + let mut buf = Buffer::empty(Rect::new(0, 0, 120, HUD_H)); + render_hud(&Hud::default(), parked, buf.area, &mut buf); + buffer_rows(&buf).remove(1) +} + +/// The rendering half of #2007's witness: the stat box states elapsed against +/// the deadline, and the readout is a function of elapsed alone — so it moves +/// between two frames with no event in between. +/// +/// The transcript row this complements states only the *budget* ("up to +/// 1800s") and then sits motionless for the length of the wait, which is why a +/// park ten seconds old and one twenty-nine minutes into its deadline used to +/// look the same, and why a wedged engine looked like both. +#[test] +fn the_hud_counts_an_open_park_up_against_its_deadline() { + let park = OpenPark { + description: "CI for branch main settles".into(), + poll_interval_secs: 30, + deadline_secs: 1_800, + }; + + let early = hud_row(Some((&park, 10_000))); + assert!( + early.contains("parked 0:10 / 30:00"), + "elapsed over the deadline, both in one unit: {early}" + ); + assert!( + early.contains('⏳'), + "chipped like the transcript row: {early}" + ); + assert!( + early.contains("CI for branch main settles"), + "and which wait it is: {early}" + ); + + let late = hud_row(Some((&park, 1_752_000))); + assert!(late.contains("parked 29:12 / 30:00"), "{late}"); + assert_ne!( + early, late, + "the same park at two moments must not render identically — that is \ + the whole defect" + ); +} + +/// An hour-long wait rolls to `H:MM:SS` rather than printing `73:20`, and a +/// turn that is not parked pays nothing at all — which is also why the deck's +/// golden frames are undisturbed by this feature. +#[test] +fn the_park_clock_rolls_past_an_hour_and_is_absent_when_not_parked() { + let long = OpenPark { + description: "the nightly suite finishes".into(), + poll_interval_secs: 60, + deadline_secs: 7_200, + }; + let row = hud_row(Some((&long, 4_400_000))); + assert!(row.contains("parked 1:13:20 / 2:00:00"), "{row}"); + + let idle = hud_row(None); + assert!( + !idle.contains("parked"), + "no chip when nothing is parked: {idle}" + ); +} + +/// A tool is free to write a paragraph into `TurnParked.description`, and the +/// stat box is one line. The subject is capped so the clock — the part that +/// cannot be read anywhere else — is never the thing pushed off the row. +#[test] +fn a_long_park_description_cannot_push_the_clock_off_the_box() { + let wordy = OpenPark { + description: "the continuous integration pipeline for the release \ + branch reaches a terminal state" + .into(), + poll_interval_secs: 30, + deadline_secs: 600, + }; + let row = hud_row(Some((&wordy, 65_000))); + assert!(row.contains("parked 1:05 / 10:00"), "{row}"); + assert!( + row.contains('…'), + "the subject is elided, not the clock: {row}" + ); +} diff --git a/crates/stella-tui/src/views/session.rs b/crates/stella-tui/src/views/session.rs index 4e02eaef6..054c4e7b9 100644 --- a/crates/stella-tui/src/views/session.rs +++ b/crates/stella-tui/src/views/session.rs @@ -408,7 +408,7 @@ pub fn render(model: &WorkspaceModel, ui: &mut DeckUi, area: Rect, buf: &mut Buf if subs_h > 0 { crate::views::subagents::render(model, ui, bands[1], buf); } - render_hud(&sm.hud, bands[2], buf); + render_hud(&sm.hud, agent.live_park(model.now_ms), bands[2], buf); if let Some(prompt) = &sm.pending_ask_user { let answered = ui.ask_answered.contains(&agent.meta.id); render_ask_user(prompt, answered, bands[3], buf);