Skip to content

feat: show a waiting state when background tasks outlive the turn#4255

Open
t3dotgg wants to merge 3 commits into
mainfrom
t3code/background-task-waiting-state
Open

feat: show a waiting state when background tasks outlive the turn#4255
t3dotgg wants to merge 3 commits into
mainfrom
t3code/background-task-waiting-state

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Jul 22, 2026

Copy link
Copy Markdown
Member

Problem

When Claude ends its turn while a background process is still running (a backgrounded `codex` review, a subagent), the thread folds to "Worked for Ns" and looks done — even though the Claude SDK will wake the session with a follow-up turn when the task finishes. Repro from tonight's session logs (thread `7210fae4`): Claude launched `codex review` with `run_in_background: true`, said "I'll report back when it completes", and the UI sat idle-looking for ~3 minutes until the task notification triggered a synthetic turn.

The SDK already tells us everything we need: the undeclared `background_tasks_changed` system message is a roster snapshot of live background tasks — the adapter was deliberately swallowing it (it used to only cause spurious warning rows).

Fix

  • ClaudeAdapter: track the background-task roster (`background_tasks_changed` snapshots, with `task_started`/`task_notification` as incremental fallbacks). On turn completion with a non-empty roster, emit `session.state.changed { state: "waiting", reason: "background-tasks", backgroundTasks }`; when the roster drains between turns, emit `ready` with an empty roster. Emissions are suppressed mid-turn, and the roster is dropped when a turn fails/interrupts so stale entries can't resurface.
  • Contracts: new optional `pendingBackgroundTasks` on `OrchestrationSession` and optional `backgroundTasks` on the `session.state.changed` payload. Deliberately no new status literal — the wire status stays `ready`, so stale clients/servers degrade to today's behavior instead of failing to decode (relevant to the remote-update-sync work).
  • Ingestion: roster-bearing state events map to status `ready` + `pendingBackgroundTasks`; every other lifecycle event clears the roster by omission. Roster events arriving while a turn is active are ignored (replay/race guard).
  • Persistence: migration 033 adds `pending_background_tasks_json` to `projection_thread_sessions`; repository + snapshot query round-trip it.
  • Web: when the timeline isn't working and the session has pending background tasks, render a pulsing row — "Waiting on background task: Run Codex review of the plan (gpt-5.6-sol)". Composer stays fully usable (steering already works while waiting).

Notes

  • Claude-specific by necessity (the roster events are an SDK feature), but the `pendingBackgroundTasks` plumbing is provider-agnostic if Codex grows an equivalent.
  • Complements (WIP) feat: subagent & workflow observability — Agents panel #4220 (Agents panel): that PR shows who is running; this one fixes the thread-level lifecycle so the session doesn't read as finished.
  • Reviewed by gpt-5.6-sol (codex); both findings (mid-turn roster race, stale roster after failed turns) fixed with regression tests.

Testing

  • `ClaudeAdapter.test.ts`: waiting emitted after turn completion with live roster, drained→ready on empty roster, roster dropped on failed turns.
  • `ProviderRuntimeIngestion.test.ts`: roster → `pendingBackgroundTasks` on ready session, cleared on drain and on turn start, stale mid-turn roster ignored.
  • `MessagesTimeline.logic.test.ts`: waiting row rendered when settled with pending tasks, suppressed while working.
  • Full typecheck across the monorepo; orchestration/persistence/contracts/client-runtime/web-chat suites green.

🤖 Generated with Claude Code


Note

Medium Risk
Changes thread session lifecycle, settling invariants, and persistence schema, but uses additive optional fields and includes race/stale-roster guards with regression tests.

Overview
Threads no longer look idle when a provider background task (e.g. a Claude background shell) keeps running after the assistant turn ends. The wire session status stays ready; clients learn about the wait via optional pendingBackgroundTasks on OrchestrationSession.

Claude adapter tracks the SDK background_tasks_changed roster (with task_started / task_notification fallbacks) and emits between-turn session.state.changed events carrying a backgroundTasks roster. Failed or interrupted turns clear the roster so stale waits cannot reappear.

Ingestion maps roster events to pendingBackgroundTasks, keeps status ready, ignores roster updates while a turn is active, and clears the list on turn start and other lifecycle transitions. Projection + DB migration 034 persist pending_background_tasks_json and expose it in snapshot queries.

Settling is blocked server- and client-side while pendingBackgroundTasks is non-empty. The web UI treats that state as working in the sidebar and shows a waiting-on-background-task timeline row when the turn is settled but tasks remain.

Reviewed by Cursor Bugbot for commit 88adca5. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Show a waiting state in chat UI when background tasks outlive the current turn

  • Adds pending_background_tasks_json column to projection_thread_sessions via migration 034, and persists/retrieves the background task roster through the repository and snapshot query layers.
  • ClaudeAdapter tracks background tasks per session and emits session.state.changed events with a waiting/ready state and task roster between turns; tasks are cleared on turn failure.
  • ProviderRuntimeIngestion applies background task roster events only between turns, sets wire status to ready, and propagates pendingBackgroundTasks on the session.
  • The chat timeline gains a waiting-background row variant that renders a pulsing indicator when tasks are pending and no turn is active; the sidebar reports working for such sessions.
  • Client-side canSettle and effectiveSettled block settling while pendingBackgroundTasks is non-empty; the server-side decider also rejects settle commands in this state.

Macroscope summarized 88adca5.

@coderabbitai

coderabbitai Bot commented Jul 22, 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

Run ID: 9392028a-811b-48d0-8b37-73a25471f8cf

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/background-task-waiting-state

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

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 22, 2026
// replayed one mid-turn would null the active turn and settle a
// genuinely running turn. The turn lifecycle already clears
// pendingBackgroundTasks when a turn starts.
return event.payload.backgroundTasks === undefined || activeTurnId === null;

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/ProviderRuntimeIngestion.ts:1348

A stale or replayed non-empty backgroundTasks roster event arriving after a newer empty roster restores pendingBackgroundTasks, falsely showing the thread as waiting on background work. The session.state.changed guard accepts any roster event when activeTurnId is null, so a late or duplicate non-empty roster re-populates pendingBackgroundTasks after it was already cleared, and the thread remains stuck in that state until another lifecycle event arrives. Consider tracking a monotonic sequence or watermark on roster events so out-of-order replays are rejected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts around line 1348:

A stale or replayed non-empty `backgroundTasks` roster event arriving after a newer empty roster restores `pendingBackgroundTasks`, falsely showing the thread as waiting on background work. The `session.state.changed` guard accepts any roster event when `activeTurnId` is `null`, so a late or duplicate non-empty roster re-populates `pendingBackgroundTasks` after it was already cleared, and the thread remains stuck in that state until another lifecycle event arrives. Consider tracking a monotonic sequence or watermark on roster events so out-of-order replays are rejected.

runtimeMode: thread.session?.runtimeMode ?? "full-access",
activeTurnId: nextActiveTurnId,
lastError,
...(pendingBackgroundTasks !== undefined ? { pendingBackgroundTasks } : {}),

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.

Waiting roster cleared by idle events

High Severity

Non-roster lifecycle events, such as session.state.changed from Claude, inadvertently clear pendingBackgroundTasks from the session state. This causes the UI to incorrectly show the session as idle or finished while background work is still active.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 16c1506. Configure here.

Comment thread apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. New feature introducing background task waiting states with complex state management. Multiple unresolved review comments identify HIGH severity bugs in roster event handling and error state management that need to be addressed.

You can customize Macroscope's approvability policy. Learn more.

t3dotgg and others added 3 commits July 22, 2026 13:47
When Claude ends its turn with background work still running (background
shells like codex reviews, subagents), the thread showed 'Worked for Ns'
and looked done even though the SDK wakes the session when the task
finishes. Track the SDK's background-task roster in the Claude adapter,
surface it through session.state.changed → session.pendingBackgroundTasks
(additive field, no new status literal, so stale clients degrade to
today's behavior), and render a pulsing 'Waiting on background task: …'
row in the web timeline while the thread waits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From gpt-5.6-sol review: (1) ignore roster-bearing session.state.changed
events while a turn is active so a delayed/replayed roster can't settle a
running turn; (2) drop the adapter's roster when a turn fails or is
interrupted so stale entries can't resurface as a bogus waiting state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… lifecycle

Rebased onto main's sidebar v2 (#4026): migration renumbered 033→034
(settled lifecycle took 033), and the pending-turn-start status resolution
in ingestion merged with the roster branch.

A thread waiting on background tasks is in motion, not done:
- resolveSidebarV2Status shows it as 'Working' instead of the receded
  ready state
- canSettle/effectiveSettled block settling (and override a stale settled
  pin), matching the running-session blockers
- the thread.settle decider rejects it server-side, mirroring the
  active-session invariant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotgg force-pushed the t3code/background-task-waiting-state branch from 16c1506 to 88adca5 Compare July 22, 2026 20:51

@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 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 88adca5. Configure here.

yield* emitBackgroundWaitState(context, {
rawMethod: "claude/system/task_notification",
rawPayload: message,
});

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.

Drain after failure clears error

High Severity

After a failed turn clears the background-task map, a later task_notification still calls emitBackgroundWaitState, which emits session.state.changed with state: "ready" and an empty roster. Ingestion then overwrites the session error status from turn.completed and clears lastError.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 88adca5. 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:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant