Make explicit desktop take-back always release the mobile presence lock (#8200)

Previously a failed resize during reclaimTerminalForDesktop left the
override/lock in place (#7588 semantics), but for an explicit "take
back all terminals" gesture that could strand banners on background
panes whose resize can't converge. Now the take-back unconditionally
releases the driver and clears any held fit-override via a new
releaseDesktopTakeBack helper, while auto-restore and phone-initiated
paths keep the original keep-lock-on-failure behavior.

Also extract the local mapWithConcurrency helper out of
workspace-cleanup.ts into a shared, tested src/shared/map-with-concurrency.ts
and use it to bound concurrent desktop-fit reclaims in
terminal-fit-restore.ts.
This commit is contained in:
Jinjing 2026-07-10 19:41:27 -07:00 committed by GitHub
parent 5a8078e755
commit a916a93df3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 169 additions and 121 deletions

View File

@ -574,9 +574,13 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(listenerEvents.some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 4: a failing resize during a HELD (no-subscriber) restore returns
// false and keeps the override — no lying, no phantom desktop-fit event.
it('held restore with a failing resize returns false and keeps the override', async () => {
// Scenario 4: an explicit take-back on a HELD (no-subscriber) override must
// ALWAYS release, even when the desktop resize fails. The override is cleared
// optimistically with a paired desktop-fit 0×0 so the banner can't linger; the
// desktop renderer refits the PTY on its next settled frame. (The gesture
// deliberately overrides #7588's keep-lock-on-failed-resize rule, which still
// governs the auto-restore and phone-initiated paths.)
it('held restore with a failing resize still releases and clears the override', async () => {
const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime(null)
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e))
@ -594,18 +598,17 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
const restored = await runtime.reclaimTerminalForDesktop('pty-1')
expect(restored).toBe(false)
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(
false
)
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false)
expect(restored).toBe(true)
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 5: a failing resize during an ACTIVE-SUBSCRIBER take-back returns
// false, keeps the override, leaves the driver on its mobile lock, and
// restores the prior display mode ('auto') — the fix #3 P1 correction.
it('active-subscriber take-back with a failing resize returns false and preserves the mobile lock', async () => {
// Scenario 5: an explicit ACTIVE-SUBSCRIBER take-back with a failing resize
// must still release — driver → desktop, override cleared, banner dismissed,
// mode reset to 'auto'. This is the "take back all terminals" guarantee: a
// background PTY that can't converge must not strand its banner.
it('active-subscriber take-back with a failing resize still releases the lock', async () => {
const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime()
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e))
@ -619,43 +622,30 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
const restored = await runtime.reclaimTerminalForDesktop('pty-1')
expect(restored).toBe(false)
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
// Driver stays mobile (lock retained) and mode is not left lying at 'desktop'.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
expect(restored).toBe(true)
// Lock released and banner dismissed despite the failed resize.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(
false
)
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false)
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 5b: after a FAILED active-subscriber take-back, wasResizedToPhone
// must be re-armed so a later unsubscribe under a finite auto-restore setting
// still schedules its timer and eventually clears the override. Without the
// re-arm the flag would be stuck false and the phone-fit would strand.
it('failed take-back re-arms wasResizedToPhone so a later unsubscribe still auto-restores', async () => {
// Finite auto-restore (5s default from the rig store).
const { runtime, ptySizes, setResizeSucceeds } = createRuntime()
// Scenario 5b: the failed-resize take-back leaves NO stranded phone-fit —
// driver released to desktop, override cleared, mode reset to 'auto'. (The
// pre-revision behavior kept the lock and relied on a later auto-restore; the
// explicit gesture now releases unconditionally.)
it('failed take-back leaves no stranded override or lock', async () => {
const { runtime, setResizeSucceeds } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Take-back fails → false, flag re-armed, mode rolled back to 'auto'.
setResizeSucceeds(false)
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false)
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
// Phone then leaves the terminal. Resize works again for the auto-restore.
setResizeSucceeds(true)
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
// Soft-leave grace, then the finite auto-restore timer.
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(5_000)
// The scheduled auto-restore fired: override cleared, PTY back to desktop.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
})
// Scenario 6: a phone-initiated setDisplayMode('desktop') against a stale

View File

@ -8197,42 +8197,35 @@ export class OrcaRuntimeService {
}
// Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take
// back" / "Restore" button). Forces the PTY back to desktop dims and
// flips the driver to `desktop`, suppressing further mobile-driven dim
// changes until a mobile actor takes the floor again. Two cases:
// 1. Active mobile subscriber: route through applyMobileDisplayMode so
// the existing 'resized' event reaches the phone.
// 2. Held with no mobile subscriber (post-indefinite-hold): no inner
// subscriber to notify; resolve restore target and enqueueLayout
// directly. applyLayout is the SOLE writer of terminalFitOverrides on
// the normal path, so the held branch defers the mutation to it — the
// one exception is the dead-pty orphan cleanup below, which mirrors
// onPtyExit's sanctioned delete. See docs/mobile-fit-hold.md.
// back" / "Restore" button). Forces the PTY back to desktop dims and flips
// the driver to `desktop`, suppressing further mobile-driven dim changes
// until a mobile actor takes the floor again. Three cases, each ending in
// releaseDesktopTakeBack:
// 1. Active mobile subscriber: route through applyMobileDisplayMode so the
// existing 'resized' event reaches the phone.
// 2. Held override, no subscriber (post-indefinite-hold): resolve the
// restore target and enqueueLayout directly.
// 3. Stale mobile driver, no subscriber and no override: nothing to resize,
// just drop the lock. See docs/mobile-fit-hold.md.
//
// Returns `true` only when the pty ends converged (no fit-override held);
// `false` when a restore was attempted but the resize failed (#7588), or
// when there was nothing to reclaim. On a failed-restore `false`, driver/mode
// are left unchanged so a driving phone keeps its lock and the modal
// truthfully stays — the user can retry.
// Why: explicit desktop take-back is a user command to reclaim input control
// NOW. Unlike the auto-restore timer and phone-initiated setDisplayMode paths
// (which keep the lock when a resize can't converge, #7588), this gesture
// ALWAYS drops the presence lock and banner. "Take back all terminals"
// reclaims several PTYs at once; a background pane whose desktop resize can't
// converge must not strand its banner on the other terminals. The resize is
// best-effort — the desktop renderer refits the PTY on its next settled
// frame. Returns `true` whenever there was a lock to reclaim, `false` only
// when there was nothing to reclaim.
async reclaimTerminalForDesktop(ptyId: string): Promise<boolean> {
if (this.isMobileSubscriberActive(ptyId)) {
// Why (#7588): capture the prior mode so a failed restore can roll the
// routing-only 'desktop' write back — driver/mode transitions must be
// gated on convergence, not committed before we know the resize took.
const priorMode = this.getMobileDisplayMode(ptyId)
this.setMobileDisplayMode(ptyId, 'desktop')
const converged = await this.applyMobileDisplayMode(ptyId)
if (!converged) {
// Resize failed — override still held. Leave the driver on its mobile
// lock and undo the mode write so nothing is left lying at 'desktop'.
this.setMobileDisplayMode(ptyId, priorMode)
return false
}
this.setDriver(ptyId, { kind: 'desktop' })
// Why: a desktop-initiated reclaim is "I'm taking over right now",
// not a sticky preference. The next mobile subscribe (e.g. user
// switches back to the terminal tab on the phone) must default to
// phone-fit again, not stay in passive desktop-watch mode.
await this.applyMobileDisplayMode(ptyId)
this.releaseDesktopTakeBack(ptyId)
// Why: a desktop-initiated reclaim is "I'm taking over right now", not a
// sticky preference. The next mobile subscribe (e.g. user switches back to
// the terminal tab on the phone) must default to phone-fit again, not stay
// in passive desktop-watch mode.
this.setMobileDisplayMode(ptyId, 'auto')
return true
}
@ -8251,36 +8244,38 @@ export class OrcaRuntimeService {
const renderer = this.lastRendererSizes.get(ptyId)
const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols
const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows
const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
if (result.ok) {
this.setDriver(ptyId, { kind: 'desktop' })
// Why: a desktop-initiated reclaim is "I'm taking over right now",
// not a sticky preference. Reset to auto so the next mobile subscribe
// re-enters phone-fit. (Held-PTY branch may not have an entry, but
// calling setMobileDisplayMode('auto') is a no-op deletion in that
// case — safe and idempotent.)
this.setMobileDisplayMode(ptyId, 'auto')
return true
}
// Why (#7588): the layout entry is gone (pty exited) but the override is
// still held — unreachable via public APIs today (onPtyExit deletes both
// in lockstep), but reporting success while stranding the override would
// re-show the modal on the next hydrate. Run the same cleanup onPtyExit
// does (delete override + paired desktop-fit 0×0) so the renderer
// converges; nothing to resize on a dead pty.
if (result.reason === 'pty-exited' && this.terminalFitOverrides.has(ptyId)) {
this.terminalFitOverrides.delete(ptyId)
this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0)
this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0)
return true
}
// resize-failed: the override remains held; report the truth so the
// modal correctly stays and the user can retry. Driver/mode untouched.
return false
await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
this.releaseDesktopTakeBack(ptyId)
this.setMobileDisplayMode(ptyId, 'auto')
return true
}
// Why: a stale lock — driver still reads mobile with no active subscriber
// and no held override (e.g. reclaimed inside the soft-leave grace, or a
// subscriber that dropped without a clean unsubscribe). Release it so the
// banner can't linger; there is nothing to resize.
if (this.getDriver(ptyId).kind === 'mobile') {
this.releaseDesktopTakeBack(ptyId)
return true
}
return false
}
// Why: the shared "banner must be gone now" step for an explicit desktop
// take-back. Releases the presence lock (driver → desktop) and, if the
// best-effort resize left a fit-override held (resize didn't converge),
// clears it optimistically with a paired desktop-fit 0×0 — the same signal
// onPtyExit emits — so neither the presence-lock banner nor the held-fit
// banner can survive the reclaim. The desktop renderer refits the PTY to real
// dims on its next settled frame.
private releaseDesktopTakeBack(ptyId: string): void {
this.setDriver(ptyId, { kind: 'desktop' })
if (this.terminalFitOverrides.has(ptyId)) {
this.terminalFitOverrides.delete(ptyId)
this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0)
this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0)
}
}
// Why: read-side clamp for mobileAutoRestoreFitMs. `null` means
// indefinite hold (no auto-restore timer). A finite value is clamped
// to [MIN, MAX] to defend against bad config — the smallest useful

View File

@ -1,4 +1,5 @@
import type { GlobalSettings } from '../../../../shared/types'
import { mapWithConcurrency } from '../../../../shared/map-with-concurrency'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
@ -7,6 +8,13 @@ import {
type TerminalFitRestoreSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | undefined
// Why: "take back all terminals" can target a phone that controls hundreds of
// PTYs. Fanning out one IPC/RPC reclaim per PTY unbounded would burst the
// runtime transport; cap the in-flight reclaims so a huge session degrades to
// steady throughput instead of a thundering herd. Each reclaim is a short
// round-trip, so a modest pool keeps latency low without overwhelming it.
const RESTORE_FIT_CONCURRENCY = 8
const restoreFailedResult = (): { restored: boolean } => {
// Why: terminal fit restore is best-effort when mobile/remote transports disappear.
return { restored: false }
@ -37,8 +45,8 @@ export async function restoreTerminalFitsToDesktop(
settings: TerminalFitRestoreSettings
): Promise<boolean> {
const uniquePtyIds = [...new Set(ptyIds)]
const results = await Promise.all(
uniquePtyIds.map((ptyId) => restoreTerminalFitToDesktop(ptyId, settings))
const results = await mapWithConcurrency(uniquePtyIds, RESTORE_FIT_CONCURRENCY, (ptyId) =>
restoreTerminalFitToDesktop(ptyId, settings)
)
return results.some(Boolean)
}

View File

@ -21,6 +21,7 @@ import {
type WorkspaceCleanupScanProgress,
type WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import { mapWithConcurrency } from '../../../../shared/map-with-concurrency'
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { translate } from '@/i18n/i18n'
@ -553,26 +554,6 @@ function getWorkspaceCleanupProgressCandidateIndex(
}
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
const workerCount = Math.min(limit, items.length)
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
results[index] = await fn(items[index])
}
})
)
return results
}
function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] {
const ids = new Set<string>()
if (state.activeWorktreeId) {

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { mapWithConcurrency } from './map-with-concurrency'
describe('mapWithConcurrency', () => {
it('preserves input order in the result array regardless of settle order', async () => {
// Later items resolve sooner, so a naive push-on-resolve would reorder.
const results = await mapWithConcurrency([30, 20, 10], 3, async (ms, index) => {
await new Promise((resolve) => setTimeout(resolve, ms))
return index
})
expect(results).toEqual([0, 1, 2])
})
it('never exceeds the concurrency limit', async () => {
let inFlight = 0
let peak = 0
const items = Array.from({ length: 50 }, (_, i) => i)
await mapWithConcurrency(items, 8, async () => {
inFlight += 1
peak = Math.max(peak, inFlight)
await new Promise((resolve) => setTimeout(resolve, 1))
inFlight -= 1
})
expect(peak).toBeLessThanOrEqual(8)
// With 50 items and limit 8, the pool should actually saturate.
expect(peak).toBe(8)
})
it('processes every item exactly once', async () => {
const seen: number[] = []
await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => {
seen.push(n)
})
expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5])
})
it('returns an empty array for no items without spawning workers', async () => {
let calls = 0
const results = await mapWithConcurrency([], 4, async () => {
calls += 1
})
expect(results).toEqual([])
expect(calls).toBe(0)
})
it('clamps a limit below one to a single worker instead of stalling', async () => {
const results = await mapWithConcurrency([1, 2, 3], 0, async (n) => n * 2)
expect(results).toEqual([2, 4, 6])
})
})

View File

@ -0,0 +1,24 @@
// Why: bounded-concurrency map. `Promise.all(items.map(fn))` fans out every
// item at once — fine for a handful, but a burst of hundreds of concurrent
// IPC/RPC round-trips can swamp the transport or its call queue. This runs at
// most `limit` calls in flight via a fixed worker pool while preserving input
// order in the result array (results[i] corresponds to items[i]).
export async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
const workerCount = Math.max(1, Math.min(limit, items.length))
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
results[index] = await fn(items[index], index)
}
})
)
return results
}