fix(desktop): keep a branch child on its parent's backend after the create

Routing the branch create to the parent's owning connection was only half the
job. The child then landed in the sidebar as a row that lied about who owned
it, so the chat pane spun forever on "draft: branch #1" and never hydrated —
the create was right, the row was wrong.

upsertOptimisticSession stamps the row's profile from $activeGatewayProfile and
omits connection_id entirely when no owner is passed (utils.ts:1318-1342), and
it also skips setSessionOwnerHint. The branch call site passed no owner, so the
child got NEITHER a row tag NOR a hint. resumeSession's owner ladder starts at
`capturedOwner || getSessionOwnerHint(storedSessionId)` and forkBranch calls it
without a capturedOwner, so the missing hint alone was enough to send the
resume to whichever backend happened to be active. Pass the parent's route as
the owner argument, restoring both mechanisms. The two sibling routed creates
in this file already did exactly this.

The tile path had the same defect one rung further out. A branch of a session
that is not the open chat opens a tile instead of resuming, and
SessionTileChrome resolved its owner from the tile route alone. openSessionTile
is called for a branch child with no workspaceScope, and session-states.ts only
persists a tile ownerRoute in bots mode, so that tile had no owner at all and
its model + composer RPCs fell back to the ambient socket. Use the same
tile-route-then-row ladder its sibling in session-tile-actions.ts already uses,
resolved per render so it cannot go stale against the tile store, the
recents/cron/messaging rows, or the hint map, with only the resulting identity
memoised on primitives.

An untagged parent row still reproduces the previous ambient behaviour exactly,
so single-connection users are unaffected.

Verified end to end against two real gateways: a session owned by a remote
connection, branched through the actual sidebar context menu in a running dev
app. The remote gateway served the create (ws closed ... messages=11
detached_sessions=1) and the resulting row polled stable at connection_id =
the remote for the full 8s window. Before the fix the same gesture produced a
row with no connection_id.
This commit is contained in:
Zeus-Deus
2026-08-30 15:26:39 +02:00
committed by brooklyn!
parent c4bddd9a4e
commit ff22b18913
4 changed files with 110 additions and 3 deletions
@@ -11,3 +11,28 @@ describe('SessionTilePane owner-scoped listing', () => {
expect(source).not.toMatch(/void resolveStoredSession\(storedSessionId\)\s*\n/)
})
})
describe('SessionTileChrome owner ladder', () => {
// A tile opened without an explicit route (openSessionTile with no
// workspaceScope — how a branch child is opened) has no tile ownerRoute, so
// the tile route ALONE leaves ownerRoute undefined and requestForSessionProfile
// falls back to the ambient socket. The session's own row/hint rung is what
// keeps its model + composer RPCs on the backend that owns it.
it('falls back to the session row owner when the tile carries no route', () => {
expect(source).toContain(
'sessionTileOwnerRoute(storedSessionId) ?? knownSessionOwner(ownerLookupSessionRows(), storedSessionId)'
)
})
it('does not resolve the chrome owner from the tile route alone', () => {
// The pre-fix shape: `const ownerRoute = sessionTileOwnerRoute(storedSessionId)`
// with no fallback rung.
expect(source).not.toMatch(/const ownerRoute = sessionTileOwnerRoute\(storedSessionId\)\s*\n/)
})
it('narrows a bare profile owner to an object route', () => {
// knownSessionOwner may return a bare profile string, which carries no
// connection and must not be handed to requestForSessionProfile as a route.
expect(source).toContain("typeof resolvedOwner === 'object'")
})
})
+35 -2
View File
@@ -44,10 +44,12 @@ import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
knownSessionOwner,
ownerLookupSessionRows,
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { requestForSessionProfile } from '@/store/session-request-router'
import { requestForSessionProfile, type SessionOwnerRoute } from '@/store/session-request-router'
import {
$sessionStates,
$sessionTileDelegateRevision,
@@ -157,7 +159,38 @@ function TileChat({
}) {
const { gateway, requestGateway } = useGatewayRequest()
const queryClient = useQueryClient()
const ownerRoute = sessionTileOwnerRoute(storedSessionId)
// Owner ladder, same as useSessionTileActions (session-tile-actions.ts:99-103):
// this tile's explicit route first, then the session row's own
// (connection, profile) tag — knownSessionOwner also folds in the owner hint.
// A tile opened without an explicit route — e.g. a branch child, which
// openSessionTile creates with no workspaceScope — has no tile route, so the
// row/hint rung is the only thing keeping this tile's model + composer RPCs
// on the backend that owns the session instead of the ambient one.
//
// Resolved on every render (cheap id lookups) so it cannot go stale against
// the tile store, the recents/cron/messaging rows, or the hint map. Only the
// resulting IDENTITY is memoised, on primitives, because knownSessionOwner
// mints a fresh object per call and requestTileGateway below is keyed on it.
const resolvedOwner =
sessionTileOwnerRoute(storedSessionId) ?? knownSessionOwner(ownerLookupSessionRows(), storedSessionId)
const ownerConnectionId = resolvedOwner && typeof resolvedOwner === 'object' ? resolvedOwner.connectionId : ''
const ownerProfile = resolvedOwner && typeof resolvedOwner === 'object' ? resolvedOwner.profile : ''
const ownerTargetProfile = resolvedOwner && typeof resolvedOwner === 'object' ? resolvedOwner.targetProfile : undefined
// A bare profile string carries no connection and is not a usable route here.
const ownerRoute = useMemo<SessionOwnerRoute | undefined>(
() =>
ownerConnectionId
? {
connectionId: ownerConnectionId,
profile: ownerProfile,
...(ownerTargetProfile ? { targetProfile: ownerTargetProfile } : {})
}
: undefined,
[ownerConnectionId, ownerProfile, ownerTargetProfile]
)
const requestTileGateway = useCallback(
<T,>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal): Promise<T> =>
@@ -1886,6 +1886,46 @@ describe('branchStoredSession desktop source tagging', () => {
})
})
// The create landing on the right backend is only half the job: the sidebar
// row must be TAGGED with that owner too. An untagged optimistic row inherits
// the ambient profile, so every later owner lookup (resume/hydrate/prompt)
// routes to the wrong backend and the chat pane spins forever on a session
// that backend never had.
it('tags the optimistic branch row with the parent connection owner', async () => {
const ambientRequest = vi.fn(async () => ({}) as never)
vi.mocked(requestGatewayForAgent).mockImplementation((async (
_connectionId: null | string,
_profile: string,
method: string
) => {
if (method === 'session.create') {
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
}) as never)
setSessions([
storedSession({ connection_id: 'pandora', id: 'stored-parent', message_count: 1, profile: 'default' })
])
vi.mocked(getAllSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={ambientRequest} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
const row = $sessions.get().find(session => session.id === 'branch-stored')
expect(row).toBeDefined()
expect(row!.connection_id).toBe('pandora')
expect(row!.profile).toBe('default')
})
// An untagged row (single-backend users, the overwhelmingly common case)
// must keep the ambient path exactly as before — no behaviour change.
it('keeps an untagged parent branch on the ambient socket', async () => {
@@ -2023,13 +2023,22 @@ export function useSessionActions({
: 0
setFreshDraftReady(false)
// Stamp the optimistic row with the branch's EXACT owner. Without it the
// row inherits $activeGatewayProfile and carries no connection_id, so a
// child correctly created on the parent's remote backend is listed as
// belonging to whichever backend happens to be active. Every later
// owner lookup off that row (resume, hydrate, prompt) then routes to the
// wrong machine and the chat pane spins on a session that backend never
// had — the create is right, the row is a lie. Mirrors the routed
// creates at the top of this file, which already pass their route here.
upsertOptimisticSession(
branched,
routedSessionId,
copy.branchTitle(siblings + 1).toLowerCase(),
preview,
parentStoredId,
parent ? parent.last_active || parent.started_at : undefined
parent ? parent.last_active || parent.started_at : undefined,
ownerRoute ?? null
)
ensureSessionState(branched.session_id, routedSessionId)
updateSessionState(