Repository navigation
feat(web): migrate threads across accounts from the picker and limit popup - #45
Merged
Merged
Conversation
Pressing Stop on a thread whose provider had wedged did nothing, 31 times over 54 minutes, with no feedback anywhere in the UI. Three gaps combined: the Claude adapter awaited `query.interrupt()` with no timeout, so a child that never acknowledges could park the caller forever; the reactor called `interruptTurn` bare, unlike every sibling handler, so a failure produced only a log line; and the decider emitted the interrupt event unconditionally, so a thread with nothing running absorbed repeated presses as no-op events. Bound the interrupt on the same rationale already applied to `stopTask` one call above it — a wedged child's promise may never settle, and non-resolution is not something `catch` can see. This matters beyond one thread: the reactor runs a single worker fiber for every thread's provider commands, so an unbounded wait there stalls unrelated threads too. Add the missing catchCause so a stop that cannot reach its provider appends a provider.turn.interrupt.failed activity, and reject the command when no session is starting or running. Each test fails without its fix: the adapter test hangs the full 60s, the reactor test finds no activity, and the decider test sees the dispatch succeed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ProjectionPipeline recorded thread.turn-interrupt-requested; projector.ts had no case for it. The two projections of the same event therefore disagreed after every interrupt: the SQL read model the UI renders showed the turn interrupted while the decider's read model still reported it running, permanently. That divergence is why the interrupt guard could only check session status. A wedged provider leaves its session stuck on "running", so the session check alone does not stop a user pressing Stop on a turn that already ended — the exact case that produced 31 no-op events over 54 minutes. Project the interrupt onto latestTurn, scoped to the turn the press targets and only while it is still running, so a terminal turn keeps the outcome it already recorded. The decider then rejects a repeat press on positive evidence the turn has ended, while still emitting whenever the read model is unsure — Stop must work whenever there is doubt. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The active-turn watchdog only acted when the runtime reported "dead", and liveness means "the stream fiber has not exited". A provider that is connected but silent therefore reads as live forever: a wedged turn stayed "running" through 92 minutes and 18 sweeps, and only a server restart — which empties the session map — cleared it. Reaping on idle alone would be wrong. Nothing recorded here separates a wedged child from one sitting inside a long tool call: both are alive, both emit no provider events. Killing on that guess would sometimes destroy live work. So escalate rather than kill. Past the inactivity threshold with a live or unknown runtime, append a provider.session.stalled activity naming how long the provider has been quiet and saying plainly that it may be wedged or may still be working. The turn is never stopped on the reaper's guess; the user gets the decision, with a Stop that now reports its own failures. The notice is per stall, not per sweep — a notice every five minutes would bury the thread it is trying to explain. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both are things a user now sees in a thread and would otherwise have to infer: a stop that reports when it could not reach the agent, and the notice raised after a long provider silence. States plainly that the notice is not a failure verdict and that Phoenix never ends a turn on its own. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review found the emit guard added here broke the background-work Stop. That banner is the only stop affordance for a settled turn whose subagent fleet is still alive; it appears precisely when the session is NOT running, and interrupts by session with no turn id. The guard rejected exactly that call, so pressing Stop on a runaway fleet painted a raw invariant error and the fleet kept burning tokens — the opposite of what this branch set out to fix, and of what its own docs promise. The turn-state half was wrong for a second reason: the projector marks a turn interrupted when the request is recorded, not when the provider confirms it. A stop that failed therefore looked terminal, and the retry was refused. The rule this branch states — emit whenever there is doubt — is better served by no guard at all, with ProviderCommandReactor keeping the looser check that already tolerates a ready session. Both cases are now locked by a test that dispatches an interrupt against a ready session with no active turn, which fails against the guard with the exact error a user would have seen. Revert the reaper's stall activity too. It is replaced by a derived indicator in the next commit rather than a durable event. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Replaces the durable stall activity with a derived label. Review showed the activity was the wrong shape three ways: tone "error" is classified as a failed tool call by both clients, so a healthy 40-minute build rendered a red X reading "Failed" while the docs said the note was not a failure; nothing retracted the row when the agent recovered; and it reached no surface but an open thread, while the user it was written for is the one not looking at it. Everything it carried is already derivable from the session timestamp the shell ships. A working row that has heard nothing for five minutes now reads "quiet 32m" instead of the working time, and mobile's pill reads "Quiet 32m" and stops pulsing — an animation implying progress while nothing arrives is the same lie as a spinner that never resolves. The hue does not change: this is a statement about silence, not a verdict. It needs no event, no activity kind, no tone, and no dedup bookkeeping, and it clears itself the moment the provider reports again — so there is nothing to retract and no way to leave a thread asserting a silence that has ended. The threshold and format live in client-runtime so both clients agree. Docs: drop two claims review found false — that a failed stop is reported for every provider (it is Claude-only) and that Phoenix never ends a turn itself (the reaper does, when a process disappears) — and link the page from the docs index, which never referenced it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Added to let the decider guard see turn state. That guard is gone — it broke the background-work Stop — so nothing reads this any more. It also only narrowed the divergence it claimed to close: the SQL projection rewrites any targeted turn unconditionally, the client reducer takes the latest turn in any state, and this took the latest turn only while running. Three rules where the commit message promised one, in code every thread touches, with no caller left. Closing that properly is its own change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Final review found the label was derived from the wrong field. Both clients passed session.updatedAt as the last provider report, but ingestion only dispatches thread.session.set under a lifecycle gate — turn started, turn ended, status changed. Content deltas, tool events and task progress never touch it. A turn streaming output for six minutes was therefore labelled quiet while text was visibly arriving: a false indicator, which is the reason the error-toned activity it replaced was removed. The premise the redesign rested on — that everything the notice conveyed was already derivable from a timestamp the shell ships — was simply untrue, and nobody checked what writes that field until now. It also never reached most users. Mobile defaults to Thread List v2, which resolves status through its own resolver and maps every running session to a static Working; the changed resolver feeds only the legacy list, so the test covering it exercised a path most devices never render. An honest version needs a real last-provider-report timestamp on the wire: contracts, ingestion, and both client resolvers. That is a feature, not the simplification this was sold as, so it leaves with an issue rather than a third attempt at deriving it. Docs: drop the quiet paragraph and scope the post-turn Stop claim to web and desktop, since mobile only renders Stop for a turn still in progress. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Phase 2 of thread migration (#36): a thread can be moved to another provider instance in place, so a rate-limited subscription no longer freezes the work on it. This lands the schema, decider, and read model only — the reactor deliberately does not act on the new event yet, so nothing changes for users until session rebinding and seeding land. `thread.migrate` (thread id, target instance, optional target model, handoff mode, trigger) is rejected mid-turn, rejected when it would not move the thread, and rejected when auto-failover asks for a brief the out-of-credit origin cannot write. It decides into `thread.migrated`, which rebinds the thread's modelSelection, plus a `thread.migrated` activity row so the history says which account did which work. Provider instances gain an optional `failoverGroup` tag; the write path refuses a group that mixes drivers while the read path stays permissive, so a hand edit can never brick a settings file. Model: Claude Opus 5 (1M context), harness: Claude Code
This was referenced Aug 19, 2026
roughcoder
added a commit
that referenced
this pull request
Sep 28, 2026
…llback Picking another account on a started thread is meant to hand the thread to the migration flow. Instead every other account is greyed out with "<name> is unavailable in this thread. Start a new thread to switch providers." PR #45 removed that lock by nulling lockedProvider in ChatView. The upstream integration merge d185ed5 (PR #110) resolved ChatView.tsx in favour of its upstream parent, which never carried #45, so deriveLockedProvider came back and the lock with it. Only the wiring was lost: ThreadMigrationDialog, the usage-limit entry point and getStartedThreadModelChangeBlockReason's cross-instance allowance all survived, and docs/user/switching-accounts.md still tells users to pick a model from a different account in the picker — so the shipped build contradicted its own documentation. Do not restore #45 verbatim. That flag feeds two consumers with different jobs: ProviderModelPicker, which decides what is selectable, and resolveComposerProviderSelection, which decides what the composer resolves to. Nulling both reopens a hole upstream closed in d92dca7 ("resume imported custom-provider threads", pingdotgg#10184) after #45 was written: without the lock, compatibleEntries stops being filtered by driver, so a started thread whose account is disabled or missing from the catalog falls through to any selectable entry — including another driver. The composer then displays that driver while resolveNextTurnModelSelection still sends on the thread's own instance, because a started thread keeps threadModelSelection whenever the requested instance differs. Displayed provider and running provider disagree. So unlock only the picker, at its one call site in ChatComposer. The composer's own resolution keeps the lock, the missing/disabled-instance protection and its tests stay exactly as upstream left them, and ModelPickerContent gates all greying on lockedProvider !== null, so a non-null lockedContinuationGroupKey alone greys nothing. Mobile was never affected: ThreadComposer opens the migration flow on a cross-instance pick. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The model picker greyed out every other account on a started thread, leaving rate-limited threads stranded. Started threads can now pick any ready instance: picking one opens a migrate dialog (replay history, or a handoff brief when the origin still has credit; both blocked with a reason mid-stream). When an account hits its usage window, a popup in the affected chat offers switch-and-retry (re-runs the exact failed message after migration), switch-only, and bulk migration of every thread on the limited account. Suggested targets rank same-driver first by remaining quota; the choice is remembered per project. The migration renders as a compact history row.
Verified live: a genuinely rate-limited thread was migrated Claude B → Claude A through this UI with full history seeding.
Depends on #38 (contracts) and #39 (limit signal). Part of #36.
Built by Codex GPT-5.6-Sol via a Phoenix child session.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.