Skip to content

A thread waiting on its own background command shows as done unless the agent marks the wait; T3 could mark obvious waits itself #35214

Open

Description

@Igloczek

What happened

An agent thread is still working: the agent started a background command to wait until its validation gates finish, and it will act when that command exits. The composer says "Running 2 commands · Wait for 184 gates (long), Wait for 184 gates to complete". The sidebar shows neither Working nor Waiting, so the thread looks done. T3 knows something is running in the background but ignores it when deciding the thread's status.

Diagnosis

The thread's state, read through the orchestrator MCP: status: "completed", activeRunId: null, pendingRequestCount: 0. The last run ended right after the agent:

  1. started a background command until grep -q DONE <evidence>/coord-status.txt; do sleep 60; done; cat … with the description "Wait for 184 gates",
  2. wrote "I'm waiting for the gates, then I decide whether to merge",
  3. ended its turn without calling t3_background_wait.

An earlier run did the same with another wait command.

How T3 decides today

  • Each pending background task has a provider-neutral shape, { kind, command, description, awaited } (packages/contracts/src/orchestrationV2.ts:1025-1038).
  • backgroundWorkHoldsCompletion (packages/shared/src/orchestrationV2PendingBackgroundWork.ts:90-108) holds the thread for a command or monitor only when awaited === true.
  • awaited is set only when the agent calls t3_background_wait, which goes through ThreadManagementService.awaitBackgroundTasks to the provider adapter's optional awaitBackgroundTasks.

The comment on that field states the assumption behind all this: "Only the agent knows whether a command is a wait or a dev server it left running."

Why that's fragile, independent of provider

Where the assumption is too strong: the agent is the only one who decides whether a command is a wait, but it usually says so in plain words, in the command's description ("Wait for 184 gates") and in the text around it ("I'm waiting for the gates" vs "the server is on localhost:3000"). T3 already has that text. It just doesn't read it.

Suggested fix

Keep the decision from #15433 / #24610 as it is: an awaited command holds the thread, and a dev server left running does not. Change only who can set awaited. When a background task starts and the agent hasn't labelled it, T3 asks its existing text-generation model whether this is a wait, and if so marks it awaited itself.

  • It reuses an existing service. apps/server/src/textGeneration/TextGeneration.ts already runs short model jobs for T3 itself (generateThreadTitle, generateBranchName, generateCommitMessage, generatePrContent, generateReviewGuide, generateTranscriptionPostProcessing). It has drivers for several providers and uses the model the user picked in textGenerationModelSelection. This would be one more operation, e.g. classifyBackgroundTask. No new provider coupling, and nothing reaches a model that the user hasn't already sent titles, diffs and transcripts to.
  • Input: the command, its description and the agent's text just before and after starting it. Output: wait or not_wait, using wait only when the agent clearly intends to act on the result. Fixed rules wouldn't work: custom scripts (./scripts/qa.sh, bun run e2e:full) and the agent's own words ("tell me when to stop it") decide the answer, and rules can't read either reliably.
  • When: as soon as the task starts. A background command returns right away and the turn usually goes on, so the answer is normally there before the turn ends. If it isn't, the completion notification waits a moment. Either way the thread doesn't flash Done, and there's no race with auto-settle.
  • Provider-neutral: it runs over the shared { command, description } task shape where pending background tasks are recorded or projected. So every provider that reports background commands benefits, including those without awaitBackgroundTasks.
  • A failure keeps today's behaviour. If the call fails, times out or text generation is off, the task stays un-awaited, exactly as now. The classifier can only add correct "Waiting" states, never break existing ones.
  • The agent's word wins. Tasks the agent already labelled skip the call. An explicit t3_background_wait({ taskIds, waiting: false }) un-marks an inferred wait, and waiting: true marks one the model missed. The user still has Stop. Because awaited is currently Literal(true), keeping an explicit "not a wait" needs that state stored somewhere (e.g. a per-task override next to the inferred value).
  • Cost: one short call per unlabelled background task, about the size of naming a thread.
  • No new kinds, UI or notification paths. The sidebar Waiting status, notifications, auto-settle and the composer strip already read awaited.

This thread's commands are easy cases: an until … sleep loop described as "Wait for 184 gates", started right after "I'm waiting for the gates". T3 already stores every background task's command and description, so the classifier can be checked against existing threads before merging.

Steps to reproduce

  1. In any thread whose provider runs background commands (reproduced with Claude), ask: "Run until [ -f /tmp/t3-done ]; do sleep 10; done in the background, tell me you're waiting for it, and end your turn. Don't call t3_background_wait."
  2. Once the turn ends, the composer shows "Running 1 command".
  3. The sidebar row shows no Working or Waiting status, and the completion notification fires.
  4. Run touch /tmp/t3-done. The agent wakes and keeps working in a thread that already reported itself as done.

Version

0.1.10-nightly.20261112.4564 (the server that ran the thread)

Environment

Server on Linux x64 (cloud). The thread ran on the Claude provider (claude-sonnet-5-5), viewed through the web client. The code paths above are provider-neutral, except awaitBackgroundTasks, which is implemented per adapter.

Evidence

# t3_thread_read (activity), redacted
thread.status=completed activeRunId=null pendingRequestCount=0 completedAt=2026-11-12T10:25:27Z

09:44:10  notification  Command "Wait for 184 gates" was stopped
09:44:21  command       E=~/<evidence>; sleep 30; until grep -q DONE $E/coord-status.txt 2>/dev/null; do sleep 60; done; cat $E/coord-status.txt
                        -> started in background (task bpouz203c)
09:44:35  assistant     "...gates restarted. They're waiting for the shared lock..."   (turn ends, no t3_background_wait)

10:25:22  command       until grep -q DONE $E1/coord-status.txt 2>/dev/null; do sleep 60; done; echo 184; cat $E1/coord-status.txt
                        -> started in background (task b22im3ntx)
10:25:27  assistant     "...I merge once the gates are green. When the gate queue reaches..."   (turn ends, no t3_background_wait)

Related issues

This builds on earlier discussion rather than reopening it:

Fix applied or workaround

None applied. Workaround: tell the agent to call t3_background_wait with the task IDs of its wait commands.

Filed by

claude (claude-opus-5-5) via T3 Code, following the t3 triage playbook

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions