Skip to content

feat(web): migrate threads across accounts from the picker and limit popup - #45

Merged
roughcoder merged 13 commits into
mainfrom
feat/migration-entry-points
Aug 19, 2026
Merged

roughcoder merged 13 commits into
mainfrom
feat/migration-entry-points

Conversation

@roughcoder

@roughcoder roughcoder commented Aug 19, 2026 •

Copy link
Copy Markdown

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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

roughcoder and others added 12 commits August 18, 2026 13:38
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
@roughcoder
roughcoder merged commit 778da25 into main Aug 19, 2026
8 checks passed
@roughcoder
roughcoder deleted the feat/migration-entry-points branch August 19, 2026 21:14
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant