Skip to content

feat(stella-core): parked waits — waiting on external state without model steps (#1471) - #1860

Merged
macanderson merged 10 commits into
mainfrom
worktree-parked-wait-1471
Aug 6, 2026
Merged

feat(stella-core): parked waits — waiting on external state without model steps (#1471)#1860
macanderson merged 10 commits into
mainfrom
worktree-parked-wait-1471

Conversation

@macanderson

@macanderson macanderson commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What & why

Waiting on external state (CI, a deploy) was a model behavior: every poll a full model round-trip on a growing transcript, every long in-tool sleep a prompt-cache expiry. The measured damage (#1471): two-thirds of a monitoring session's spend went to waiting, a 600s gh run watch voided the cache into a 54.8k-token cold step ($0.166 for one status check), and accumulated poll output forced a mid-task compaction.

This PR makes waiting a runtime primitive:

  • stella-core::waiting — pure decision logic (invariant 2): WaitRequest/WaitCall (serde round-tripped, invariant 4), interval/deadline clamps, poll-count deadline arithmetic (no clock: the injected Sleeper owns real time), fingerprint compare, wake-message rendering.
  • ToolExecutor::drain_wait_request — a tool deposits a request during execute; the engine drains it at the step boundary. The exact drain_sub_agent_spend_usd seam, with the same "decorators MUST forward" contract, forwarded through every wrapper in the workspace and pinned end-to-end by the_production_tool_stack_forwards_wait_requests.
  • driver/waiting.rs (the settlement.rs split pattern — driver.rs net shrinks) — maybe_park runs after dispatch, between model calls (invariant 6): sleep → replay a read-only probe through the standard dispatch path (hooks, timeout, repair) → compare → wake on change or deadline. Probe outputs never touch the transcript; the wake rides ONE volatile tail message (invariant 7 — the stable prefix is byte-identical across the park). Cancel is answered mid-park; a latched soft stop ends the wait early. Non-read-only probes are refused outright.
  • ci_status is the first client: wait=true now returns current status immediately and deposits a park request with a settled/pending probe (stable vocabulary — no elapsed-time noise can fake a change; ci_status wait=true watches only the newest-created run — returns instantly with unchanged output while CI is still running #1466's whole-incomplete-set semantics kept, ci_status: a pr-target call with wait=true resolves the PR head up to three times — share one resolution per execute #1526's resolve-once discipline kept). The old composed bash poll loop — the exact thing that aged the cache inside one tool call and died at the 600s exec cap — is gone; waits now default to 30 minutes and clamp at 2 hours, bounds the in-tool wait could never offer.

An arbitrarily long wait now costs O(1) model steps, forces no compaction, and leaves the prompt-cache prefix untouched.

Closes #1471
Refs #1473 — the loop-detector steer for polling loops is the model-behavior half; the primitive it should prescribe now exists.

The witness

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

crates/stella-core/src/driver/tests/parked_wait.rs, through the real run_turn:

Plus: pure-logic tests in stella-core::waiting (clamps, error-keeps-waiting, serde round-trip), ci.rs composition tests (probe stability, park deposit shape, resolved-head discipline), and the production-stack forwarding witness in stella-cli.

The gate

  • cargo fmt --check
  • scoped: core+tools+cli+serve+mcp compile clean; stella-core (993), stella-tools (718), stella-cli subagent suite all pass locally; full workspace gate runs in CI
  • Docs updated: ci_status schema description states the new contract; stella-core README module map + step-phase list
  • Closes #1471 appears above and as a commit trailer

Nothing left behind

Ground-rule check

  • No I/O added to stella-core — the park loop drives the existing ToolExecutor/Sleeper ports; no new deps anywhere
  • No new outbound network calls
  • New cross-boundary types round-trip through serde (test included)

Anything reviewers should know?

  • Exemplar followed: the deposit/drain seam copies drain_sub_agent_spend_usd (ports.rs) — the workspace's established "written by one object, drained by another" idiom — rather than inventing a control-effect channel in ToolOutput. An in-band output sentinel was considered and rejected: tool output is partly attacker-influenced text (PR titles, file contents), and a forgeable park request is a prompt-injection amplifier; the drain is structurally unforgeable.
  • God-file arithmetic: driver.rs 2580→2572, registry.rs 2184→2180, command_deck.rs 4740→4699 (TaskTap moved to command_deck/task_tap.rs), driver/tests.rs stays at 3680 (two single-item imports merged pay for the new mod). No ceiling raised; check-file-size and check-god-files pass.
  • Deliberate physics limit: a park longer than the provider cache TTL still means one cold model call on wake — that is unavoidable wherever the waiting happens; what this removes is the N polls before it, the poll transcript, and the in-tool timeout error.
  • The park deadline counts polls instead of reading a clock, so it undercounts by probe execution time — erring toward waking early, the safe direction (documented on WaitRequest::max_polls).

Summary by Sourcery

Introduce parked waits as a core runtime primitive so tools can offload long external waits to the engine without consuming model steps, and adopt this for ci_status, ensuring forwarding through all tool executor decorators.

New Features:

  • Allow tools to deposit WaitRequest objects that let the engine park turns and probe external state between model calls using pure waiting logic.
  • Add engine-side parked wait loop that replays read-only probe and wake tool calls without touching the transcript, emitting a marked wake message on change or timeout.
  • Update ci_status to support wait=true by parking the turn and using a stable settled/pending probe plus a wake replay instead of an in-tool polling loop.

Enhancements:

  • Refactor driver settlement logic and task board tap into separate modules to keep god files within size limits and reuse last_assistant_text.
  • Move exploration path-mention logic into a shared module and reuse it from the registry.
  • Ensure all ToolExecutor decorators forward drain_wait_request alongside sub-agent spend to preserve parked-wait behavior through the stack.
  • Export waiting module and WaitRequest/WaitCall types from stella-core and document the new parked-wait phase in the step pipeline.

Documentation:

  • Document the waiting module and parked wait phase in stella-core’s README, including step ordering and module map.
  • Update ci_status tool description to explain the new waiting contract and discourage manual polling loops.

Tests:

  • Add end-to-end parked wait tests exercising change-on-Nth-probe, deadline timeout, and rejection of non-read-only probes.
  • Add tests for ci_status parked-wait behavior, settledness probe semantics, and WaitRequest construction and serde round-trip.
  • Add a CLI test ensuring the production tool stack forwards wait requests through all decorators and a registry test for mentions_path in its new location.

Stella Test added 7 commits August 6, 2026 03:15
…e without model calls

The pure decision half (crate::waiting), the ToolExecutor drain port, and
the driver's park loop (driver/waiting.rs, the settlement.rs split pattern).

Refs #1471
…ing in-tool

wait=true returns current status and parks the turn; the engine probes a
stable settled/pending word (#1466 semantics kept) and wakes the model
once with the fresh status. mentions_path moves to exploration.rs to keep
registry.rs under its ceiling.

Refs #1471
…every tool-stack decorator

TaskTap moves to command_deck/task_tap.rs (the settlement.rs split
pattern) so the god file shrinks instead of growing. Pinned end-to-end by
the_production_tool_stack_forwards_wait_requests, the wait twin of the
spend-forwarding witness.

Refs #1471
…-phase list

The step loop's phase sequence gains its tail phase (maybe_park) and the
layout table names the two new waiting modules.

Closes #1471

@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 6, 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 Preview Aug 6, 2026 6:33pm

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Introduces parked waits as a core runtime primitive so tools can request the engine to wait on external state between model steps, rewiring ci_status to use this mechanism and ensuring all ToolExecutor decorators forward wait requests, while adding pure waiting logic, driver integration, and tests.

Sequence diagram for parked wait lifecycle with ci_status

sequenceDiagram
    actor User
    participant Model
    participant Engine
    participant ToolExecutor
    participant CiStatus
    participant Sleeper
    participant ExternalCI

    User->>Model: Request CI status (wait=true)
    Model->>Engine: Tool call ci_status {wait: true}
    Engine->>ToolExecutor: execute("ci_status", input)
    ToolExecutor->>CiStatus: execute(name, input)
    CiStatus->>ExternalCI: run_github(primary_command, root, timeout_secs)
    ExternalCI-->>CiStatus: (status list)
    CiStatus->>ExternalCI: run_github(settled_command, root, timeout_secs)
    ExternalCI-->>CiStatus: ("pending")
    CiStatus->>CiStatus: park_request(input, pr_head)
    CiStatus->>CiStatus: pending_wait.lock().insert(WaitRequest)
    CiStatus-->>ToolExecutor: ToolOutput::Ok (current status + "Parking the turn…")
    ToolExecutor-->>Engine: ToolOutput::Ok

    Engine->>ToolExecutor: drain_wait_request()
    ToolExecutor-->>Engine: WaitRequest
    Engine->>Sleeper: sleep(request.interval_secs())
    Sleeper-->>Engine: woke
    loop Probe until changed or deadline
        Engine->>ToolExecutor: execute_with_repair(WaitCall { probe })
        ToolExecutor->>CiStatus: execute(name, {probe: true, target})
        CiStatus->>ExternalCI: run_github(settled_command, root, timeout_secs)
        ExternalCI-->>CiStatus: ("settled" or "pending")
        CiStatus-->>ToolExecutor: ToolOutput::Ok {content}
        ToolExecutor-->>Engine: ToolOutput
        Engine->>Engine: probe_fingerprint / decide
        alt [state changed]
            Engine->>Engine: break
        else [deadline not reached]
            Engine->>Sleeper: sleep(request.interval_secs())
            Sleeper-->>Engine: woke
        end
    end

    alt WakeReason::Changed and on_wake present
        Engine->>ToolExecutor: execute_with_repair(WaitCall { on_wake })
        ToolExecutor->>CiStatus: execute(name, wake_input)
        CiStatus->>ExternalCI: run_github(primary_command, root, timeout_secs)
        ExternalCI-->>CiStatus: (fresh status)
        CiStatus-->>ToolExecutor: ToolOutput::Ok {content}
        ToolExecutor-->>Engine: ToolOutput
    end
    Engine->>Engine: wake_message(request, reason, polls_used, detail)
    Engine->>Model: Inject CompletionMessage::user(wake_message)
    Model->>Model: Next step using unchanged prompt prefix
Loading

File-Level Changes

Change Details Files
Add stella-core waiting primitives and driver park loop to support parked waits without consuming model steps
  • Introduce stella_core::waiting module with WaitRequest/WaitCall types, interval/timeout clamping, decision logic, wake message rendering, and serde round-trip tests
  • Add ToolExecutor::drain_wait_request port and implement Engine::maybe_park with driver::waiting module to drain wait requests at step boundaries, run read-only probe loops via existing ToolExecutor/Sleeper, and inject wake messages into the transcript
  • Add parked_wait driver tests that script providers/tools to verify model-call counts, probe behavior, timeout semantics, and read-only-enforcement for replayed calls
crates/stella-core/src/waiting.rs
crates/stella-core/src/ports.rs
crates/stella-core/src/driver.rs
crates/stella-core/src/driver/waiting.rs
crates/stella-core/src/driver/tests/parked_wait.rs
crates/stella-core/src/lib.rs
crates/stella-core/src/driver/tests.rs
crates/stella-core/README.md
Refactor ci_status to deposit parked wait requests and provide a stable settledness probe
  • Make CiStatus stateful with a Mutex-held pending WaitRequest and implement Tool::take_wait_request plus new PARK_POLL_INTERVAL_SECS
  • Add a read-only settledness probe path (probe=true) that returns stable 'settled'/'pending' outputs and use it for both gate command generation and engine-side probes
  • Replace the in-tool bash wait loop with a single settledness check plus park_request builder that encodes probe/wake calls and timeout/interval, updating wait=true user messaging and tests for settled_command and park_request behavior
crates/stella-tools/src/ci.rs
Ensure ToolExecutor decorators and registries forward wait requests end-to-end
  • Implement Tool::take_wait_request in stella-tools::registry and aggregate into ToolRegistry::drain_wait_request over primary and late tool maps
  • Forward drain_wait_request through all ToolExecutor decorator layers in CLI, MCP, serve, and tools (ClaimTap, DiscoveryToolSet, InteractiveToolSet, PolicyToolSet, CommitObserver, DelegatingTools, CustomToolSet, HunkGate, McpToolSet, CandidateMcpView, ReadOnlyTools, TaskTap), mirroring the spend-drain contract
  • Add CLI subagent test the_production_tool_stack_forwards_wait_requests to witness that a deposited WaitRequest survives through the decorator stack and that drains are destructive
crates/stella-tools/src/registry.rs
crates/stella-tools/src/custom.rs
crates/stella-tools/src/hunk_review.rs
crates/stella-tools/src/registry/process_tools.rs
crates/stella-tools/src/registry/tests.rs
crates/stella-core/src/ports.rs
crates/stella-cli/src/claims.rs
crates/stella-cli/src/discovery.rs
crates/stella-cli/src/fleet_commits.rs
crates/stella-cli/src/interactive.rs
crates/stella-cli/src/tool_policy.rs
crates/stella-cli/src/subagent/tests.rs
crates/stella-serve/src/subagents.rs
crates/stella-mcp/src/toolset.rs
crates/stella-core/src/ports.rs
crates/stella-core/src/driver.rs
crates/stella-core/src/driver/waiting.rs
crates/stella-cli/src/command_deck.rs
crates/stella-cli/src/command_deck/task_tap.rs
Move mentions_path helper into exploration module and keep god-file sizes under limits
  • Move mentions_path from stella-tools::registry into stella-tools::exploration as a pub(crate) helper and update callers
  • Split TaskTap from command_deck.rs into its own module to reduce file size while preserving behavior and forwarding of drains
crates/stella-tools/src/registry.rs
crates/stella-tools/src/exploration.rs
crates/stella-tools/src/registry/tests.rs
crates/stella-cli/src/command_deck.rs
crates/stella-cli/src/command_deck/task_tap.rs
Minor settlement/driver cleanup and documentation updates
  • Move last_assistant_text helper from driver.rs into driver/settlement.rs and adjust imports
  • Update stella-core README step-phase list to include parked wait phase and new waiting modules
  • Tidy imports in driver tests and re-export WaitCall/WaitRequest from stella-core::waiting
crates/stella-core/src/driver.rs
crates/stella-core/src/driver/settlement.rs
crates/stella-core/src/driver/tests.rs
crates/stella-core/src/lib.rs
crates/stella-core/README.md

Assessment against linked issues

Issue Objective Addressed Explanation
#1471 Introduce a first-class runtime primitive for waiting on external state: tools deposit a wait request, the driver parks the turn and drives cheap read-only probes via injected ports (ToolExecutor/Sleeper), only waking the model on a state change or deadline, with wake context carried in the volatile block and no transcript growth from polling.
#1471 Refactor ci_status(wait=true) to use the parked-wait primitive instead of in-tool bash polling/sleeps, preserving the 'whole incomplete set' semantics while avoiding prompt-cache expiry and transcript bloat.
#1471 Add a witness test and supporting tests that fail on main and pass with this change, demonstrating that a condition changing on the Nth poll causes exactly one model re-invocation (not N), that arbitrarily long waits incur O(1) model steps with no cache-voiding or compaction forced by poll output.

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 4437e98 into main Aug 6, 2026
4 checks passed
@macanderson
macanderson deleted the worktree-parked-wait-1471 branch August 6, 2026 18:34
macanderson added a commit that referenced this pull request Aug 6, 2026
…ter the #1875/#1898 growth (#1906)

## What & why

Unbreaks main's `file-size` gate. `crates/stella-mcp/src/toolset.rs`
reached **1756 lines** after three stacked merges — #1875 (sort the MCP
schema segment for byte-stability, #1848), #1898 (bound one server's
contribution to the advertised toolset, #1856), and the parked-waits
work (#1860) — crossing the hard 1500-line limit for files not in
`scripts/file-size-baseline.txt`.

**What moved where:** the `#[cfg(test)] mod tests` block (~945 lines,
more than half the file) moved out to a sibling submodule,
`crates/stella-mcp/src/toolset/tests.rs`, declared as `#[cfg(test)] mod
tests;`. This is the pattern AGENTS.md names
(`crates/stella-core/src/driver/settlement.rs`, split out of
`driver.rs`) and the exact shape `stella-model` already uses for
`src/zai/tests.rs` and `src/anthropic/tests.rs`. Purely mechanical: the
block is de-indented one level (plus the rustfmt repack that de-indent
allows) — **no visibility changes** (a child module reaches the parent's
private items via `use super::*`), no renames, no behavior change. The
new file carries a `//!` module doc per crate idiom, and the README
Layout row for `toolset.rs` now names the tests file (matching
`stella-model`'s README style).

- `src/toolset.rs`: 1756 → **812** lines
- `src/toolset/tests.rs` (new): **948** lines

**Why no baseline entry:** the guard's own policy —
`scripts/file-size-baseline.txt` accepts no new entries; a file over the
limit gets split, not grandfathered (AGENTS.md § God files).

**Two more main unbreaks riding along:**

- main's `cargo doc -D warnings` fails with *public documentation for
`MAX_SERVER_SCHEMA_BYTES` links to private item
`crate::client::ingest`*. The intra-doc link in that const's doc block
(same file, `toolset.rs`) is now plain code font — same fix shape as
#1823/#1830.
- main's `Cargo.lock` is stale: `stella-diff`'s manifest says 0.6.127
but the lock records 0.6.126, so any build rewrites the lock and CI's
`Cargo.lock` sync check fails. One-line resync, generated by cargo
itself.

**Heads-up for reviewers:** this PR alone does not green the gate — the
`stella-pipeline` compile break and the Makefile duplicate target are
being fixed in a separate PR by the coordinator.

## The witness

- [x] No witness needed (pure refactor / docs / CI) — because: this is a
mechanical module split plus a doc-link defuse and a lockfile resync;
behavior is proven unchanged by the existing suite, not by a new test.
All 122 `stella-mcp` unit tests (the relocated `toolset::tests` among
them) and every integration suite pass unchanged.

## The gate

GitHub Actions is in a major outage, so CI will not run on this PR —
local verification is the evidence, run in this branch's worktree:

- [x] `./scripts/check-file-size.sh` — OK, none over 1500 except the 32
grandfathered (none grew)
- [x] `./scripts/check-god-files.sh` — OK; `stella-mcp`'s "no god files"
README claim stays true
- [x] `cargo fmt -p stella-mcp -- --check` — clean
- [x] `cargo check -p stella-mcp` — clean
- [x] `cargo clippy -p stella-mcp --all-targets -- -D warnings` — clean
- [x] `cargo test -p stella-mcp` — 122 unit + 6/3/3/15/1 integration
(wiremock + fixture-server suites included), all pass
- [x] `RUSTDOCFLAGS="-D warnings" cargo doc -p stella-mcp --no-deps` —
clean (verifies the private-link fix)
- [x] Docs updated where behavior changed (README Layout row)
- [x] CLA signed

## Nothing left behind

- [x] There is nothing: everything I noticed is fixed in this PR (the
remaining main breaks — `stella-pipeline` compile, Makefile duplicate
target — are already owned by the coordinator's separate PR)

## Anything reviewers should know?

Do not merge on green checks alone — Actions is down; the checklist
above is the verification record.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Monitoring burns model steps, prompt cache, and context on polling — make waiting a runtime primitive with change-notification

1 participant