fix(ssh): fold in partition tabs on demand, not only at startup

The ssh:<target> plane is LIVE — the CLI and headless surfaces still write it —
but the hoist ran only in the Store constructor. A tab created there mid-session
never reached local, so a reattach refused it as noTab and its shell ran on
unbound with the tab invisible until the app restarted. Same defect class as the
original report, with a narrower window.

The reattach now re-hoists once on a noTab refusal and retries the bind before
concluding no pane owns the shell. No polling, and it costs nothing on the path
where local already knows the tab.

Mutation-proofed: making the on-demand hoist a no-op reddens the new test.
This commit is contained in:
Neil
2026-08-13 21:03:10 -07:00
parent 1e1ff11133
commit a7b73031d3
3 changed files with 70 additions and 0 deletions
+19
View File
@@ -6590,6 +6590,25 @@ export class Store {
return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
}
/**
* Re-run the partition hoist against current state.
*
* Why this is not construction-only: `ssh:<target>` is a LIVE plane. A tab the CLI or a headless
* surface creates mid-session lands there, `local` never learns of it, and a reattach then
* refuses it as `noTab` — the shell runs on unbound and its tab stays invisible until the app
* restarts. Re-hoisting on that refusal closes the window without polling.
*/
hoistSshPartitionsNow(): boolean {
const changed = hoistSshPartitionsIntoLocalSession(
this.state,
this.state.workspaceSessionsByHostId
)
if (changed) {
this.scheduleSave()
}
return changed
}
/** Read once, immediately after a refused `persistPtyBinding`. Clearing on read keeps a stale
* reason from being attributed to a later call that never set one. */
consumePtyBindingRefusalReason(): PtyBindingRefusalReason | undefined {
@@ -439,4 +439,40 @@ describe('an SSH pane whose membership was persisted into the ssh partition', ()
'a local-only layout edit reached through into the ssh partition'
).toBe(PTY)
})
// `ssh:<target>` is a LIVE plane: the CLI and headless surfaces still write it. A tab created
// there mid-session is not in `local`, so a reattach refuses it as noTab and its shell runs on
// unbound. Re-hoisting on demand closes that window without waiting for a restart.
it('folds in a tab the partition gained mid-session, without a restart', async () => {
const before = await createStore()
before.setWorkspaceSession(paneSession() as never, SSH_PARTITION)
before.flushOrThrow()
stripHoistMarker()
const store = await reopenStore()
expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? []).toHaveLength(1)
// The CLI adds a pane to the partition while the app is running.
const grown = paneSession()
grown.tabsByWorktree[WORKTREE] = [
...grown.tabsByWorktree[WORKTREE],
{ ...grown.tabsByWorktree[WORKTREE][0], id: 'tab-cli' }
]
grown.terminalLayoutsByTabId = {
...grown.terminalLayoutsByTabId,
'tab-cli': {
root: { type: 'leaf' as const, leafId: LEAF_TWO },
activeLeafId: LEAF_TWO,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_TWO]: PTY }
}
} as typeof grown.terminalLayoutsByTabId
store.setWorkspaceSession(grown as never, SSH_PARTITION)
expect(store.hoistSshPartitionsNow(), 'the on-demand hoist reported no change').toBe(true)
expect(
(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? []).map((tab) => tab.id).sort(),
'a tab the partition gained mid-session stayed invisible until restart'
).toEqual(['tab-1', 'tab-cli'])
})
})
+15
View File
@@ -2618,6 +2618,21 @@ export class SshRelaySession {
} catch (error) {
console.error('[ssh-relay-session] Failed to persist reconnect incarnation:', error)
}
if (bind?.bound === false && bind.refusalReason === 'noTab') {
// The partition is a live plane, so a tab created there mid-session is not in `local` yet.
// Fold it in and try once more before concluding no pane owns this shell.
if (this.store?.hoistSshPartitionsNow()) {
bind = bindPaneShell({
store: this.store,
worktreeId: lease.worktreeId,
tabId,
leafId: lease.leafId,
ptyId: appPtyId,
mayCreate: false,
...(incarnationId ? { incarnationId } : {})
})
}
}
if (bind?.bound === false && bind.refusalReason === 'noTab') {
// No durable tab owns this leaf, so there is nothing to publish it against — registering
// would announce a pane that does not exist. Unresolved, not dead: the shell keeps running