fix(remote): reopening a slept tab resumes when the other holder's agent has proven exited (#29588)

A pane whose shell outlived its agent kept counting as the session's live
holder because its PTY was alive, so a paired client got a plain shell.
Ask the hook store: a holder it proves exited no longer blocks the resume;
unverifiable still does.
This commit is contained in:
Neil
2026-10-16 02:40:20 -07:00
committed by GitHub
parent 67c36f3588
commit 17a14a9d7f
2 changed files with 33 additions and 16 deletions
@@ -301,11 +301,12 @@ describe('OrcaRuntimeService', () => {
expect(tap.spawn.mock.calls[0]?.[0]).toMatchObject({ spawnIntent: 'user-resume' })
})
function makeRuntimeWithOtherPaneHolder(livePtyIds: string[]) {
function makeRuntimeWithOtherPaneHolder(ptyIds: string[], verdict?: 'exited' | 'unverifiable') {
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
const parked = makeParkedRuntime(runtimeStore, {
livePtyIds,
livePtyIds: ptyIds,
deps: {
checkHookAgentPresence: async () => verdict ?? null,
getAgentProviderSessionSnapshot: () => [
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only paneKey and providerSession.
{
@@ -330,6 +331,16 @@ describe('OrcaRuntimeService', () => {
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1')
})
// Why: the agent can exit to a shell that keeps the PTY alive; only the hook store proves that.
it.each([
['exited', true],
['unverifiable', false]
] as const)('live-PTY holder agent %s: resumes %s', async (verdict, resumes) => {
const { runtime, spawn } = makeRuntimeWithOtherPaneHolder(['other-pty'], verdict)
await userActivate(runtime)
expect((firstSpawnArgs(spawn).command ?? '').includes('provider-session-1')).toBe(resumes)
})
// Why: losing sight of the holder (a dropped SSH relay) is not evidence it stopped writing.
it('does not resume while the other pane holding the session is unverifiable', async () => {
const { runtime, spawn } = makeRuntimeWithOtherPaneHolder([])
@@ -177,7 +177,7 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc
)
if (
sharedWithAnotherPane ||
this.isProviderSessionLiveInAnotherPane(record) ||
(await this.isProviderSessionLiveInAnotherPane(record)) ||
this.hookStoreNamesAnotherSessionForPane(record)
) {
return {}
@@ -191,7 +191,9 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc
}
/** The host's hook store places this conversation in another pane not proven exited. */
private isProviderSessionLiveInAnotherPane(record: SleepingAgentSessionRecord): boolean {
private async isProviderSessionLiveInAnotherPane(
record: SleepingAgentSessionRecord
): Promise<boolean> {
const otherPaneKeys = new Set(
(this.getAgentProviderSessionSnapshotFn?.() ?? [])
.filter(
@@ -201,21 +203,25 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc
)
.map((row) => row.paneKey)
)
if (otherPaneKeys.size === 0) {
return false
}
// Why: only a proven exit clears a holder; an unverifiable PTY (dropped SSH relay) or one not
// yet hydrated after a restart may still be writing the transcript.
const provenExitedPaneKeys = new Set<string>()
for (const pty of this.ptysById.values()) {
if (pty.paneKey && otherPaneKeys.has(pty.paneKey)) {
if (!this.isPtyKnownExited(pty.ptyId)) {
return true
}
provenExitedPaneKeys.add(pty.paneKey)
}
const ptys = [...this.ptysById.values()]
const holderPaneKeys = [...otherPaneKeys].filter((paneKey) => {
const panePtys = ptys.filter((pty) => pty.paneKey === paneKey)
return panePtys.length === 0 || panePtys.some((pty) => !this.isPtyKnownExited(pty.ptyId))
})
if (holderPaneKeys.length === 0) {
return false
}
return [...otherPaneKeys].some((paneKey) => !provenExitedPaneKeys.has(paneKey))
// Why: a shell can outlive the agent; the hook store's proven exit releases a pane whose PTY lives.
const verdicts = await Promise.all(
holderPaneKeys.map((paneKey) =>
this.checkHookAgentPresenceFn
? this.checkHookAgentPresenceFn(paneKey, 0).catch(() => null)
: Promise.resolve(null)
)
)
return verdicts.some((verdict) => verdict !== 'exited')
}
/** The pane moved on to another conversation after the record was saved; the hook store is authoritative. */