Skip to content

feat(stella-serve,stella-tui): make a parked wait visible — count it in the turn tally, and run its clock on the deck (#2006, #2007) - #2040

Merged
macanderson merged 3 commits into
mainfrom
feat/2006-2007-parked-wait-observability
Aug 7, 2026
Merged

feat(stella-serve,stella-tui): make a parked wait visible — count it in the turn tally, and run its clock on the deck (#2006, #2007)#2040
macanderson merged 3 commits into
mainfrom
feat/2006-2007-parked-wait-observability

Conversation

@macanderson

@macanderson macanderson commented Aug 7, 2026

Copy link
Copy Markdown
Owner

What & why

Both remaining consumers from #1857 that still cannot see a parked wait. They are one logical change — "make a park visible to the surfaces that read a stall" — approached from the two ends the #1857 sweep left open, and the issues themselves note they are worth sequencing together.

#2006stella-serve: the turn tally could not tell a parked turn from a wedged one.
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. 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, additive and zero-skipping so a record written before this parses unchanged:

field why
parked_spans / parked_spans_woken counted apart, not assumed equal: driver::waiting returns without a wake when the turn is cancelled or a soft stop is latched, so a turn can settle mid-span. TurnTally::ended_parked() names that case — "ended inside the wait" is a different diagnosis from "waited and came back".
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 seeing model_calls_total flat and turn_duration_ms_total climbing otherwise cannot tell a fleet waiting on external state from one that is stuck. Fixing the record and leaving the scrape blind would have closed half the gap.

#2007stella-tui: the deck showed the park but not the clock.
#1994 shipped the chip; this is the clock. A park lasts up to its deadline (30 minutes is an ordinary deadline_secs) and the engine emits nothing for the whole span. So the deck drew ⏳ parked until CI for branch main settles · every 30s, up to 1800s and 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 in read identically — and a genuinely wedged engine read like both.

Option 1 of the three the issue weighed, split so no clock is ever read inside a fold:

  • SessionModel::parked: Option<OpenPark> — the pure what. Set by TurnParked, cleared by TurnWoken; a turn ending closes an open span too, because a cancelled or soft-stopped park never gets its wake. A retryable Error is mid-flight and leaves it alone — the same reading the proof rail and the plan already take of that event.
  • 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.
  • render_hud grows the chip: ⏳ parked 4:12 / 30:00 · CI for branch main settles. Elapsed arrives as a plain number, so the renderer reads no clock and a golden frame can pin it.

The countdown lands on the stat box rather than in the transcript deliberately: a transcript is a log of things that happened, and the ⏳ row already in scrollback must keep reading as history after the wake, not freeze holding a counter that stopped.

Closes #2006
Closes #2007

Refs #1857, #1471

The witness

  • This PR includes a witness test (fails on main, passes here)

Three, one per claim:

  • stella-serveobserve::tally::tests::a_parked_turn_is_distinguishable_from_a_wedged_one folds [Stage, TurnParked, TurnWoken] against [Stage] alone and asserts the two tallies differ while stages is equal. On main the two are byte-identical, which is the defect. (a_turn_that_settles_mid_park_leaves_the_span_open and the_park_description_never_reaches_the_tally cover the open-span and content-free halves.)
  • stella-tuideck::tests::the_park_clock_advances_on_the_decks_own_clock_with_no_further_events parks a lane, then advances now_ms alone with no further input of any kind, and asserts elapsed moved. That is the feature stated exactly: on main there is nothing there to count.
  • stella-tuirender::tests::the_hud_counts_an_open_park_up_against_its_deadline renders the same park at two moments and asserts the rows differ and read parked 0:10 / 30:00.

Both features are new fields, so the witnesses do not compile against main rather than failing an assertion — the usual shape for an additive-field feature; the assertions are written so they would fail (not merely not-build) against a version of the field that was never populated.

The gate

  • cargo fmt --check
  • cargo clippy -p stella-serve -p stella-tui --all-targets -- -D warnings
  • cargo test -p stella-serve (168 pass) and cargo test -p stella-tui (814 lib + every integration suite)
  • make guards-fast — all 20 toolchain-free guards, including file-size, god-files, module-reachability and gate-parity
  • Docs updated where behavior changed (doc comments on every new field; no flags or commands changed)
  • Closes #N appears both above and as a commit trailer

Not run locally: cargo test --workspace / cargo clippy --workspace. A Terminal-Bench 2.1 match is running on this machine and a workspace build would perturb the measurement it exists to produce. The exposure is stella-cli, the only crate downstream of the two touched here: both changes are purely additive (new struct fields, one new pub(crate) fn parameter whose sole call site is updated in place), and there is no exhaustive SessionModel/TurnTally/AgentEntry literal anywhere in the workspace — verified by grep. CI is the check on that.

Nothing left behind

  • There is nothing: everything I noticed is fixed in this PR.

One thing worth naming rather than filing: #1858 (RemoteToolExecutor cannot forward parked-wait requests across the wire) is still open, and until it lands a served turn cannot park at all — so #2006's counters are correct-and-unexercised on the serve path today. That is the observability half of the same story arriving first, as #2006 itself describes, not an omission here.

Ground-rule check

  • No I/O added to stella-core (untouched); no new deps
  • No new outbound network calls
  • New cross-boundary types round-trip through serde — TurnTally's four counters are exercised with non-zero values in events_round_trip_byte_for_byte, because their skip_serializing_if means a zeroed tally would have proven nothing about them

Anything reviewers should know?

deck.rs was split first, in its own commit. It sat at 1492 of the 1500-line guard and #2007 needs ~14 lines in it. Rather than move a ceiling, its four pure event classifiers (event_intensity, status_from_event, trace_of, snip) moved to deck/classify.rs — the same extraction prompt_queue.rs took out of this same file, for the same reason. A pure move: bodies byte-identical, deck.rs re-imports the names so every call site is unchanged, no test touched, 1492 → 1207. Reviewing that commit alone should be quick.

views/session.rs is at its exact ceiling (1611/1611), which is why its call site changed one line for one line rather than growing. That constraint shaped the design — AgentEntry::live_park exists partly so the whole join fits in a single call.

Deck goldens are undisturbed and that is asserted, not assumed: a session with no open park adds no spans, so it renders byte-for-byte as before. the_park_clock_rolls_past_an_hour_and_is_absent_when_not_parked pins the absent case directly. No BLESS=1 was run and no snapshot changed.

Exemplar followed for the clock discipline: deck::AgentEntry::turn_started_ms and fleet_dashboard's now: Instant parameter — both inject the clock rather than reading one inside a fold, which is what keeps SessionModel::replay(&log) == SessionModel::replay(&log) (L-T1) true. Option 2 from #2007 (a per-probe TurnParkProbe wire event) was rejected as the issue suggested: it buys precision the user does not need, adds stream volume proportional to park length, and taxes replay.

Summary by Sourcery

Expose parked waits as first-class state in both the server observability path and the TUI deck, so deliberate parks are visible and distinguishable from wedged turns.

New Features:

  • Track parked-wait spans, wakes, probes, and licensed wait duration in turn tallies and exported serve metrics, enabling hosts to distinguish deliberate parks from hangs.
  • Surface live parked waits in the TUI HUD with an elapsed-vs-deadline countdown and subject line, driven off the deck’s own clock rather than new engine events.

Enhancements:

  • Extend the session model and deck workspace model to carry open-park state separately from transcript history, keeping parked status as live state while preserving replay purity.
  • Refactor deck event-classifier helpers into a dedicated module to keep the deck view within its size constraints without changing behavior.

Tests:

  • Add serve-side tally tests that prove parked turns are distinguishable from wedged ones and that park descriptions do not affect tallies.
  • Add TUI model, deck, and render tests that exercise open-park lifecycle, clock advancement on the deck’s clock alone, HUD formatting, and behavior when no park or stale park stamps are present.

…is not read as a hang

`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
…k/classify.rs

`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
…ng down, not just the budget

#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<OpenPark>` — 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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Aug 7, 2026 4:39am

@sourcery-ai

sourcery-ai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements parked-wait observability across stella-serve and stella-tui: TurnTally and Metrics now count parked spans and deadlines so parked turns are distinguishable from wedged ones, and the TUI deck gains live parked-wait state plus a HUD clock that advances on the deck’s own clock without reading time inside folds; deck classifiers were extracted to a helper module to stay under file-size guards.

Sequence diagram for parked-wait events through serve tally and metrics

sequenceDiagram
    participant Engine as Engine
    participant ServeObserver as TallyFold
    participant TurnTally as TurnTally
    participant Metrics as Metrics

    Engine->>ServeObserver: observe(AgentEvent::TurnParked)
    ServeObserver->>TurnTally: increment parked_spans
    ServeObserver->>TurnTally: add deadline_secs to parked_deadline_secs

    Engine->>ServeObserver: observe(AgentEvent::TurnWoken)
    ServeObserver->>TurnTally: increment parked_spans_woken
    ServeObserver->>TurnTally: add polls_used to parked_polls

    Engine->>Metrics: observe(ServeEvent::TurnSettled { tally })
    Metrics->>Metrics: fetch_add(parked_spans_total, tally.parked_spans)
    Metrics->>Metrics: fetch_add(parked_deadline_secs_total, tally.parked_deadline_secs)

    Metrics->>Metrics: snapshot()
    Metrics-->>Engine: Snapshot { parked_spans_total, parked_deadline_secs_total }
Loading

Sequence diagram for parked-wait state into TUI HUD clock

sequenceDiagram
    participant Engine as Engine
    participant Session as SessionModel
    participant Deck as AgentEntry
    participant View as views::session::render
    participant HUD as render_hud

    Engine->>Session: apply_event(AgentEvent::TurnParked)
    Session->>Session: parked = Some(OpenPark { .. })

    Engine->>Session: apply_event(AgentEvent::TurnWoken)
    Session->>Session: parked = None

    loop each frame
        View->>Deck: WorkspaceModel::apply_event(..., now_ms)
        Note over Deck: on TurnParked
        Deck->>Deck: parked_since_ms = Some(now_ms)

        View->>Deck: live_park(model.now_ms)
        Deck-->>View: Option<(&OpenPark, elapsed_ms)>
        View->>HUD: render_hud(&Hud, parked, area, buf)
    end
Loading

File-Level Changes

Change Details Files
Track parked waits in TurnTally and expose aggregate parked-wait metrics so parked turns are distinguishable from wedged/stalled ones.
  • Extend TurnTally with parked_spans, parked_spans_woken, parked_polls, and parked_deadline_secs fields plus an ended_parked() helper and matching serde zero-skipping helpers
  • Update TallyFold::observe to handle AgentEvent::TurnParked/TurnWoken by incrementing the new counters without consuming free-text fields
  • Adjust TurnTally round-trip tests to assert non-zero park counters serialize/deserialize correctly
  • Extend Metrics and Snapshot with parked_spans_total and parked_deadline_secs_total and increment them from TurnTally in the Metrics Observer implementation, with tests validating aggregation
crates/stella-serve/src/observe/event.rs
crates/stella-serve/src/observe/tally.rs
crates/stella-serve/src/observe/metrics.rs
Model live parked-wait state in the TUI session model and deck so the HUD can render a parked-wait chip using the deck’s injected clock, without violating replay/purity constraints.
  • Add SessionModel::parked: Option and implement OpenPark, updating SessionModel::apply to open/close parks on TurnParked/TurnWoken and on terminal Complete/Error events while leaving retryable errors alone
  • Extend AgentEntry with parked_since_ms, initialize it in the WorkspaceModel fold on AgentEvent::TurnParked, and add AgentEntry::live_park(now_ms) to join pure parked state with elapsed time based on the deck’s now_ms
  • Update views/session::render to pass agent.live_park(model.now_ms) into render_hud, and add tests ensuring the deck’s clock drives elapsed time, stale stamps don’t resurrect parks, and terminal events close open parks
crates/stella-tui/src/model.rs
crates/stella-tui/src/model/tests.rs
crates/stella-tui/src/deck.rs
crates/stella-tui/src/deck/tests.rs
crates/stella-tui/src/views/session.rs
Enhance the HUD to show a live parked-wait countdown chip and supporting formatting helpers, and test its rendering behavior.
  • Change render_hud signature to accept an optional (OpenPark, elapsed_ms) tuple, append a ⏳ parked chip rendering elapsed vs. deadline via a shared clock_ms formatter, and trim the description via park_subject to keep the clock visible
  • Introduce clock_ms and park_subject helpers to normalize time formatting and cap description length, and add tests validating multi-frame progression, hour-plus formatting, absence when not parked, and truncation behavior
  • Add a HUD-only helper hud_row in tests to make it easy to snapshot the flattened chip row for assertions
crates/stella-tui/src/render.rs
crates/stella-tui/src/render/tests.rs
Refactor deck event classifiers into a dedicated module to stay within file-size guards while keeping behavior identical.
  • Extract event_intensity, status_from_event, trace_of, and snip from deck.rs into a new deck::classify module marked pub(super) and re-export them in deck.rs
  • Wire deck.rs to use the new module via a submodule declaration and use classify::{event_intensity, snip, status_from_event, trace_of}, leaving call sites and behavior unchanged
crates/stella-tui/src/deck.rs
crates/stella-tui/src/deck/classify.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#2006 Add park accounting to TurnTally and TallyFold::observe so TurnParked/TurnWoken are explicitly tallied, content-free, and serialized in a serde-additive way (including any necessary wire-schema updates).
#2006 Enable hosts/consumers (including stella-tui) to correctly distinguish a wedged turn from a deliberately parked wait, and verify this via tests that differentiate folds with park/wake events from those without.
#2007 Implement a live parked-wait clock in the TUI deck that shows elapsed time against the park deadline, updates without new engine events, stops and remains historically correct on wake, and preserves SessionModel fold purity by keeping time-stamping on the draw side rather than inside the event fold.
#2007 Ensure the new time-varying parked-wait display is testable and does not destabilize golden/snapshot tests by using an injected/frozen clock and appropriate unit tests for the HUD and deck behavior.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macanderson
macanderson merged commit 1dc0ac1 into main Aug 7, 2026
17 checks passed
@macanderson
macanderson deleted the feat/2006-2007-parked-wait-observability branch August 7, 2026 04:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant