You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Proposal: make "waiting on other work" an explicit, bounded thread state
#26689
The sidebar answers one question per thread: does this need me now? It gets that wrong in two opposite directions, and both come from the same gap. T3 can tell that some background work exists, but it has no record of what a thread is waiting on, and nothing bounds how long a wait may hold the thread.
Waiting threads land in the inbox. An agent that is waiting on a ScheduleWakeup timer (#20249), between stages of a long job (#25585), on a background command under a provider without t3_background_wait, or on another thread it does not own as a child, ends its turn and the thread returns to the top of the inbox as if it needed the user. The same happens in the gap between a hold ending and its wake run existing (#24297, #26083).
Dead waiters stay Working. Each hold ends only on its own exit events. A watched PR that goes quiet (checks done, review pending) is the clearest case: a watch ends on merge, close, Stop, unwatch, repeated read failures, a cap on comment-only wakes, or settlement. A quiet open PR hits none of the first six, and a held thread cannot auto-settle, so the thread stays Working until someone notices (#23329). A delegate on a linked environment that never finishes is followed with no upper bound, whether the other side is unreachable or simply never completes.
Each instance is being fixed in place, and the fixes are good. The class keeps growing because every new kind of waiting has to invent its own hold, wake and cleanup, and none of them is asked what happens if the thing it waits for never arrives.
Proposal in one paragraph
Add a thin, server-side wait record on top of the background work T3 already derives. It does not replace that derivation. It adds what is missing: what the thread is waiting on, when the wait expires, why it ended, and a way to declare waits T3 cannot see on its own. Each observer applies deadlines to the waits it watches. When a wait lapses, the thread is woken (or returned to the inbox) with the reason, instead of staying Working forever.
A smaller route is fine too. The timer and pending-wake fixes already in review, plus bounding PR watches in place (step 1 below), may be enough. The record is offered if you want one durable way to inspect and bound the remaining kinds; it is not a prerequisite for those fixes, and none of them should wait on this discussion.
Principles
Intent is explicit where it is ambiguous. A background command may be a wait or a dev server the agent walked away from (see Should background commands keep a thread in the Working section? #15433), so, as with t3_background_wait today, it holds only when the agent says so. Structured lifecycle signals that already mean "waiting" (a delegated child, a subagent, a PR watch, a provider-reported timer) keep holding without an extra call.
One derivation decides what exists; the record adds bounds. Every place that asks "does this thread have background work?" keeps reading the same shared derivation, so the sidebar, thread view, Stop and settlement never disagree. The record only adds deadlines, lapse reasons and declared waits. An expired wait stays listed, visible and stoppable; it only stops holding the thread.
Every hold has a deadline. A stale wait (its observer is gone or unreachable) is not an expired one (its deadline passed); a healthy observer can watch a hung target forever, so only a deadline bounds a hold. Once the target finishes, its execution deadline stops and a delivery deadline starts, counting only time in which the thread could have taken the wake.
Lapses fail loud and lose nothing. A lapse wakes the thread with the reason, or, when no wake can be dispatched, returns it to the inbox with the reason recorded as activity. Existing settlement rules then apply unchanged, including immediate settlement after a PR has merged or closed. Expiry releases the thread's hold only: it never stops a remote child, never marks a running provider turn terminal, never cancels an already-queued wake, and a target that finishes late still delivers its result once.
Provider-neutral and durable. One way to declare a wait for every provider, built on what T3 already persists where possible: delegated delivery records for children, and scheduled tasks for external events (webhooks) and timers (which need a new one-shot schedule kind; today's fixed_time recurs daily or on chosen weekdays). Waits survive restart and are reconciled after existing recovery has run.
Model
A wait has a kind (delegated child, linked-environment child, PR watch, provider background task, timer, another thread's run, external event), a declarer (the agent or the component that opened it), an observer (the component that reports on it), a deadline, and a state: open, delivery owed, satisfied, expired or cancelled, with a reason.
Placement applies after today's overrides (approvals and questions, pinned, snoozed, settled), first match wins:
Condition
Placement
Pill
Approval, input or guest request pending
Inbox
as today
A run is active
Working
Working (or Waiting for a blocking wait, as today)
At least one open or delivery-owed hold
Working
Waiting, with what it waits on and the nearest deadline ("delivering result" when only delivery is owed); marked stale if an observer is gone
The last hold just lapsed
Inbox, as a return
the lapse reason
Otherwise
Inbox or settled
as today
Satisfied, expired and cancelled waits hold nothing. Expiring one wait does not move the thread while another hold or an active run remains. Moving between Working and Waiting is never a return. Archive keeps today's per-kind behavior (it pauses a PR watch and disposes a delegated delivery); a lapse on an archived thread is recorded and never unarchives it. A deadline that passed while its observer could not look (archived thread, server down) is judged only after one fresh observation. A lapse wake tells the agent to hand back unless something changed. Re-watching an unchanged PR keeps the original deadline, and re-watching one whose deadline has already passed is refused with that reason, so a lapse cannot re-arm itself or loop.
Delivery
If you only want one thing, take step 1; we can stop there. Each step targets one underlying problem, and every step, including step 1, needs direction agreement here before any PR. The later steps are optional directions for discussion, not a request to approve a six-PR program.
Bound a quiet PR watch. An idle deadline on watches whose PR has made no watch-relevant progress, plus a longer absolute cap, ending the watch with a lapse wake. Re-watching an unchanged PR keeps the original deadline. Where today's code refuses the wake (archived, settled or native subagent threads), the watch simply ends, as after repeated read failures. Settlement is unchanged. It complements fix(server): watch_pull_request wakes once more and stops when the checks finish after a failure #25539, which ends one quiet case early.
The wait record as an overlay, behavior-neutral. Persist the record for existing holds and expose it on the thread shell. Deadlines are stored, not enforced.
Delivery owed. Hold the thread from target completion until the wake is handed off or the result is read in-run, with a bounded delivery deadline, for delegated children (on their existing durable delivery records) and provider background tasks (which need a durable wake-owed marker today's in-memory continuation lacks).
Deadlines. Enforce expiry and observer staleness for children, linked children and provider tasks, after boot recovery, including the lapse activity anchor for lapses that cannot wake.
Declared waits. A provider-neutral way to declare a timer, another thread's run or an external event. External events reuse webhook scheduled tasks; timers add a one-shot scheduled-task kind that is consumed exactly once.
Client detail. What a thread waits on, until when, stale marking and lapse reasons, on web and mobile.
Questions for maintainers
Is an overlay wait record the direction you want, or would you rather keep bounding each mechanism in place?
Are an idle deadline and an absolute cap on PR watches (step 1) acceptable on their own, and what should count as progress: any evaluated change including external comments (as sketched), or only head, check and review changes?
For declared waits, a new MCP tool, or a provider-neutral t3_background_wait that accepts non-task targets?
The defaults are product choices: 6 h for unfinished delegated and provider work, 24 h idle and 7 days absolute for PR watches, 1 h of deliverable time for a finished result (measured basis in the sketch). Are those acceptable, and should they be agent-passed only or also user-configurable?
When a delegate on a linked environment expires, should T3 only release the parent (as proposed) or also stop the remote child?
Declared timers and webhooks would be scheduled tasks. Should they stay out of the ordinary schedule list and show only in their thread (as proposed)?
Related open work (snapshot, refreshed before posting)
Checked read-only with gh api on 28 Oct 2026 at 15:04 PDT. States are a snapshot and should be refreshed before posting. No listed PR was merged at this check. Issue closure does not establish which implementation is on main. Handles are code-formatted to avoid notifications.
Please do not hold any of these fixes for this proposal; if they land first, later steps build on them.
directly addressed: Step 1 bounds the quiet watch when only required human approval remains. Approval-aware early handoff is the separate policy request.
complementary: A plain-text force-push permission request stays hidden while the watch holds Working. Attention precedence and explicit handback remain separate from idle expiry.
complementary: t3_thread_interrupt with stop: true stops background commands after a turn ends. Stop must dispose waits; releasing a wait alone does not stop its command.
Technical design (sketch for discussion, not a commitment to these names)
Snapshot overlay
These are proposed changes at the pinned baseline. derivePendingBackgroundWork remains authoritative for existing work. The wait overlay adds bounds, declared targets, delivery-owed inputs and generation-scoped non-holding marks; it never recreates the provider roster from wait rows.
Persist one full-row thread-wait.updated snapshot event type per wait. Keep terminal rows for replay. No refresh or stale events.
A row contains the thread, existing derived taskId, target generation, kind, declarer, observer identity, execution deadline, persisted delivery budget spent and last deliverable interval, coarse target progress, state and terminal reason. Generation checks dedupe observations; expiry suppresses only a second lapse, never a new completion. The existing taskId remains the dedupe identity.
Add the projection migration, EventSink application, ProjectionStore readers and a shell schema version bump. ProjectionMaintenance must verify the new table, delete it during rebuild, and compact snapshots by wait id, keeping first and latest like other entities.
Observer health stays in memory; compute stale at read time. Reconnect rebuilds observer health, not wait history. Projections persist across boot and rebuild only when verification fails.
Step 2 stores metadata without enforcing deadlines or releasing existing work. Step 3 enforces its delivery budget; step 4 activates execution deadlines for legacy holds from activation time, avoiding immediate mass expiry.
Preserve every existing hold
Use the existing derived taskId unchanged, including native-id fallback and roster-before-turn-item dedupe. Keep active-provider, settled-run, rolled-back-run and persistent-tool filters.
Source
Holding rule and observer
Bound once step 4 activates
App-owned local delegate turn item
Child task plus nested-work finalization; orchestration owns observation
6 h; stale when child deleted or owner runtime gone
Linked delegate turn item
Same local task id; linked follower observes remote completion including delegated work
6 h; stale on unreachable or forgotten link, with a provisional 30 min failure-observation window
Provider roster subagent or background_task
Holds by kind even without awaited; provider adapter and session generation own observation
6 h; stale when session or process generation gone
Provider roster command or monitor
Holds only when explicitly awaited; same adapter ownership
24 h idle, 7 day absolute; reactor gone or 60 min without successful check is stale
Declared thread-run
Exact thread/run observer, not arbitrary next message
6 h; deleted target or observer scope gone
Declared timer or external event
ScheduledTask service is the observer and wake authority
Due plus 5 min or 6 h external; scheduler scope gone is stale
The 6 h defaults exceed measured delegate p99 23.92 min and provider p99 116.73 min. The PR log sample has longest-quiet p99 89 min, a proxy between wakes rather than the proposed progress clock; 24 h exceeds it but remains provisional, as do the 7 day cap and timer grace. Linked completions number only 10. No elapsed-silence threshold applies to healthy event-driven owners. A successful unchanged PR read proves observer health, never progress.
At every return of the shared derivation, after dedupe and declared/delivery-owed additions, mark released generations holds: false; never remove still-running source work. Carry this through full projections, SQL shells and client hydration. Placement and backgroundWorkHoldsCompletion use the flag; Stop, interrupt, IdleCompaction and composer Stop controls still see listed work, including list-length checks. A reused taskId in a new generation is unaffected.
Server callers: ProjectionStore full and SQL shell readers, Orchestrator Stop selection and interrupt, IdleCompaction, and ThreadSettlementService including its roster-event filter and getSettlementCandidates must all use that shared result. Enqueue settlement on thread-wait.updated commits, even without a roster event. Child finalization (delegatedTaskProgress and Orchestrator's roster-empty trigger) and RestartContinuation hasOpenAwaitedWork count holding entries and outstanding holding wake debt as active child work; released entries do not count, even if still listed.
Client callers: threadExecution, ChatView.logic, operations/commands, agentAwareness, models, Sidebar.logic and mobile threadListV2 must use it too. Preserve non-holding visible tasks where callers use list length. Actual Stop still uses owned source work to stop a released computation.
Blocking t3_thread_wait and delegate_task mode wait remain derived display rows from deriveBlockedOnThreadWait and active dynamic-tool items. Store no blocking-call wait and add no heartbeat or timeout. Their active run and existing 10/60 min limits remain authoritative.
Delivery owed and expiry
Target completion stops the execution deadline and arms a 1 h delivery budget, identical for delegates, provider markers and declared fires. Count only deliverable time: no active run of the thread itself and no held queue, including restart recovery or a usage limit. Persist spent time across retries and restart; downtime consumes none. Delivery-owed rows hold Working/Waiting only while deliverable, showing "delivering result" and the remaining budget; a held queue releases that hold immediately, without consuming or discarding delivery debt. A completed target never emits an execution-timeout wake.
The code signals are queue_after_active (ProviderContinuationService:127), active-run steering eligibility (Orchestrator:5308-5341), live spawning-run suppression (:11576), queueHeld and client-only release (:5450), usageLimitHoldsQueue (:1492), and boot holding of non-buffered queued runs (ProviderRuntimeRecoveryService:343-357). Provider-failure/worktree holds (:1527-1540) also pause the budget. These existing signals inform the proposed clock; active steering does not make an active-run interval chargeable.
For delegates derive this from existing task completionDelivery and parent delegatedCompletion cohort. Delivered, acknowledged or disposed task states and a non-open cohort disposition release transport holds, subject to current-delivery and undelivered-mailbox-steer checks; a queued run not yet started still owes the bounded handoff below.
A steer commit alone does not prove consumption. Preserve debt for an undelivered steer eligible for redelivery and a pending sibling while the current delivery run runs or its successor cohort is reserved; apply the deliverability rule to its hold and clock. Reuse generation, messageId and taskIds without another delivery counter. A sibling coalesced into an already-queued generation inherits that generation's remaining budget, even if nearly exhausted; joining never resets it.
Delivery remains owed through queue start or in-run consumption, subject to the clock above. At exhaustion, atomically release the hold with reason "result could not be delivered" and record lapse activity. Fence only uncommitted retry debt; never cancel, fence or replace an already-queued wake. Preserve its normal queue execution and readable results in task_status and thread view. An already-active run remains active.
Provider-task continuation requests are in-memory closures today. Step 3 adds a durable wake-owed marker in the same commit that removes a completed holding task from the provider roster. Snapshot result and marker together. Clear transport debt in the wake dispatch/queue commit or explicit disposal, preserving queued delivery debt and its conditional hold above; rollback and retry retain the marker and spent budget. A marker id makes replay idempotent.
A new ThreadWaitReactor rechecks source state and generation under the command lane. For every wait kind whose deadline passed without observation during archive or downtime, perform one fresh source read before judging expiry; durable completion wins over an execution lapse. Completion wins when already recorded; expiry commits the generation-scoped non-holding mark, reason and lapse activity independently of dispatchability. Delivery dispatch, queue start and expiry share the serialized generation check. An expired target that finishes late still delivers exactly one completion wake through the normal path with its own 1 h deliverable-time budget, or records completion activity where no wake can be dispatched, including archived or settled threads. Keep completion receipt separate from lapse receipt: the fence suppresses a second lapse and duplicate completion, never the first late result. It never reopens an expired execution hold.
Attempt the wake when dispatchable, with a giveUp-style fallback that still commits the release and reason without it. PR watch sync already accepts null watch, endedWatch and wake. A rejected wake must not roll back expiry indefinitely.
In step 4, add lastLapseActivityAt and a separate lastCompletionActivityAt for no-wake late results to projection and shell readers, including getSettlementCandidates, and fold both into the activity maximum at ThreadSettlementService:218-224. Use engine message.dispatch for the new reactor's wake, not ThreadManagement's provider gate; if no wake commits, the lapse anchor supplies activity and an eligible unarchived thread's return. It preserves approval, snooze, pinned and settled precedence. Another hold or active run keeps Working. Terminal-PR immediate settlement still applies; no guaranteed visibility grace or acknowledgement state is added.
Lapse text instructs the agent to hand back unless something changed. Persist the expired PR's progress signature and original deadlines; re-watch on unchanged head and evaluated progress reuses those deadlines, including the absolute cap. Own comments or raw fingerprint churn cannot reset them. If either carried deadline has passed, reject the thread.pull-request.watch command in Orchestrator with the lapse reason, without another wake, so the agent tool (watch_pull_request in the pull request toolkit) and the web watch control both surface it.
After linked expiry, park normal blocking follows at a slow cadence; retain the durable remote Stop debt and its delivery path. Expiry neither stops the remote child nor marks any active provider run terminal.
Archive pauses PR observation, not the wall clock. After unarchive or downtime, obtain one fresh observation before judging a missed idle deadline; progress may refresh idle time, never the absolute cap. Read failure follows existing failure handling, not an assumed unchanged result. Delegated archive disposes deliveries; provider work keeps existing cleanup. A lapse racing archive is recorded but never unarchives or returns to inbox. ScheduledTask has no archive policy: sending to an archived target fails; retain the failure and judge the declared wait on unarchive after one fresh read, then lapse if still unsatisfied and overdue.
Quiet PR watch first
Add optional activeAt to ThreadPullRequestWatch in threadPullRequest.ts. evaluatePullRequestWatch advances it only on headMoved || changes.length > 0; use watch start as the legacy fallback.
Persist separate idleDeadline, absoluteDeadline, progress signature and activeAt in endedWatch. Orchestrator:3553-3582 carries these deadlines forward on unchanged re-watch, including after a rejected wake, but allocates a fresh startedAt as watch identity. Never reuse startedAt as deadline storage: an old in-flight read must fail its identity check against the replacement. Refuse already-overdue unchanged watches in that command before creating a watch.
Check idle and absolute deadlines in the reactor's sweep beside endsWithoutRead, before fingerprint early returns and rate-limited read-group skips, except that an unobserved overdue watch must receive the fresh read above first. Use existing record(target, null, idleWake, target.watch) with no-wake fallback. Add "idle" to WatchEndReason. Keep existing settlement behavior and watch-sync command shape. Like giveUp today, a wake refused because the thread became archived, settled or a native subagent ends without waking; no inbox return is promised in step 1. A stale startedAt is a generation mismatch, not authority to end a replacement watch. A missing provider does not refuse a watch-sync wake.
This is an independently implementable policy change requiring agreement on end condition and default, complementary to #25539, rather than an obvious-bug exception.
Recovery and declared waits
In runtimeLayer, make wait reconciliation and expiry startup depend explicitly on ProviderRuntimeRecoveryService completion and delegated-completion recovery. Gate continuation/expiry consumption until reconciliation finishes, rather than relying on layer declaration order.
Recovery retains delegated/cohort state and clears provider rosters today. Reconcile persisted wake-owed markers before observing absence as a new loss. Extend the existing restart background note with separate lines for "cancelled before finishing", "finished, result not delivered" and "lapsed: ". Fold these into its existing continuation, never a second restart wake. Folding a wake-owed marker into that note atomically consumes the marker and releases its hold, whether continuation runs or settings, session checks or loop cap decline it. A declined continuation retains the deferred note for the next user turn, without retry or lapse wake for that same marker. Disposed or delivery-expired uncommitted retry debt does not reoffer; committed queued wakes and first late completions remain eligible.
Step 5 adds an absolute-instant one-shot ScheduledTask kind and persisted consumed state; fixed_time recurs daily or on chosen optional weekdays. Reuse existing-thread targeting. Commit one winning transition per task/generation from armed to consumed, cancelled or expired, serialized with wait expiry/cancel. Consumed stores a stable fire identity and durable delivery intent together; retries reuse its commandId/messageId, never the current time. Restart replays an unacknowledged intent and catches up an overdue armed timer after a fresh target read, before judging wait expiry and without the fixed-time 10 min missed-run skip. A missed due time alone is not a lapse.
Exactly once means one logical fire and one deduped queued message, not one provider execution. A fire committed first cannot be re-fired or undone by cancel/expiry; later delivery uses the 1 h deliverable-time budget and never cancels a queued wake. Cancel/expiry committed first forbids fire. Recovery must check receipts and message identity before retrying an intent; a crash before dispatch loses nothing, and one after dispatch cannot duplicate the message.
External waits reuse webhook schedules, endpoint token, signatures, dedupe, filters and delivery-age limits. Accept only the first matching delivery per wait generation and durably disable further fires. Add a persisted wait-backed discriminator to hide these tasks from the ordinary schedule list and show them in the owning thread's detail, as proposed in Question 6; do not imply existing archive suppression. The wait record only holds and bounds.
Provider-native timers retain their adapter wake authority, keyed by session generation. Do not also create a server ScheduledTask for the same native timer.
Question 3 remains open: A, a new provider-neutral declaration tool; B, extend t3_background_wait with tagged timer, thread-run and external targets while preserving taskIds/awaited inputs. Both share declare/read/cancel authorization, bounds and idempotency. Return the wait id and deadline and tell the caller to end its turn only after delivery is durably armed.
Delivery checks
Paths below are relative to apps/server/src/orchestration-v2/ unless qualified. ScheduledTaskService is under apps/server/src/scheduledTasks/; MCP handlers are under apps/server/src/mcp/. New reactor, wait migration and declaration service names are proposed.
TestClock quiet/changed/rate-limited cases; old thread with inactivity settlement enabled, wake rejected after settlement during read, then unchanged re-watch preserving deadlines with fresh identity; overdue re-watch refused with reason; stale in-flight read cannot affect replacement; unarchive or downtime with fresh PR progress; one lapse; merged/closed settlement; no settlement change
2
packages/contracts/src/orchestrationV2.ts, wait migration, EventSink, ProjectionStore, ProjectionMaintenance, shared derivation and its callers
Replay after compaction, verify/rebuild, schema compatibility, native/roster dedupe, each mapped hold, old/new behavior equivalence
Completed awaited command with undispatched wake does not finalize child; parent busy over 1 h still receives completion; restart-held queue runs wake on next user message; usage-limit/failure holds pause budget; both delivery kinds exhaust after 1 h deliverable time, fence only uncommitted retry debt, retain queued wake and results; sibling inherits remaining budget; rollback/retry, in-run acknowledge, disabled-provider disposal, undelivered steer, pending sibling, restart note consumes marker, including declined continuation, without lingering hold or duplicate wake
Completion/expiry/queue-start races; completion after execution lapse wakes once with result or records activity, no second lapse; wait commits enqueue settlement; released child roster entries do not block finalization; fallback lapse activity; Stop and t3_thread_interrupt stop: true reach released provider work, compaction stays blocked; taskId reused in a new generation; healthy silent owners; boot ordering; archive race and unarchive fresh read; parked follower with owed Stop; active run or other live hold retains Working
Absolute one-shot due/catch-up before expiry, crashes before and after dispatch/ack, consumed restart, cancel/fire and expiry/fire races, no daily recurrence, archived send failure then unarchive read, webhook first-match isolation, wait-backed schedules hidden from list and shown in thread, unrelated messages, permissions, native-timer dedupe; test the chosen MCP contract after Question 3 resolves
6
Shell contracts/readers, client models/threadShell/threadInbox/threadExecution, web Sidebar and ChatView, mobile threadListV2
Aggregate placement, child approval precedence, snooze, stale annotation, lapse return activity, old-server fallback; inspect web/mobile screenshots and interactions
Drafted by AI agents on my behalf (Claude Opus 5.5 for the concept, GPT 6.1 Sol for the technical sketch and measurements), with three rounds of cross-model review against main at 9826dbb. Measurements come from one user's local T3 store and server logs and are aggregates only.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Problem
The sidebar answers one question per thread: does this need me now? It gets that wrong in two opposite directions, and both come from the same gap. T3 can tell that some background work exists, but it has no record of what a thread is waiting on, and nothing bounds how long a wait may hold the thread.
Waiting threads land in the inbox. An agent that is waiting on a ScheduleWakeup timer (#20249), between stages of a long job (#25585), on a background command under a provider without
t3_background_wait, or on another thread it does not own as a child, ends its turn and the thread returns to the top of the inbox as if it needed the user. The same happens in the gap between a hold ending and its wake run existing (#24297, #26083).Dead waiters stay Working. Each hold ends only on its own exit events. A watched PR that goes quiet (checks done, review pending) is the clearest case: a watch ends on merge, close, Stop, unwatch, repeated read failures, a cap on comment-only wakes, or settlement. A quiet open PR hits none of the first six, and a held thread cannot auto-settle, so the thread stays Working until someone notices (#23329). A delegate on a linked environment that never finishes is followed with no upper bound, whether the other side is unreachable or simply never completes.
Each instance is being fixed in place, and the fixes are good. The class keeps growing because every new kind of waiting has to invent its own hold, wake and cleanup, and none of them is asked what happens if the thing it waits for never arrives.
Proposal in one paragraph
Add a thin, server-side wait record on top of the background work T3 already derives. It does not replace that derivation. It adds what is missing: what the thread is waiting on, when the wait expires, why it ended, and a way to declare waits T3 cannot see on its own. Each observer applies deadlines to the waits it watches. When a wait lapses, the thread is woken (or returned to the inbox) with the reason, instead of staying Working forever.
A smaller route is fine too. The timer and pending-wake fixes already in review, plus bounding PR watches in place (step 1 below), may be enough. The record is offered if you want one durable way to inspect and bound the remaining kinds; it is not a prerequisite for those fixes, and none of them should wait on this discussion.
Principles
t3_background_waittoday, it holds only when the agent says so. Structured lifecycle signals that already mean "waiting" (a delegated child, a subagent, a PR watch, a provider-reported timer) keep holding without an extra call.fixed_timerecurs daily or on chosen weekdays). Waits survive restart and are reconciled after existing recovery has run.Model
A wait has a kind (delegated child, linked-environment child, PR watch, provider background task, timer, another thread's run, external event), a declarer (the agent or the component that opened it), an observer (the component that reports on it), a deadline, and a state: open, delivery owed, satisfied, expired or cancelled, with a reason.
Placement applies after today's overrides (approvals and questions, pinned, snoozed, settled), first match wins:
Satisfied, expired and cancelled waits hold nothing. Expiring one wait does not move the thread while another hold or an active run remains. Moving between Working and Waiting is never a return. Archive keeps today's per-kind behavior (it pauses a PR watch and disposes a delegated delivery); a lapse on an archived thread is recorded and never unarchives it. A deadline that passed while its observer could not look (archived thread, server down) is judged only after one fresh observation. A lapse wake tells the agent to hand back unless something changed. Re-watching an unchanged PR keeps the original deadline, and re-watching one whose deadline has already passed is refused with that reason, so a lapse cannot re-arm itself or loop.
Delivery
If you only want one thing, take step 1; we can stop there. Each step targets one underlying problem, and every step, including step 1, needs direction agreement here before any PR. The later steps are optional directions for discussion, not a request to approve a six-PR program.
Questions for maintainers
t3_background_waitthat accepts non-task targets?Related open work (snapshot, refreshed before posting)
Checked read-only with
gh apion 28 Oct 2026 at 15:04 PDT. States are a snapshot and should be refreshed before posting. No listed PR was merged at this check. Issue closure does not establish which implementation is on main. Handles are code-formatted to avoid notifications.Please do not hold any of these fixes for this proposal; if they land first, later steps build on them.
rolandorojasMdSadiqMdCookerTeaderek-elizahanneslipuschsignal-developer-botilyaliaoletrandattheovelikovUzEEjuliusmarmingenkoynovAnobleSCMt3_thread_interruptwithstop: truestops background commands after a turn ends. Stop must dispose waits; releasing a wait alone does not stop its command.brahimhamichanjuliusmarmingeScottN-PVjuliusmarmingejuliusmarmingeshivamhwpaglowienkasaphidsaphidUzEEjieyuexingroyalaid(this proposal's author)juliusmarmingehenrychu04BeLazy167kaovilaibrahimhamichanTechnical design (sketch for discussion, not a commitment to these names)
Snapshot overlay
These are proposed changes at the pinned baseline.
derivePendingBackgroundWorkremains authoritative for existing work. The wait overlay adds bounds, declared targets, delivery-owed inputs and generation-scoped non-holding marks; it never recreates the provider roster from wait rows.Persist one full-row
thread-wait.updatedsnapshot event type per wait. Keep terminal rows for replay. No refresh or stale events.A row contains the thread, existing derived taskId, target generation, kind, declarer, observer identity, execution deadline, persisted delivery budget spent and last deliverable interval, coarse target progress, state and terminal reason. Generation checks dedupe observations; expiry suppresses only a second lapse, never a new completion. The existing taskId remains the dedupe identity.
Add the projection migration, EventSink application, ProjectionStore readers and a shell schema version bump. ProjectionMaintenance must verify the new table, delete it during rebuild, and compact snapshots by wait id, keeping first and latest like other entities.
Observer health stays in memory; compute stale at read time. Reconnect rebuilds observer health, not wait history. Projections persist across boot and rebuild only when verification fails.
Step 2 stores metadata without enforcing deadlines or releasing existing work. Step 3 enforces its delivery budget; step 4 activates execution deadlines for legacy holds from activation time, avoiding immediate mass expiry.
Preserve every existing hold
Use the existing derived taskId unchanged, including native-id fallback and roster-before-turn-item dedupe. Keep active-provider, settled-run, rolled-back-run and persistent-tool filters.
subagentorbackground_taskcommandormonitordynamic_toolitembackground_taskhold; adapter owns observationsubagentitemThe 6 h defaults exceed measured delegate p99 23.92 min and provider p99 116.73 min. The PR log sample has longest-quiet p99 89 min, a proxy between wakes rather than the proposed progress clock; 24 h exceeds it but remains provisional, as do the 7 day cap and timer grace. Linked completions number only 10. No elapsed-silence threshold applies to healthy event-driven owners. A successful unchanged PR read proves observer health, never progress.
At every return of the shared derivation, after dedupe and declared/delivery-owed additions, mark released generations
holds: false; never remove still-running source work. Carry this through full projections, SQL shells and client hydration. Placement andbackgroundWorkHoldsCompletionuse the flag; Stop, interrupt, IdleCompaction and composer Stop controls still see listed work, including list-length checks. A reused taskId in a new generation is unaffected.Server callers: ProjectionStore full and SQL shell readers, Orchestrator Stop selection and interrupt, IdleCompaction, and ThreadSettlementService including its roster-event filter and
getSettlementCandidatesmust all use that shared result. Enqueue settlement onthread-wait.updatedcommits, even without a roster event. Child finalization (delegatedTaskProgressand Orchestrator's roster-empty trigger) and RestartContinuationhasOpenAwaitedWorkcount holding entries and outstanding holding wake debt as active child work; released entries do not count, even if still listed.Client callers: threadExecution, ChatView.logic, operations/commands, agentAwareness, models, Sidebar.logic and mobile threadListV2 must use it too. Preserve non-holding visible tasks where callers use list length. Actual Stop still uses owned source work to stop a released computation.
Blocking
t3_thread_waitanddelegate_taskmode wait remain derived display rows fromderiveBlockedOnThreadWaitand active dynamic-tool items. Store no blocking-call wait and add no heartbeat or timeout. Their active run and existing 10/60 min limits remain authoritative.Delivery owed and expiry
Target completion stops the execution deadline and arms a 1 h delivery budget, identical for delegates, provider markers and declared fires. Count only deliverable time: no active run of the thread itself and no held queue, including restart recovery or a usage limit. Persist spent time across retries and restart; downtime consumes none. Delivery-owed rows hold Working/Waiting only while deliverable, showing "delivering result" and the remaining budget; a held queue releases that hold immediately, without consuming or discarding delivery debt. A completed target never emits an execution-timeout wake.
The code signals are
queue_after_active(ProviderContinuationService:127), active-run steering eligibility (Orchestrator:5308-5341), live spawning-run suppression (:11576),queueHeldand client-only release (:5450),usageLimitHoldsQueue(:1492), and boot holding of non-buffered queued runs (ProviderRuntimeRecoveryService:343-357). Provider-failure/worktree holds (:1527-1540) also pause the budget. These existing signals inform the proposed clock; active steering does not make an active-run interval chargeable.For delegates derive this from existing task
completionDeliveryand parentdelegatedCompletioncohort. Delivered, acknowledged or disposed task states and a non-open cohort disposition release transport holds, subject to current-delivery and undelivered-mailbox-steer checks; a queued run not yet started still owes the bounded handoff below.A steer commit alone does not prove consumption. Preserve debt for an undelivered steer eligible for redelivery and a pending sibling while the current delivery run runs or its successor cohort is reserved; apply the deliverability rule to its hold and clock. Reuse generation, messageId and taskIds without another delivery counter. A sibling coalesced into an already-queued generation inherits that generation's remaining budget, even if nearly exhausted; joining never resets it.
Delivery remains owed through queue start or in-run consumption, subject to the clock above. At exhaustion, atomically release the hold with reason "result could not be delivered" and record lapse activity. Fence only uncommitted retry debt; never cancel, fence or replace an already-queued wake. Preserve its normal queue execution and readable results in task_status and thread view. An already-active run remains active.
Provider-task continuation requests are in-memory closures today. Step 3 adds a durable wake-owed marker in the same commit that removes a completed holding task from the provider roster. Snapshot result and marker together. Clear transport debt in the wake dispatch/queue commit or explicit disposal, preserving queued delivery debt and its conditional hold above; rollback and retry retain the marker and spent budget. A marker id makes replay idempotent.
A new ThreadWaitReactor rechecks source state and generation under the command lane. For every wait kind whose deadline passed without observation during archive or downtime, perform one fresh source read before judging expiry; durable completion wins over an execution lapse. Completion wins when already recorded; expiry commits the generation-scoped non-holding mark, reason and lapse activity independently of dispatchability. Delivery dispatch, queue start and expiry share the serialized generation check. An expired target that finishes late still delivers exactly one completion wake through the normal path with its own 1 h deliverable-time budget, or records completion activity where no wake can be dispatched, including archived or settled threads. Keep completion receipt separate from lapse receipt: the fence suppresses a second lapse and duplicate completion, never the first late result. It never reopens an expired execution hold.
Attempt the wake when dispatchable, with a giveUp-style fallback that still commits the release and reason without it. PR watch sync already accepts null watch, endedWatch and wake. A rejected wake must not roll back expiry indefinitely.
In step 4, add
lastLapseActivityAtand a separatelastCompletionActivityAtfor no-wake late results to projection and shell readers, includinggetSettlementCandidates, and fold both into the activity maximum at ThreadSettlementService:218-224. Use enginemessage.dispatchfor the new reactor's wake, not ThreadManagement's provider gate; if no wake commits, the lapse anchor supplies activity and an eligible unarchived thread's return. It preserves approval, snooze, pinned and settled precedence. Another hold or active run keeps Working. Terminal-PR immediate settlement still applies; no guaranteed visibility grace or acknowledgement state is added.Lapse text instructs the agent to hand back unless something changed. Persist the expired PR's progress signature and original deadlines; re-watch on unchanged head and evaluated progress reuses those deadlines, including the absolute cap. Own comments or raw fingerprint churn cannot reset them. If either carried deadline has passed, reject the
thread.pull-request.watchcommand in Orchestrator with the lapse reason, without another wake, so the agent tool (watch_pull_requestin the pull request toolkit) and the web watch control both surface it.After linked expiry, park normal blocking follows at a slow cadence; retain the durable remote Stop debt and its delivery path. Expiry neither stops the remote child nor marks any active provider run terminal.
Archive pauses PR observation, not the wall clock. After unarchive or downtime, obtain one fresh observation before judging a missed idle deadline; progress may refresh idle time, never the absolute cap. Read failure follows existing failure handling, not an assumed unchanged result. Delegated archive disposes deliveries; provider work keeps existing cleanup. A lapse racing archive is recorded but never unarchives or returns to inbox. ScheduledTask has no archive policy: sending to an archived target fails; retain the failure and judge the declared wait on unarchive after one fresh read, then lapse if still unsatisfied and overdue.
Quiet PR watch first
Add optional
activeAtto ThreadPullRequestWatch inthreadPullRequest.ts.evaluatePullRequestWatchadvances it only onheadMoved || changes.length > 0; use watch start as the legacy fallback.Persist separate
idleDeadline,absoluteDeadline, progress signature andactiveAtin endedWatch. Orchestrator:3553-3582 carries these deadlines forward on unchanged re-watch, including after a rejected wake, but allocates a freshstartedAtas watch identity. Never reusestartedAtas deadline storage: an old in-flight read must fail its identity check against the replacement. Refuse already-overdue unchanged watches in that command before creating a watch.Check idle and absolute deadlines in the reactor's sweep beside
endsWithoutRead, before fingerprint early returns and rate-limited read-group skips, except that an unobserved overdue watch must receive the fresh read above first. Use existingrecord(target, null, idleWake, target.watch)with no-wake fallback. Add"idle"to WatchEndReason. Keep existing settlement behavior and watch-sync command shape. Like giveUp today, a wake refused because the thread became archived, settled or a native subagent ends without waking; no inbox return is promised in step 1. A stale startedAt is a generation mismatch, not authority to end a replacement watch. A missing provider does not refuse a watch-sync wake.This is an independently implementable policy change requiring agreement on end condition and default, complementary to #25539, rather than an obvious-bug exception.
Recovery and declared waits
In runtimeLayer, make wait reconciliation and expiry startup depend explicitly on ProviderRuntimeRecoveryService completion and delegated-completion recovery. Gate continuation/expiry consumption until reconciliation finishes, rather than relying on layer declaration order.
Recovery retains delegated/cohort state and clears provider rosters today. Reconcile persisted wake-owed markers before observing absence as a new loss. Extend the existing restart background note with separate lines for "cancelled before finishing", "finished, result not delivered" and "lapsed: ". Fold these into its existing continuation, never a second restart wake. Folding a wake-owed marker into that note atomically consumes the marker and releases its hold, whether continuation runs or settings, session checks or loop cap decline it. A declined continuation retains the deferred note for the next user turn, without retry or lapse wake for that same marker. Disposed or delivery-expired uncommitted retry debt does not reoffer; committed queued wakes and first late completions remain eligible.
Step 5 adds an absolute-instant one-shot ScheduledTask kind and persisted consumed state;
fixed_timerecurs daily or on chosen optional weekdays. Reuse existing-thread targeting. Commit one winning transition per task/generation from armed to consumed, cancelled or expired, serialized with wait expiry/cancel. Consumed stores a stable fire identity and durable delivery intent together; retries reuse its commandId/messageId, never the current time. Restart replays an unacknowledged intent and catches up an overdue armed timer after a fresh target read, before judging wait expiry and without the fixed-time 10 min missed-run skip. A missed due time alone is not a lapse.Exactly once means one logical fire and one deduped queued message, not one provider execution. A fire committed first cannot be re-fired or undone by cancel/expiry; later delivery uses the 1 h deliverable-time budget and never cancels a queued wake. Cancel/expiry committed first forbids fire. Recovery must check receipts and message identity before retrying an intent; a crash before dispatch loses nothing, and one after dispatch cannot duplicate the message.
External waits reuse webhook schedules, endpoint token, signatures, dedupe, filters and delivery-age limits. Accept only the first matching delivery per wait generation and durably disable further fires. Add a persisted wait-backed discriminator to hide these tasks from the ordinary schedule list and show them in the owning thread's detail, as proposed in Question 6; do not imply existing archive suppression. The wait record only holds and bounds.
Provider-native timers retain their adapter wake authority, keyed by session generation. Do not also create a server ScheduledTask for the same native timer.
Question 3 remains open: A, a new provider-neutral declaration tool; B, extend
t3_background_waitwith tagged timer, thread-run and external targets while preserving taskIds/awaited inputs. Both share declare/read/cancel authorization, bounds and idempotency. Return the wait id and deadline and tell the caller to end its turn only after delivery is durably armed.Delivery checks
Paths below are relative to
apps/server/src/orchestration-v2/unless qualified. ScheduledTaskService is underapps/server/src/scheduledTasks/; MCP handlers are underapps/server/src/mcp/. New reactor, wait migration and declaration service names are proposed.packages/contracts/src/threadPullRequest.ts,pullRequestWatch.ts,PullRequestWatchReactor.ts,Orchestrator.tswatch command and creation,apps/server/src/mcp/toolkits/pullRequests/handlers.tserror mapping, sidebar guidepackages/contracts/src/orchestrationV2.ts, wait migration, EventSink, ProjectionStore, ProjectionMaintenance, shared derivation and its callerspackages/provider-core/src/server/subagentProjection.tsand Orchestrator roster-empty finalizationt3_thread_interrupt stop: truereach released provider work, compaction stays blocked; taskId reused in a new generation; healthy silent owners; boot ordering; archive race and unarchive fresh read; parked follower with owed Stop; active run or other live hold retains Workingpackages/contracts/src/scheduledTask.tsunion, schedule persistence migration, MCP contracts/tools/handlers, OrchestratorMcpService, declaration service;Schedule.test.ts,ScheduledTaskService.schedule.test.ts,ScheduledTaskService.test.ts,ScheduledTaskService.webhook.test.tsPinned code anchors
snapshot compaction; projection rebuild; transaction; shared derivation; blocking display; delivery disposition; dispatchMessage; watch schema; watch telemetry; watch fallback; recovery; settlement guards; mobile status; remote delegated-work flag; scheduled tasks.
watch creation; wake refusal; child progress; roster-empty finalization; settlement activity; recurring schedule calculation; scheduled dispatch and reschedule; command replay.
queued completion; active steering; live spawning run; held queue release; usage-limit hold; failure holds; recovery queue holds; optional weekdays; settlement triggers; restart refusal.
Drafted by AI agents on my behalf (Claude Opus 5.5 for the concept, GPT 6.1 Sol for the technical sketch and measurements), with three rounds of cross-model review against
mainat 9826dbb. Measurements come from one user's local T3 store and server logs and are aggregates only.All reactions