Skip to content

(MOT-4100) fix(harness): stop button — prompt cancellation end to end#528

Merged
andersonleal merged 12 commits into
mainfrom
fix/harness-stop-button
Jul 17, 2026
Merged

(MOT-4100) fix(harness): stop button — prompt cancellation end to end#528
andersonleal merged 12 commits into
mainfrom
fix/harness-stop-button

Conversation

@andersonleal

@andersonleal andersonleal commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

Clicking stop (■) fired harness::stop (~4ms ack) but the turn kept running for seconds — up to router_timeout_ms (320s) worst case — with no UI feedback and a still-clickable button (users click 6+ times), no transcript record of the stop, and the provider's upstream HTTP stream kept generating billed tokens after the abort.

What changed

harness — stop actually stops:

  • TurnCancels: per-turn, level-triggered in-process cancel registry, fired lock-free by harness::stop
  • router.chat gains an abort arm: cuts the await immediately, persists the partial as Aborted, re-fires router::abort (a stop can race ahead of the router's stream registration), kills the held-open trigger task
  • cancel observed between tool calls and before dispatching generation (the durable flag can't reach either spot — the tool phase holds the session lock, context assembly precedes the flag write)
  • idempotent stop; "stopping" status ack; durable stopped by user. notice (e_{turn}_stopped); argument-less tool-call blocks truncated by the cut are dropped from the persisted partial

console — feedback + dedupe:

  • stop button disables with a spinner while stopping; repeat clicks don't re-fire the RPC; re-enables if the abort RPC fails
  • live cancelled notice shares the durable entry id, so live + reconciled rows dedupe

llm-router + all 7 providers — the provider stream dies too:

  • router::abort fans out to a new provider::<id>::abort (request correlation via the existing resolution_key)
  • scaffold StreamAborts registry + pump_abortable: the abort cuts the pump even mid-silence; upstream spawns race Sender::closed() so the parked HTTP future cancels instantly instead of at its next send
  • abort subscription latches from stream-fn entry (an abort during credential resolve/config prevents the upstream from ever starting); PING_INTERVAL 30s → 2s as the passive backstop when the active abort is lost

Also carries fix(provider-kimi): emit slim (partial: None) delta frames (cherry-pick) — kimi didn't compile against the slim-delta contract on this base.

A post-implementation review round (commits fa1710bd/6e5b552e/e8dddbd5) closed the races it found: stopping-ack vs finalizer status ordering (incl. making fail_turn take the session lock), a losable pump wakeup (notify_one), terminal-frame-vs-partial preference on abort, and the two provider gaps above.

Verification

  • cargo clippy + tests green on harness (230), llm-router (107), and all seven provider crates; console tsc + 1021 vitest
  • Live on the dev stack (claude-sonnet-5): stop mid-generation → one harness::stop trace per click (was 6+), turn cancels in <1s (server-driven case <100ms), spinner while stopping, durable notice survives reload, provider::anthropic::abort fires in ~300μs beside router::abort, remaining queued tool calls never run (verified with 3×sleep 8)
  • Known pre-existing failure, untouched: provider-anthropic's live-catalog test asserts a model id Anthropic no longer lists

Summary by CodeRabbit

  • New Features

    • Added reliable cancellation for in-progress chat responses across supported providers.
    • Stop requests now immediately halt generation, including during setup and tool execution.
    • Added visual feedback while a response is stopping, preventing duplicate stop actions.
    • Cancelled conversations now retain a clear “stopped” notice after reloads.
  • Bug Fixes

    • Prevented upstream requests and background streams from lingering after cancellation.
    • Improved retry behavior when stopping a response fails.
    • Reduced the chance of sessions becoming stuck in a working state.

ytallo and others added 9 commits July 17, 2026 13:42
provider-kimi predates #525's llm-router!: slim streaming delta frames
change and still built full AssistantMessage snapshots on every delta,
which no longer compiles against the new Option<AssistantMessage> contract.
Mirrors the pattern already applied to the other six providers.
harness::stop set a durable abort flag that run_step only observed at
step boundaries and after generation returned — router.chat had no local
cancellation, so a stop could wait out a full generation (up to
router_timeout_ms) or an entire tool-execution phase, and repeat stops
redid the whole cascade every click.

- TurnCancels (locks.rs): per-turn level-triggered watch registry, fired
  lock-free by stop.rs; keyed by turn_id so a stale fire can't cancel a
  newer turn. Single-process, same caveat as SessionLocks.
- router.chat gains an abort arm: cuts the await immediately, persists
  the partial as Aborted, re-fires router::abort (a stop can race ahead
  of the router's stream registration and no-op), kills the held-open
  trigger task.
- run_step: bail before dispatching generation when the cancel fired
  during context assembly; check the signal between tool calls (the
  durable write blocks on the session lock for that whole phase).
- stop.rs: idempotency guard (repeat stops become one read), immediate
  "stopping" phase reason on the existing status channel, cancel signal
  fired before anything that awaits.
- finalize_cancelled: durable "stopped by user." notice entry
  (e_{turn}_stopped) so the transcript doesn't just end mid-thought;
  argument-less tool-call blocks truncated by the cut are dropped from
  the persisted partial (they render as empty cards and can never run).
The stop button gave no feedback and stayed clickable after a click:
local isStreaming cleared instantly on abort but serverWorking held the
indicator, so users clicked repeatedly (each click re-firing
harness::stop) with nothing acknowledging the stop.

- ChatView: `stopping` state — set on first stop, guards repeat clicks,
  cleared when the streaming indicator drops.
- Composer: stop button disables with a spinner while stopping.
- translate: the cancelled stop-reason event carries
  entryId e_{turn}_stopped, matching the harness's new durable notice so
  the live event and the reconciled transcript row dedupe.
router::abort closed the relay reader and left the provider to notice
on its next channel write — up to 2×PING_INTERVAL (60s) of billed
upstream generation during a silent stretch (e.g. Anthropic
server-buffering a large tool_use input).

- chat.rs: the inflight abort closer now also fires
  provider::<id>::abort (detached, best-effort; providers without the
  function are a harmless error) using the request_id the provider
  already receives as resolution_key.
- provider_scaffold::aborts: StreamAborts registry (request_id → live
  upstream cancel, level-triggered watch) + a ready-made make_abort
  handler for providers to register.
- pump_abortable: pump with an abort arm — returns immediately on the
  signal even mid-silence, dropping the upstream receiver, which aborts
  the upstream task and its in-flight HTTP request.
- PING_INTERVAL 30s → 2s: the failed-ping write remains the passive
  backstop when the active abort is lost; this bounds that path to ~4s.
- ProviderAbortRequest/ProviderAbortResponse wire types.
Wires the scaffold's StreamAborts/pump_abortable into every provider:
the stream registers its resolution_key (the router's request_id) while
the upstream is live, provider::<id>::abort signals it, and the pump
returns immediately — cancelling the upstream HTTP stream instead of
letting it bill tokens until a write fails.

Same shape everywhere: ABORT_ID/ABORT_DESC in the surface catalog,
registration right after stream, resolution_key match around the live
pump (plain pump when absent), schema goldens regenerated. provider-xai
shares one pump_stream helper across its two stream paths. Cargo.locks
pick up the already-committed llm-router 1.2.0 pin.
- The "stopping" status ack moves under the session lock, after the
  terminal re-check: issued lock-free it could land after a concurrent
  finalizer's "done" and strand the session on "working" (the locked
  re-read returns stopping:false without restoring). fail_turn now takes
  the session lock too — it was the one lock-free finalizer, the same
  hole by another door (its only caller runs after run_step returned, so
  no deadlock).
- chat's pump wakeup uses notify_one (latched permit) instead of
  notify_waiters: a pre-fired cancel can reach the abort arm before the
  spawned pump polls its notified() future, and an unlatched notify is
  lost — pump.await would hang on next_binary until natural EOF.
- The abort arm prefers final_message over the tracker partial: a stop
  landing between a terminal Done frame and EOF must not replace the
  complete assistant entry with the stale pre-Done partial.
A rejected backend.abortRun left `stopping` stuck true while the server
still reported working — the reset effect waits on the streaming
indicator, so the disabled button could never retry. Reset on rejection.
Review found two gaps in the provider::<id>::abort wiring, fixed
uniformly across all seven providers:

- The detached upstream task only observed a dropped receiver at its
  next send, so a silent upstream — parked in a chunk read with nothing
  to send — kept the HTTP stream and its billed generation alive until
  the next frame or the read timeout. spawn_upstream (and xai's
  responses variant) now races the call against Sender::closed(), so
  receiver drop cancels the HTTP future immediately.
- The abort registry entry was created only at pump time, so an abort
  landing during provider setup (credential resolve/config) hit an
  unknown id and was lost, letting the request start after its own
  cancellation. The stream fn now subscribes at entry (level-triggered
  watch latches early aborts), skips spawning the upstream when already
  fired, and cleans up at a single point in the make_stream wrapper.

Also marks provider::kimi::abort internal like the other six, keeping
the control-plane callback out of the agent-facing catalog.
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jul 17, 2026 6:32pm
workers-tech-spec Ready Ready Preview, Comment Jul 17, 2026 6:32pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 46 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@andersonleal, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d1280c57-a5d8-4e54-97ad-86a9a1972889

📥 Commits

Reviewing files that changed from the base of the PR and between dc172f8 and 648bda5.

📒 Files selected for processing (9)
  • console/web/src/components/chat/ChatView.tsx
  • llm-router/src/provider_scaffold/aborts.rs
  • provider-anthropic/src/stream_fn.rs
  • provider-kimi/src/stream_fn.rs
  • provider-llamacpp/src/stream_fn.rs
  • provider-openai-codex/src/stream_fn.rs
  • provider-openai/src/stream_fn.rs
  • provider-xai/src/stream_fn.rs
  • provider-zai/src/stream_fn.rs
📝 Walkthrough

Walkthrough

This change adds end-to-end cancellation for streamed turns, from stop-button state and durable stop notices through harness cancellation, router abort propagation, provider abort endpoints, and upstream stream termination across supported providers.

Changes

Stream cancellation

Layer / File(s) Summary
Stop request UI and translated stop events
console/web/src/components/chat/ChatView.tsx, console/web/src/components/chat/Composer.tsx, console/web/src/lib/backend/translate.*
The UI tracks stopping state, disables and animates the stop control, and emits stop-reason events with deterministic entry IDs.
Harness cancellation lifecycle
harness/src/locks.rs, harness/src/deps.rs, harness/src/functions/stop.rs, harness/src/turn_loop.rs, harness/src/clients/router.rs
Per-turn cancellation signals interrupt generation and tool execution, while cancellation finalization clears state and appends a durable stopped notice.
Router abort contracts and propagation
llm-router/src/types/router.rs, llm-router/src/provider_scaffold/*, llm-router/src/chat/chat.rs
Typed abort requests, level-triggered abort registries, abort-aware pumping, and provider abort fan-out are added.
Provider registration and stream handling
provider-*/src/*, provider-*/tests/*
Provider abort surfaces and schemas are registered; stream setup, pumping, cleanup, and upstream tasks respond to cancellation across Anthropic, Kimi, LlamaCpp, OpenAI Codex, OpenAI, xAI, and Z.AI.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChatView
  participant Harness
  participant Router
  participant Provider
  participant Upstream
  User->>ChatView: Press stop
  ChatView->>Harness: abortRun(turn_id)
  Harness->>Router: cancel chat with abort receiver
  Router->>Provider: provider::<provider>::abort(request_id)
  Provider->>Upstream: signal and close upstream stream
  Upstream-->>Provider: stop streaming
  Provider-->>Router: aborted outcome
  Router-->>Harness: cancelled turn
  Harness-->>ChatView: durable stopped notice
Loading

Possibly related PRs

  • iii-hq/workers#443: Earlier provider-Z.AI streaming work touched the implementation extended here with abort support.
  • iii-hq/workers#469: Related stop-reason rendering uses the stable entry ID added by this change.

Suggested reviewers: ytallo

Poem

A rabbit tapped stop with a swift little toe,
And streams learned the moment to quietly go.
Watches rang clear, upstreams fell still,
A stopped note was saved by the turn-loop’s quill.
“No stuck buttons!” I cheer with delight—
Then hop through the logs into moonlit night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main theme: end-to-end prompt cancellation for the stop button flow.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/harness-stop-button

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@console/web/src/components/chat/ChatView.tsx`:
- Around line 1288-1297: Update the stop handler around setStopping so the
stopping guard reads the current stopping value from the callback closure
instead of using a state updater. Keep the early return when already stopping,
then perform abortRef.current?.abort(), backend.abortRun?.(sessionId), and the
failure reset in the event handler body; use setStopping only to update state.

In `@provider-anthropic/src/stream_fn.rs`:
- Around line 35-37: Register the abort watch before the first await that opens
the sink, then pass the receiver into the downstream stream-call function. Apply
this to provider-anthropic/src/stream_fn.rs (lines 35-37),
provider-kimi/src/stream_fn.rs (lines 36-38),
provider-openai-codex/src/stream_fn.rs (lines 38-40),
provider-openai/src/stream_fn.rs (lines 51-53), and
provider-zai/src/stream_fn.rs (lines 34-36); ensure the Kimi flow removes the
watch if sink opening fails.

In `@provider-llamacpp/src/stream_fn.rs`:
- Around line 15-40: Replace the manual post-run cleanup in make_stream with an
RAII Drop guard that owns the optional request_id and removes it from
StreamAborts when the async scope is exited, including cancellation while
run_stream_call is suspended. Ensure normal completion and early returns remain
safe, and avoid duplicate removal when an abort has already consumed the entry.

In `@provider-xai/src/stream_fn.rs`:
- Around line 45-49: Subscribe to the abort registry before the first await in
the stream closure, before calling open_sink, and retain the receiver for the
request lifecycle. Pass that receiver into run_stream_call and remove its
delayed aborts.watch subscription; ensure any open_sink failure removes the
registry entry before returning the error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f85b4b8c-a51c-41a0-a5d6-aceb357185f2

📥 Commits

Reviewing files that changed from the base of the PR and between beb7937 and dc172f8.

⛔ Files ignored due to path filters (7)
  • provider-anthropic/Cargo.lock is excluded by !**/*.lock
  • provider-kimi/Cargo.lock is excluded by !**/*.lock
  • provider-llamacpp/Cargo.lock is excluded by !**/*.lock
  • provider-openai-codex/Cargo.lock is excluded by !**/*.lock
  • provider-openai/Cargo.lock is excluded by !**/*.lock
  • provider-xai/Cargo.lock is excluded by !**/*.lock
  • provider-zai/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (58)
  • console/web/src/components/chat/ChatView.tsx
  • console/web/src/components/chat/Composer.tsx
  • console/web/src/lib/backend/translate.test.ts
  • console/web/src/lib/backend/translate.ts
  • harness/src/clients/router.rs
  • harness/src/deps.rs
  • harness/src/functions/stop.rs
  • harness/src/locks.rs
  • harness/src/turn_loop.rs
  • llm-router/src/chat/chat.rs
  • llm-router/src/provider_scaffold/aborts.rs
  • llm-router/src/provider_scaffold/mod.rs
  • llm-router/src/provider_scaffold/pump.rs
  • llm-router/src/types/router.rs
  • provider-anthropic/src/register.rs
  • provider-anthropic/src/stream_fn.rs
  • provider-anthropic/src/surface.rs
  • provider-anthropic/src/upstream.rs
  • provider-anthropic/tests/golden/schemas/provider.anthropic.abort.json
  • provider-anthropic/tests/schemas.rs
  • provider-kimi/src/register.rs
  • provider-kimi/src/sse.rs
  • provider-kimi/src/stream_fn.rs
  • provider-kimi/src/surface.rs
  • provider-kimi/src/upstream.rs
  • provider-kimi/tests/golden/schemas/provider.kimi.abort.json
  • provider-kimi/tests/schemas.rs
  • provider-llamacpp/src/register.rs
  • provider-llamacpp/src/stream_fn.rs
  • provider-llamacpp/src/surface.rs
  • provider-llamacpp/src/upstream.rs
  • provider-llamacpp/tests/golden/schemas/provider.llamacpp.abort.json
  • provider-llamacpp/tests/schemas.rs
  • provider-openai-codex/src/register.rs
  • provider-openai-codex/src/stream_fn.rs
  • provider-openai-codex/src/surface.rs
  • provider-openai-codex/src/upstream.rs
  • provider-openai-codex/tests/golden/schemas/provider.openai-codex.abort.json
  • provider-openai-codex/tests/schemas.rs
  • provider-openai/src/register.rs
  • provider-openai/src/stream_fn.rs
  • provider-openai/src/surface.rs
  • provider-openai/src/upstream.rs
  • provider-openai/tests/golden/schemas/provider.openai.abort.json
  • provider-openai/tests/schemas.rs
  • provider-xai/src/register.rs
  • provider-xai/src/responses.rs
  • provider-xai/src/stream_fn.rs
  • provider-xai/src/surface.rs
  • provider-xai/src/upstream.rs
  • provider-xai/tests/golden/schemas/provider.xai.abort.json
  • provider-xai/tests/schemas.rs
  • provider-zai/src/register.rs
  • provider-zai/src/stream_fn.rs
  • provider-zai/src/surface.rs
  • provider-zai/src/upstream.rs
  • provider-zai/tests/golden/schemas/provider.zai.abort.json
  • provider-zai/tests/schemas.rs

Comment thread console/web/src/components/chat/ChatView.tsx Outdated
Comment thread provider-anthropic/src/stream_fn.rs Outdated
Comment thread provider-llamacpp/src/stream_fn.rs Outdated
Comment thread provider-xai/src/stream_fn.rs Outdated
React forbids side effects inside a state updater — Strict Mode
double-invokes updaters in dev, which would fire abort()/abortRun twice.
Dedupe on a ref instead (also synchronous, so two clicks in one frame
can't both pass), keeping abort()/abortRun and the failure reset in the
event-handler body.
Two review findings across all seven provider stream fns:

- The abort watch was created only after `open_sink(...).await`, so a
  stop landing during sink setup hit an unknown request id, was
  consumed, and the upstream then started with a fresh untriggered
  watch. Register before the first await so the (level-triggered)
  signal latches.
- Sequential `aborts.remove(rid)` after the call is skipped if the
  executor drops the handler future mid-await, leaking the registry
  entry. Replace with an RAII `AbortGuard` (StreamAborts::register)
  that deregisters on Drop — covering early returns and cancellation.
@andersonleal andersonleal changed the title fix(harness): stop button — prompt cancellation end to end (MOT-4100) fix(harness): stop button — prompt cancellation end to end Jul 17, 2026
@andersonleal
andersonleal merged commit 3a5401d into main Jul 17, 2026
57 checks passed
@andersonleal
andersonleal deleted the fix/harness-stop-button branch July 17, 2026 19:21
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.

2 participants