diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts index e3acd80eb37..e8a1c9f5640 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts @@ -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([]) diff --git a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts index dd288d6b723..8db2963d386 100644 --- a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts @@ -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 { 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() - 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. */