Skip to content

fix(remote): stop button gives immediate feedback and prevents stuck thinking on remote server - #8619

Open
ImBIOS wants to merge 5 commits into
pingdotgg:mainfrom
ImBIOS:fix-remote-stop-no-feedback-stuck-thinking
Open

fix(remote): stop button gives immediate feedback and prevents stuck thinking on remote server#8619
ImBIOS wants to merge 5 commits into
pingdotgg:mainfrom
ImBIOS:fix-remote-stop-no-feedback-stuck-thinking

Conversation

@ImBIOS

@ImBIOS ImBIOS commented Aug 29, 2026

Copy link
Copy Markdown

Fixes #8618. Related: #4713, #8802.

Problem

Using a remote T3 Code server (relay/tunnel), clicking the red Stop button gives no UI feedback and the thread can stay stuck in Thinking / Working indefinitely.

Root causes, all verified on current main:

  1. No optimistic stopping stateonInterrupt fires and waits silently; the Stop button has no pending/disabled variant (contrast isStoppingBackgroundWorkStopping...).
  2. Stale turnId drops the interrupt — the client only includes turnId when the snapshot has it, and both the client reducer (threadReducer.ts) and the server projection (ProjectionPipeline.ts) early-return for turnId === undefined.
  3. Session hang on successProviderCommandReactor only tears down on interrupt failure. When the provider ignores the abort (or the turn already ended without a session-set), the session stays running and every further Stop is accepted with no effect.
  4. Queue buildupinterruptTurn is serial per thread, so rapid Stop clicks pile up behind each other.

Fix

  • Immediate feedback (web + mobile): isStoppingTurn / isStoppingThread set on click, cleared on failure or when the session leaves running/starting, reset on thread switch. The Stop button shows a spinner, is disabled, and announces Stopping.
  • turnId fallback: turnId-less thread.turn-interrupt-requested falls back to the session-pinned turn in both the client reducer and the server projection.
  • Server escalation: the reactor releases wedged sessions on Stop — immediately when the pinned turn already ended before this request (completedAt < createdAt guard keeps healthy in-flight interrupts on the normal path), or when a repeat Stop arrives 5s+ after the previous one for the same still-running session. Release = best-effort stopSession + forced session-set stopped with a visible reason, so the thread is resumable (same end state as the manual SIGTERM workaround in Thread session stuck in running after turn interrupt — stop button becomes a no-op #4713).
  • interruptTurn is now singleFlight per thread so rapid clicks share one in-flight request.

Also restores the @t3tools/shared/stripTerminalEscapes export dropped while merging main (fixes server typecheck).

Verification

  • vp test runProviderCommandReactor.test.ts (54, incl. 3 new: zombie release, repeat-stop escalation, in-grace negative), ProjectionPipeline.test.ts (28, incl. 1 new turnId-less fallback), threadReducer.test.ts (36, incl. 2 new), ComposerPrimaryActions.test.tsx (17, incl. 1 new), plus threads-atoms / threads-sync.
  • Scoped typechecks green: t3 (server), @t3tools/client-runtime, @t3tools/web, @t3tools/mobile, @t3tools/shared. Scoped vp lint exit 0.
  • Manual (remote): npx t3 --share on remote, connect via pairing URL, start a long turn, click Stop → button feedback instantly, Thinking/Working clears even if the provider is wedged; repeat Stop escalates within seconds.

Note

Fix stop button feedback and escalate repeat-stop for stuck remote sessions

  • Adds optimistic stopping state to web and mobile stop buttons. Buttons disable, show a "Stopping" label or spinner, and clear the state on interrupt failure or when work finishes.
  • Fixes client and server projection for interrupt events without turnId by falling back to the thread session's activeTurnId.
  • Adds repeat-stop escalation in ProviderCommandReactor to stop wedged sessions. A repeated stop after 5 seconds invokes a full session stop instead of a normal interrupt.
  • Adds stripTerminalEscapes and sanitizeTerminalValue utilities in stripTerminalEscapes.ts to remove terminal escape sequences from CLI output and model option values.
  • Risk: ThreadRouteContent retains the prior stopping value when switching directly between two active threads, as the identity change does not clear the state.

Macroscope summarized c8b4b3f.

ImBIOS and others added 3 commits August 21, 2026 11:05
…tored agent selections

opencode <=1.18 writes ESC ]0;<cwd>: ready BEL to stdout for every
non-help command even when stdout is a pipe (agent list, models
--verbose, debug skill). T3's ChildProcessSpawner captures that stdout
via collectStreamAsString and the parsers stored a polluted agent id
like "\x1b]0;imbios: ready\x07build" in model_selection_json.

Later sendTurn used that polluted id and opencode rejected it with
"Agent not found: \"\x1b]0;imbios: ready\x07build\"" which was
surfaced as session.error UnknownError + a generic SessionPrompt
UnknownError wrapper (the stack the user pasted).

Fix:
- packages/shared/src/stripTerminalEscapes.ts: shared OSC/CSI sanitizer
- apps/server/src/provider/opencodeRuntime.ts: strip before
  parseModels/Agent/Skills and via parse* entry points; keeps skills
  from silently degrading to [] when polluted
- apps/server/src/provider/Layers/OpenCodeProvider.ts: sanitize
  inventory agent names/variants and --version parsing; build clean
  capability option ids
- apps/server/src/provider/Layers/OpenCodeAdapter.ts &
  textGeneration/OpenCodeTextGeneration.ts: sanitize stored
  getModelSelectionStringOptionValue values before promptAsync
- packages/shared/src/model.ts: sanitize persisted option values and
  model slugs on read (repairs 3 polluted threads without DB migration)
- tests: add OSC/ANSI regression cases for both parsers

Polluted threads still read as clean via model.ts sanitizer; no
migration needed but DB can be cleaned with stripTerminalEscapes.

Fixes the reported UnknownError at SessionPrompt.createUserMessage
and the earlier "Agent not found" session.error.
…ng on remote

Fixes pingdotgg#8618

Remote stop had no optimistic state, so clicks over relay (100-400ms RTT
+ 50ms shell coalesce) looked dead while local 10-20ms masked it. Also
stale activeTurnId omitted turnId, causing thread.turn-interrupt-requested
to be ignored by threadReducer/ProjectionPipeline, and successful interrupts
that left the provider alive kept session in running forever (Working for
Xm Ys stuck).

This commit adds isStoppingTurn (mirrors isStoppingBackgroundWork) that
shows Stopping... instantly and clears when isWorking false or thread
switches. Remaining fallbacks (turnId guard relaxation, server 5s
escalation, singleFlight/timeout) are tracked in the forkhub intent
fix-remote-stop-no-feedback-stuck-thinking-7h3k9p2m and will follow in
follow-up commits.
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c863cd9-f882-4a7c-83ef-003675af4709

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 29, 2026
* through `shell: true` spawns.
*/
const OSC_RE = /\x1b\].*?(?:\x07|\x1b\\)/g;
const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;

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.

🟡 Medium src/stripTerminalEscapes.ts:17

stripTerminalEscapes leaves colon-form CSI sequences in the output, so \x1b[38:2::255:0:0mbuild (primary) becomes 38:2::255:0:0mbuild (primary) and the OpenCode agent-list parser drops that agent. CSI_RE only accepts [0-9;?], excluding valid ECMA-48 parameter bytes such as :, so match the complete 0x300x3f range.

Suggested change
const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
const CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/shared/src/stripTerminalEscapes.ts around line 17:

`stripTerminalEscapes` leaves colon-form CSI sequences in the output, so `\x1b[38:2::255:0:0mbuild (primary)` becomes `38:2::255:0:0mbuild (primary)` and the OpenCode agent-list parser drops that agent. `CSI_RE` only accepts `[0-9;?]`, excluding valid ECMA-48 parameter bytes such as `:`, so match the complete `0x30`–`0x3f` range.

@macroscopeapp macroscopeapp 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.

One finding: the new optimistic stopping state in ChatView.tsx is never rendered, so the Stop button still gives no feedback on remote.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeapp Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a broad production behavior change spanning remote session orchestration, event projection, command concurrency, and web/mobile Stop-state handling, including forced provider-session teardown. Unresolved substantive findings identify cases where Stop can remain unusable or leak its disabled state across threads, so the change warrants human review.

Not approved because:

  • 3 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

# Conflicts:
#	apps/server/src/provider/Layers/OpenCodeAdapter.ts
#	apps/server/src/provider/Layers/OpenCodeProvider.ts
#	apps/server/src/provider/opencodeRuntime.cliParsers.test.ts
#	packages/shared/package.json
Stop on a remote server gave no UI feedback and left the thread stuck
in Thinking: interrupts without a turnId were dropped by both
projections, and a provider that ignored the abort kept the session
pinned at running while every further Stop was accepted with no
effect (pingdotgg#8618, pingdotgg#4713, pingdotgg#8802).

- web/mobile: optimistic stopping state (Stopping feedback, disabled)
  held until the session leaves running
- client/server projection: turnId-less interrupts fall back to the
  session-pinned turn instead of no-op
- reactor: repeat Stop releases a wedged session (terminal turn still
  pinned, or same session running past a 5s grace) via stopSession +
  forced session stop, so the thread is resumable
- interruptTurn is singleFlight per thread so rapid Stop clicks share
  one in-flight request instead of queueing
}
// Remember this request so a later repeat Stop can escalate.
if (Number.isFinite(requestedAtMs)) {
recentTurnInterrupts.set(input.threadId, { turnId: activeTurnId, requestedAtMs });

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.

🟡 Medium Layers/ProviderCommandReactor.ts:1383

recentTurnInterrupts grows without bound when abandoned wedged threads never emit a cleanup event, so a long-lived server accumulates one entry per thread indefinitely. Bound the map (or expire entries) when recording a stop request.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProviderCommandReactor.ts around line 1383:

`recentTurnInterrupts` grows without bound when abandoned wedged threads never emit a cleanup event, so a long-lived server accumulates one entry per thread indefinitely. Bound the map (or expire entries) when recording a stop request.

(activeTurnId === null || latestTurn.turnId === activeTurnId) &&
latestTurn.state !== "running" &&
latestTurn.completedAt !== null &&
latestTurn.completedAt < input.createdAt

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.

🟡 Medium Layers/ProviderCommandReactor.ts:1346

The zombie check fails to release an already-completed turn when completedAt and createdAt use different valid timezone offsets, because their ISO strings are compared lexicographically rather than chronologically. Parse both IsoDateTime values before comparing them so a wedged session is released correctly.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProviderCommandReactor.ts around line 1346:

The zombie check fails to release an already-completed turn when `completedAt` and `createdAt` use different valid timezone offsets, because their ISO strings are compared lexicographically rather than chronologically. Parse both `IsoDateTime` values before comparing them so a wedged session is released correctly.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c8b4b3f. Configure here.

useEffect(() => {
// Per-thread: switching threads must not leak Stopping... to B
setIsStoppingTurn(false);
}, [activeThreadId]);

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.

Disabled Stop blocks wedged-session release

High Severity

A successful interrupt leaves isStoppingTurn / isStoppingThread set until the session leaves running, which disables Stop. maybeReleaseWedgedSession only calls stopSession on a later Stop after the 5s grace. When the provider ignores the abort, the control never re-enables and Thinking/Working never clears.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c8b4b3f. Configure here.

if (selectedThreadSessionStatus !== "running" && selectedThreadSessionStatus !== "starting") {
setIsStoppingThread(false);
}
}, [selectedThreadIdentity, selectedThreadSessionStatus]);

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.

Stopping state leaks across threads

Medium Severity

isStoppingThread is a single boolean, and the reset effect only clears it when the newly selected session is not running or starting. Switching from a stopping thread onto another live thread leaves that thread’s Stop disabled and announced as Stopping.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c8b4b3f. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Remote stop button gives no feedback and leaves thread stuck in Thinking

1 participant