Skip to content

fix(server): allow provider handoffs during background work - #110

Merged
ScriptedAlchemy merged 3 commits into
mainfrom
t3/prevent-model-switch-background-conflict
Nov 12, 2026
Merged

ScriptedAlchemy merged 3 commits into
mainfrom
t3/prevent-model-switch-background-conflict

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Follow-up defect repair

Commit d120792fe8 fixes a lifecycle issue exposed by retaining the outgoing session: a late background update could make the old provider conversation active again after a successful handoff.

The SQL and in-memory projections now preserve the selected binding unless an inactive provider thread advances its execution or receives a new recorded handoff. The regression also covers rollback lowering the active run ordinal, deliberate same-run retry handoffs, and switching back to a previous provider. Outgoing task rosters and timestamps continue to update.

SQL captures the incoming provider's prior state before its upsert through indexed joins in the existing lookup. Independent review reproduced the rollback edge in the first fix and verified the final predicate and regression sequence.

Follow-up validation

  • Both the original late-update failure and the rollback counterexample failed before their fixes and passed afterward.
  • 59 tests passed across two suites in vp run check:changed.
  • All 67 ProviderSwitch integration cases passed against the final tree, including immediate/queued busy handoffs, shared-history guards, and return/retry paths.
  • Direct typechecks passed for server, evals, and scripts, all three affected packages, on pinned Node 26.12.1.
  • Native project-memory build, formatting, lint, both unused-code scans, and GraphQL validation passed.
  • The cached typecheck launcher cannot initialize this environment's seccomp_unotify supervisor, so the combined local gate is not fully green; the direct checks above completed.
  • Published Git tree verified identical to the tested tree. Hosted CI for this commit is queued as of publication.

Follow-up audit and fixes: GPT-6 Astra Pro in ChatGPT.


Problem

Sending immediately after changing providers can fail with “The previous provider is still running background agents or commands,” then succeed when sent again later. The immediate-send path rejects an independent handoff that the queued-send path already supports safely.

Change

Keep busy outgoing sessions alive during independent provider handoffs and send the message to the selected provider on the first attempt. Idle sessions still detach. Shared-history account restarts, provider-home changes, and explicit fresh sessions retain their existing background-work guards.

Scope and approval

Focused fix requested for the reported send/retry error, using the existing queued-handoff policy. The shared server path covers web, desktop, mobile, and agent sends without contract or UI changes.

Verification

  • Regression failed on the base code with the exact reported error. After the fix, the second run completes with transferred context in a distinct provider thread while the first session remains ready and no session is closed.
  • vp run check:changed: all 87 tests across 5 files pass, along with format, lint, both knip checks, GraphQL validation, and the native addon build.
  • The previously reported Cursor type errors did not reproduce after a fresh locked install. Direct checks now pass for the server and provider-cursor package; follow-up affected-package results are recorded above.

Server replay result:

Before: second send -> OrchestratorDispatchError: previous provider is still running background agents or commands
After: second send -> completed; previous session -> ready; closed sessions -> []
Shared native history: send refused while busy -> succeeds after background work ends

Model: GPT-6 Astra (xhigh). Harness: Codex in T3 Code.

@changeset-bot

changeset-bot Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5bfa0ad

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

ScriptedAlchemy and others added 2 commits November 11, 2026 12:06
Prevent retained outgoing provider snapshots from taking over the active
conversation after a handoff or rollback. Require execution advancement
or a new recorded handoff before reactivating an existing inactive thread.

Capture the incoming provider's prior state before SQL upsert, and apply
the same activation rule in the in-memory projection. Preserve roster and
timestamp updates and deliberate same-run retry handoffs.

Cover late updates, rollback, return switches, and tied retry handoffs.

Implemented and reviewed with GPT-6 Astra Pro in ChatGPT.
@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Validated the provider handoff changes in an isolated checkout.

  • Projection and provider-switch integration suites passed all 124 tests.
  • The final vp run check:changed passed, including 144 adjacent tests and affected typechecks.
  • The integration cases cover retaining background sessions during a handoff and keeping late projection updates from changing the active provider back.

Commit 5bfa0ad5bf removes a redundant explanatory comment. No correctness defect was established in this audit. The suggested helper extraction was declined because the immediate and queued start rules differ, and this validation found no need to widen the change.

GPT-6.1-Sol through Codex in T3 Code.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Shipping verdict for PR110: PASS+NOTES

Exact head. 5bfa0ad5bfb2a5c5d873f864f706c91a544d5d52, against origin/main at cff49436fd. This covers the whole PR (3 commits, 4 files), not only the last comment cleanup.

Goal. A user can send to a different provider while the current one still runs background agents or commands. The old work keeps running, and late updates from the old session no longer take the thread's active provider binding back.

What changes against main

  • On main, an immediate send that hands off to another provider instance is refused while the replaced session has background work ("Wait for them to finish, or press Stop"). The base test "refuses the hand-off while the previous instance still runs background work" asserts that refusal.
  • At head, a create_with_handoff switch with no fresh context and no continuation change keeps the busy session attached and starts the new run (Orchestrator.ts:7735-7757). Account switches that share native history, fresh-context sends, and restart_and_resume switches still refuse. This gate is narrower than the queued path, which already kept busy sessions on main (Orchestrator.ts:2331-2365).
  • provider-thread.updated no longer activates its provider thread unconditionally. The pure and SQL projections now share canActivateProviderThread (ProjectionStore.ts:862-878). A thread activates when it is already active, when nothing is active, or when its run ordinal is at least the active one's and it advanced or gained a handoff.

Executed at the exact head

  • vp test run src/orchestration-v2/ProjectionStore.test.ts -t "keeps the latest provider binding when outgoing background work updates" (from apps/server). 1 passed, 56 skipped.
  • vp test run src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts src/orchestration-v2/ProjectionStore.test.ts. 2 files, 124 passed, 0 failed, 83.6s.
  • A verbose run filtered to the busy-session cases gave 6 passed:
    • keeps a busy previous session running when an immediate hand-off starts (old session stays ready, none closed, second run on a new provider thread, handoff carries the first turn's text)
    • refuses an immediate account switch while the replaced session still runs background work
    • keeps a busy previous session running when a queued hand-off starts
    • holds a queued account switch while the replaced session still runs background work
    • keeps the native session after turns refused for background work
    • resumes a queued account switch without requiring a portable handoff
  • git status in the checkout stayed clean afterwards.

Source checks

  • Every run-start emitter stamps the target thread with that run's ordinal (Orchestrator.ts:2578, Orchestrator.ts:7819-7829, ProviderTurnStartService.ts:1640). nextRunOrdinal is the max over all runs plus one, and rolled-back runs stay in the list. So the thread a new run targets always passes the gate. Only stale updates from a thread that is no longer active get blocked.
  • lastRunOrdinal is Schema.NullOr(PositiveInt), so a present row reads null in both paths. The pure and SQL paths take the same branch for a missing active row, the active thread itself, and a NULL ordinal. The new test checks that both paths agree on the binding and updatedAt at all 11 steps, rollback and retry handoffs included.
  • The SQL path reads the active and previous provider rows in the SELECT that already loaded the thread row, before the upsert. That adds two primary-key joins per provider-thread update. I didn't measure it. I infer the cost is negligible.
  • ProviderSessionManager's idle timeout releases a kept session once its work ends (ProviderSessionManager.ts:1241-1252), so the session doesn't leak.

Notes (non-blocking)

  1. The kept session's background work drops out of sight. Pending background tasks come only from the active provider thread (packages/shared/src/orchestrationV2PendingBackgroundWork.ts:267-270). thread.stop targets the latest run when that list is non-empty (Orchestrator.ts:13424-13437). After a handoff, the old session's work no longer shows, and Stop likely doesn't reach it. That is inferred from source, not executed. The queued hand-off path on main already behaves this way, and PR110 makes it reachable from an ordinary send. A follow-up could show and stop work in live sessions that aren't active.
  2. A wake from the kept session has no test. If the old session's background task finishes and wakes a run, that run gets the highest ordinal and re-activates the old provider thread. That is probably correct and matches the queued path.
  3. Selection commands still skip the background check. thread.model-selection.set and provider.switch detach released sessions with no background-work check (Orchestrator.ts:4004-4023, Orchestrator.ts:4779-4793). That is unchanged from main. Web sends the model with the turn (useThreadLifecycle.tsx:513). No web or mobile caller passes modelSelection through updateThreadMetadata, so the composer send reaches the new gate.
  4. Bindings already flipped heal on the next run. A thread whose binding background updates flipped back before this PR keeps that binding until its next run starts, which re-activates the right thread.

Limits

  • Base behavior comes from the base source and its own regression test. I didn't run tests at the base, because this pass allowed no second checkout.
  • The earlier vp run check:changed pass (format, lint, knip, typecheck of 3 packages, 144 tests in 6 files) was logged at 18:47, before 5bfa0ad5bf was committed at 18:49. That commit deletes one comment line. CI is green at the exact head (13 passed, 3 skipped).
  • This is a server-only change, and I didn't drive a client. The user-visible effect is that the send no longer fails, which the integration test covers.

Verdict. PASS+NOTES at 5bfa0ad5bfb2a5c5d873f864f706c91a544d5d52.

Claude Opus 5.5 via Claude Agent in T3 Code.

@ScriptedAlchemy
ScriptedAlchemy merged commit 1c4c054 into main Nov 12, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant