[Bug]: Thread shows as done at every intermediate stop of a multi-stage background job (sidebar Done badge, desktop "Thread completed", phone "Agent finished"), not once at the end#25585
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
packages/contracts or packages/shared
Summary
For long jobs the agent can't finish in one turn, such as ML training, evaluation, video renders or data backfills, it works in a loop:
plan → start a job in the background → end the turn → (job finishes) → agent is woken → read results, decide the next step → start the next job → end the turn → … → final answer
Every time the agent ends a turn to wait for its own background job, T3 Code reports the thread as done on every surface, even though the work isn't finished and is still being monitored:
- 📱 Phone: an "Agent finished" push.
- 🖥️ Desktop sidebar: the thread row shows the green Done badge, or no status at all, but never Working or Waiting.
- 🖥️ Desktop notification: a "Thread completed" OS notification and sound (with "Notifications with sound").
In a 4-stage pipeline the user gets 4 "finished" signals on each surface, and only the last one is true. "Done" stops meaning done, so the user has to open the thread each time to check. This has been happening for a long time.
Related: #15099 (closed as fixed by #24610), #14831, #13625, #19926. This report covers what is still broken after those:
- Stable is still affected. The fix in #24610 (
t3_background_wait) is not in any stable release. v0.1.2 (the current "Latest") does not have the tool. I reproduced the false completions live on it, on the phone path and the desktop sidebar and notification path. Details below. - On
main, the hold is opt-in at every stage. A thread is held only if the agent calls a deferred MCP tool (which it first has to load through ToolSearch) with the right task ID before every turn end. If it misses one call in a 4-stage loop, the user sees a false Done on the sidebar, the desktop notification and the phone. - Only Claude can opt in.
awaitBackgroundTasksis optional onProviderAdapter, and onlyClaudeAdapterV2implements it. Every other provider gets"unsupported", so background commands never hold. - Monitors regressed on
main. In v0.1.2 a pendingmonitorheld completion. Since #24610 it holds only when marked. A common pattern is a background render or training command plus a Monitor that watches it. That pattern was held on v0.1.2 and now shows Done at every stop unless the agent marks it.
Example (what the user experiences)
User: "Fine-tune the model, evaluate it, and if it beats the baseline, run the ablations and write up the results."
| # | Agent | Background | Desktop sidebar | Desktop notification | Phone |
|---|---|---|---|---|---|
| 1 | Plans, starts training (run_in_background), ends turn: "Training is running; I'll check the loss when it finishes." | training running (2 h) | Done ❌ | Thread completed 🔔 ❌ | Agent finished 🔔 ❌ |
| 2 | Woken when training ends. Reads the curves, starts evaluation, ends turn. | eval running (30 min) | Done ❌ | Thread completed 🔔 ❌ | Agent finished 🔔 ❌ |
| 3 | Woken when eval ends. Beats the baseline, so it starts ablations and ends the turn. | ablations running (1 h) | Done ❌ | Thread completed 🔔 ❌ | Agent finished 🔔 ❌ |
| 4 | Woken when ablations end. Writes the results. | none | Done ✅ | Thread completed ✅ | Agent finished ✅ |
Expected: while the agent waits on its own background job, the sidebar shows Working or Waiting · , and no completion notification or push fires. One Done, one desktop notification and one push happen at step 4 (or, for steps 1–3, a clearly non-final signal such as "Waiting on: Run evaluation"). Actual: Done, "Thread completed" and "Agent finished" at steps 1, 2, 3 and 4.
Steps to reproduce (deterministic, ~3 min, stable v0.1.2)
Turn on desktop notifications ("Notifications with sound"), and mobile push for the environment (T3 Connect).
Create a Claude thread (any model; I used Claude Haiku 5.5, low effort), open it, then switch to another thread or app.
Send it this prompt:
text1 This is a test of a two-stage mock ML pipeline. Keep replies to one short sentence. 2 Stage 1: use Bash with run_in_background=true to run: 3 sleep 75 && echo "train_loss=0.42" > ./stage1.txt 4 Then end your turn with: "Training is running in the background; I'll check results when it finishes." Do not poll, sleep, or use Monitor. 5 When notified that stage 1 finished, read ./stage1.txt, then start Stage 2 the same way: 6 sleep 75 && echo "eval_acc=0.91" > ./stage2.txt 7 Then end your turn with: "Evaluation is running in the background; I'll report when it finishes." 8 When notified that stage 2 finished, read ./stage2.txt and reply: "Pipeline done: train_loss=0.42, eval_acc=0.91." Watch the thread row in the sidebar, the desktop notifications, and the phone. Each one reports "done" three times for one pipeline.
Evidence 1: desktop sidebar and desktop notification, live on stable v0.1.2
Setup: the v0.1.2 web client served by the same local server as the desktop app, with notificationMode: "notifications-and-sound" (the same as the desktop app's own client settings). The page was instrumented to emulate the app being in the background (document.hasFocus() → false). window.Notification was stubbed to record each notification the client tried to show. The sidebar state was read from each thread row's accessible label ("<title>, <status>, <project>").
| Stop | Run completed (UTC) | Still running (pendingBackgroundTasks) | Sidebar row label | Desktop notification recorded |
|---|---|---|---|---|
| 1 | 17:46:32.990 | [{"taskId":"bp99jnhf1","kind":"command"}] (training) | "Repro 2: …, No project": no Working or Waiting, so it looks idle* | "Thread completed" at 17:46:33.044 ❌ |
| 2 | 17:47:52.389 | [{"taskId":"bbgfft7ss","kind":"command"}] (evaluation) | "Repro 2: …, Done, No project" ❌ | "Thread completed" at 17:47:52.442 ❌ |
| 3 | 17:49:10.715 | [] | "Repro 2: …, Done, No project" ✅ | "Thread completed" at 17:49:10.769 ✅ |
Runs 2 and 3 were started automatically by wake messages with notification.source.kind: "background_command". Each false "Thread completed" fired about 50 ms after the run ended, while the job's sleep process was still alive.
* At stop 1 the Done badge was hidden only because a client had the thread open at that moment: a thread.visit moved lastVisitedAt to exactly the completion time. The row still showed neither Working nor Waiting. At stop 2 nobody was viewing the thread, and the row showed Done while the evaluation job was running.
The completion sound uses the same trigger as the desktop notification (playNotificationSound(kind, …) runs right before it in ThreadNotificationCoordinator). My stub didn't capture the audio path, so the sound comes from reading the code, not from observation.
Evidence 2: phone push path, live on stable v0.1.2
Same pipeline in a separate thread (60 s stages). Server state captured with read-only SQLite reads of statev2.sqlite and t3_thread_read at each stop:
| Run | Triggered by | Run completed (UTC) | pendingBackgroundTasks at completion | Thread status | Projected relay phase (v0.1.2 rule) | Agent's last message |
|---|---|---|---|---|---|---|
| 1 | user | 17:33:42.335 | [{"taskId":"b4ktlbi02","kind":"command"}] (the sleep 60 process was alive, checked with pgrep) | completed, activeRunId: null | completed → "Agent finished" | "Training is running in the background; I'll check results when it finishes." |
| 2 | wake background_command, requested 17:34:40.942 | 17:34:50.229 | [{"taskId":"bx0xyludo","kind":"command"}] | completed | completed → "Agent finished" | "Evaluation is running in the background; I'll report when it finishes." |
| 3 | wake background_command, requested 17:35:49.036 | 17:35:54.932 | [] | completed | completed → "Agent finished" ✅ | "Pipeline done: train_loss=0.42, eval_acc=0.91." |
None of the pending tasks carry awaited, and on v0.1.2 the agent has no way to set it. The server's relay publish spans (AgentAwarenessRelay.publishThread) do fire at each stop. They carry no thread or phase attributes, so I could not tie individual APNs deliveries to them from the logs. The phase decision is deterministic from the state above.
Evidence 3: how often this happens in real use (one machine)
Scan of the raw Claude SDK streams in ~/.t3/userdata/logs/provider/events.*.log*. For each result (turn end), I recorded the background_tasks_changed roster that was live at that moment:
Since the v0.1.2 release (2026-10-22 20:44 UTC): 134 Claude turn ends. 22 of them (16%) ended with only an unmarked background command still running, so each showed as done on v0.1.2.
In a data-processing thread, one user request produced three
completedruns: "the download is running in the background" → wake → "the upload is running in the background" → wake → actually done. The user then sent "see how much is it completed" three times, because the thread kept looking finished.Multi-stage render thread (2026-10-21). One request led to three turn ends, and each roster had a background render plus a Monitor watching it:
- 17:42:36 "Waiting for the preview render to finish.": roster
[local_bash: Render all chapters low-q…, Monitor: low-q render…] - 17:47:53 "The final render is running … I'll verify the output when it finishes.": roster
[local_bash: Final 1080p60 render…, Monitor: final 1080p60 render] - 18:03:06 the actual result: roster
[]
On v0.1.2 the Monitor holds these stops. On
mainthey show as done unless the agent callst3_background_wait(point 4 above).- 17:42:36 "Waiting for the preview render to finish.": roster
Root cause
All three surfaces use the same rule, backgroundWorkHoldsCompletion. When a run completes and the remaining background work doesn't "hold completion", the thread counts as finished.
The rule. On v0.1.2 (packages/shared/src/orchestrationV2PendingBackgroundWork.ts @ v0.1.2), a command never holds, so a background Bash job always reads as finished:
| 1 | case "command": |
| 2 | return false; |
| 3 | case "subagent": |
| 4 | case "monitor": |
| 5 | case "background_task": |
| 6 | return true; |
On main @ e762f09e (orchestrationV2PendingBackgroundWork.ts:89-108, after #24610), commands and monitors hold only when the agent marks them awaited:
| 1 | // Commands and monitors are as often a dev server or its watchdog as a wait, so they hold |
| 2 | // only when the agent marks them awaited. |
| 3 | case "command": |
| 4 | case "monitor": |
| 5 | return false; |
The only way to set awaited is the agent calling t3_background_wait (apps/server/src/mcp/toolkits/orchestrator/tools.ts:120, handler handlers.ts:60-80). The Claude system prompt asks it to (ClaudeAdapterV2.ts:1396-1403). Other providers get "unsupported" (ThreadManagementService.ts:1142, optional awaitBackgroundTasks at packages/provider-core/src/server/ProviderAdapter.ts:595).
Desktop sidebar: Done badge, no Working or Waiting. shellRuntime (packages/client-runtime/src/state/models.ts:216-223 @ v0.1.2, :220-227 on main) parks the thread at idle (shown as Waiting) only when the rule holds:
| 1 | const parkAtIdle = |
| 2 | backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && |
| 3 | thread.status !== "failed"; |
| 4 | ... |
| 5 | const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); |
For an unmarked background command, the runtime status stays completed. resolveSidebarThreadStatus (apps/web/src/components/Sidebar.logic.ts:894 @ v0.1.2, :868 on main) then returns ready. hasUnseenCompletion (:716 @ v0.1.2, :689 on main) is true because the run's completedAt is newer than lastVisitedAt. So SidebarThreadRow.tsx renders the Done badge (:636/:696 @ v0.1.2, :684/:750 on main).
Desktop notification and sound: "Thread completed". ThreadNotificationCoordinator.tsx (v0.1.2 :139-174, :222) uses resolveSidebarThreadStatus too. status === "ready" && latestRun.status === "completed" with a newer completedAt counts as a new completion, which plays the completion sound and shows the toast or OS notification. On main (:187-210) this goes through createThreadNotificationGate, which waits only WORKING_EXIT_GRACE_MS = 2_000 (packages/client-runtime/src/state/threadInbox.ts:115). That covers the run-to-continuation handoff race from #13625, not a job that runs for minutes or hours.
Phone push: "Agent finished". projectThreadAwarenessV2 (packages/shared/src/agentAwareness.ts:97-135) maps a completed run to completed unless the rule holds:
| 1 | case "completed": |
| 2 | return backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) |
| 3 | ? "running" |
| 4 | : "completed"; |
The relay then alerts on every move into completed (apps/server/src/relay/AgentAwarenessRelay.ts:557-563, comment: "the relay alerts on every move into completed"). Its 5 s confirmation hold (nowMs + 5_000) has the same limit as the desktop's 2 s grace: it only covers the gap between a run ending and its continuation starting. Here the continuation starts when the job finishes, so every intermediate stop is a fresh completion (infra/relay/src/agentActivity/agentActivityAlerts.ts:149-161).
In short: whether the sidebar, the desktop notification and the phone all say "done" at a background-job stop depends entirely on the agent volunteering a tool call. In a multi-stage loop it has to do that at every stage. On stable it can't do it at all.
Suggested fix direction
The opt-in rule from #24610 suits auto-settle and the inbox, where a dev server must not keep a thread "working" forever. But a Done badge or notification while the agent's own job is still running is misleading, and a notification or push can't be taken back. Options:
- Show a distinct state while background work from the finished run is still pending (marked or not). In the sidebar, use Waiting · , not Done or blank. The roster already has
description(this was the proposal in #19926). Keep Done for an empty roster. A dev server left running then reads "Waiting · dev server" instead of being stuck in Working, which is still accurate. - Defer completion notifications (desktop and push) while that work is pending. Fire them when one of these happens:
- (a) the roster drains and no wake run starts within the existing grace or confirmation window;
- (b) the wake run completes with an empty roster;
- (c) a cap passes (for example 10–15 min), so threads that leave a dev server running still get notified, just later.
- Or send a non-final signal at a background-wait stop, such as "Waiting on: Run stage 2 evaluation". Keep "Thread completed" and "Agent finished" for an empty roster.
- Ship #24610 in a stable release. v0.1.2 users currently have no way to avoid this other than turning off notifications.
- Consider whether a Monitor that watches a pending background command from the same run should hold by default (the render and training pattern), since it held on v0.1.2.
Regression test idea: a completed run with an unmarked pending command, followed by a background_command wake run that completes with an empty roster, should produce exactly one Done transition, one desktop completion notification and one relay completion alert.
Impact
Major degradation or frequent failure
Version or commit
Reproduced on stable v0.1.2 (desktop app, web client and server 0.1.2). Code references are on v0.1.2 and main @ e762f09e.
Environment
macOS (arm64), T3 Code desktop 0.1.2 with "Notifications with sound", Claude provider (Claude Code SDK; Opus 5.5 in real use, Haiku 5.5 for the repro), mobile push via T3 Connect.
Workaround
None, other than turning off notifications and ignoring the sidebar Done badge. On nightly builds that include #24610, telling the agent to call t3_background_wait before ending each turn should help, but it depends on the model complying every time.
Contributions welcome
This is open for anyone who wants to pick it up. The root cause, a deterministic repro, and fix options are laid out above. If you'd like to take it, leave a comment so we don't double up, and feel free to ask questions on the issue.