From 72864c1844f75567b8cf7edafdac3711bc44af2d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:23:21 -0700 Subject: [PATCH] fix(terminal): allow independent hidden account restarts to progress (#31994) * fix(terminal): let independent account restarts progress in parallel * docs(terminal): explain the detached restart concurrency cap The regression tests reproduce the stalled-spawn scenario reported in #22243. Co-Authored-By: Claude Co-authored-by: Tauri-EPO <176725071+Tauri-EPO@users.noreply.github.com> * fix(terminal): re-ask and free the slot when a detached restart spawn stalls A spawn that never settles no longer holds one of the four restart slots or leaves its pane silently input-blocked: after 90s the pane's restart prompt reopens and the slot is released, while the claim stays until main settles so a late result is still adopted or reaped. Co-Authored-By: Claude --------- Co-authored-by: Claude Co-authored-by: Tauri-EPO <176725071+Tauri-EPO@users.noreply.github.com> --- config/reliability-gates.jsonc | 86 ++++++ .../account-restart-queue-progress.test.ts | 282 ++++++++++++++++++ .../codex-detached-pane-restart-scheduler.ts | 28 +- .../codex-detached-pane-restart.ts | 66 ++-- .../detached-pane-restart-pty-release.ts | 29 ++ .../detached-pane-restart-slots.ts | 27 ++ 6 files changed, 458 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts create mode 100644 src/renderer/src/components/terminal-pane/detached-pane-restart-pty-release.ts create mode 100644 src/renderer/src/components/terminal-pane/detached-pane-restart-slots.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4197b6cbf3e..c61dee8a9f1 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,92 @@ } }, "gates": [ + { + "id": "agent-session.detached-account-restart-progress", + "title": "Independent hidden account restarts progress within a shared spawn limit", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-store-provider-contract", + "surfaces": [ + "hidden account-switch restart", + "Codex restart notices", + "Claude move-on-turn-end" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local-daemon", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "Real renderer store and scheduler with synthetic PTY IPC. Covers slot admission, later requests, prompt recovery, mounted ownership, stale replacement cleanup and executor reinstall. Physical PTYs and account switches remain unproved. Foreign-runtime and mobile execution are unchanged.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/22243"], + "invariant": "One unresolved detached restart must not block independent accepted restarts, and after 90s it must re-ask instead of blocking silently. At most four unexpired claimed restarts may be in flight across sweeps; each claim must recheck current pane ownership before spawning and retain existing late-result adoption or reaping.", + "oracle": "Hold the first spawn pending and reject the second; require later panes to rebind and unblock. Add requests during a pending sweep and require four total in flight, with excess requests still queued. Free a slot after a queued tab mounts and require that mounted pane to remain untouched while another pane progresses. Reinstall the executor and settle an older claim to release a slot for new work. Stall four spawns past the deadline and require the fifth pane to restart, the stalled pane to ask again, and its late result to still adopt.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts src/renderer/src/lib/agent-account-restart-startup.test.ts src/renderer/src/lib/codex-session-restart.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts", + "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts", + "src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts", + "src/renderer/src/lib/agent-account-restart-startup.test.ts", + "src/renderer/src/lib/codex-session-restart.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts", + "assertions": [ + "finishes later accepted panes while the first spawn stays pending and a second fails", + "admits later requests within a shared limit and rechecks mounted ownership as slots free", + "resumes queued work when a claim from an earlier executor installation settles", + "asks again and frees the slot when a spawn stalls, then adopts its late result" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts", + "assertions": [ + "rejects a detached spawn after the tab generation and leaf owner change", + "keeps the replacement when a pane mounts during the spawn, since main already stopped the old PTY" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-22", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts src/renderer/src/lib/agent-account-restart-startup.test.ts src/renderer/src/lib/codex-session-restart.test.ts", + "result": "passed", + "durationSeconds": 4.52, + "summary": "75 tests pass across five files. Initial serial code fails both progress regressions; a stale installation callback fails the reinstall regression until corrected." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "deterministic renderer store/provider contracts" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "One local full gate pass; CI soak not collected." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original serial sweep strands pane three and admits only one later request. Generation-bound settlement callbacks strand the fifth pane after reinstall. All three regressions pass after fixes." + }, + "performanceBudget": { + "required": true, + "evidence": "At most four claimed restart IPCs across all concurrent sweeps; fake-clock test verifies admission and slot reuse. One 90s per-claim timer frees a stalled slot and re-asks; no polling, retries, cancellation or process enumeration. Existing pending store is the queue; no second pending collection." + }, + "promotionCriteria": [ + "Collect 100 CI passes or 14 days without unexplained flakes.", + "Exercise live Codex and Claude hidden account-switch batches on Windows, Linux and macOS." + ], + "knownGaps": [ + "A stalled spawn is not cancelled; its pane re-asks after 90s and stays claimed until main settles. Why a real Windows spawn stalls is not proved.", + "No live accounts, ConPTY, WSL or rendered prompt interaction exercised; no claim of closing broader restart/continuity reports.", + "Remote-runtime and mobile detached executors are unchanged; foreign-machine PTYs still wait for mounted transport." + ], + "demotionRule": "Keep experimental or demote if pending work spins, the shared limit is exceeded, mounted owners are replaced, or stale results leak or overwrite newer panes." + }, { "id": "terminal-provider.retired-macos-daemon-recovery", "title": "Restart releases retired macOS daemon conversation owners across upgrades", diff --git a/src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts b/src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts new file mode 100644 index 00000000000..3c63d1b503a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/account-restart-queue-progress.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store' +import { provideLayoutFrameTerminalPanes } from '@/runtime/layout-frame-terminal-panes' +import { registerRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' +import { awaitsCodexRestartAnswer, blocksCodexPaneInput } from '../codex-restart-notice-state' +import { + installCodexDetachedPaneRestartExecutor, + resetCodexDetachedPaneRestartExecutorForTests +} from './codex-detached-pane-restart-scheduler' +import { STALLED_DETACHED_RESTART_MS } from './detached-pane-restart-slots' + +const spawn = vi.fn() +const kill = vi.fn() + +function pane(index: number) { + return { + tabId: `restart-tab-${index}`, + ptyId: `wt1@@old-${index}`, + replacementId: `wt1@@replacement-${index}`, + leafId: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` + } +} + +function seedPanes(count: number): void { + const tabs: AppState['tabsByWorktree'][string] = [] + const layouts: AppState['terminalLayoutsByTabId'] = {} + const ptyIdsByTabId: AppState['ptyIdsByTabId'] = {} + for (let index = 1; index <= count; index += 1) { + const { tabId, ptyId, leafId } = pane(index) + tabs.push({ + id: tabId, + worktreeId: 'wt1', + ptyId, + title: 'codex', + customTitle: null, + color: null, + sortOrder: index, + createdAt: index, + launchAgent: 'codex' + }) + ptyIdsByTabId[tabId] = [ptyId] + layouts[tabId] = { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + } + useAppStore.setState({ + tabsByWorktree: { wt1: tabs }, + terminalLayoutsByTabId: layouts, + ptyIdsByTabId + }) + provideLayoutFrameTerminalPanes({ + holdsTerminalPane: () => false, + terminalTab: (worktreeId, tabId) => { + const tab = tabs.find((candidate) => candidate.id === tabId) + const layout = layouts[tabId] + if (worktreeId !== 'wt1' || !tab || !layout) { + return undefined + } + return { + id: tab.id, + kind: 'terminal', + entityId: tab.id, + executionHostId: 'local', + createdAt: tab.createdAt, + customTitle: null, + color: null, + terminal: { + root: layout.root, + panes: Object.entries(layout.ptyIdsByLeafId ?? {}).map(([leafId, ptyId]) => ({ + leafId, + ptyId + })) + } + } + } + }) +} + +function acceptRestarts(indices: number[]): void { + const ids = indices.map((index) => pane(index).ptyId) + useAppStore.getState().markCodexRestartNotices( + ids.map((ptyId) => ({ + ptyId, + previousAccountLabel: 'Account A', + nextAccountLabel: 'Account B' + })) + ) + useAppStore.getState().queuePaneAccountRestarts(ids, 'codex') +} + +function expectRestarted(index: number): void { + const { tabId, ptyId, leafId, replacementId } = pane(index) + const state = useAppStore.getState() + expect(state.ptyIdsByTabId[tabId]).toEqual([replacementId]) + expect(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]).toBe(replacementId) + expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[ptyId])).toBe(false) + expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[replacementId])).toBe(false) +} + +describe('accepted account restart queue progress', () => { + beforeEach(() => { + vi.useFakeTimers() + resetCodexDetachedPaneRestartExecutorForTests() + useAppStore.setState(useAppStore.getInitialState(), true) + spawn.mockReset().mockImplementation(async ({ replacesPtyId }) => ({ + id: replacesPtyId?.replace('old-', 'replacement-') ?? 'unexpected-spawn' + })) + kill.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { pty: { spawn, kill } } }) + }) + + afterEach(() => { + resetCodexDetachedPaneRestartExecutorForTests() + provideLayoutFrameTerminalPanes({ + holdsTerminalPane: () => false, + terminalTab: () => undefined + }) + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('finishes later accepted panes while the first spawn stays pending and a second fails', async () => { + const first = Promise.withResolvers<{ id: string }>() + spawn.mockReturnValueOnce(first.promise).mockRejectedValueOnce(new Error('launch refused')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedPanes(7) + acceptRestarts([1, 2, 3, 4, 5, 6, 7]) + const uninstall = installCodexDetachedPaneRestartExecutor() + try { + await vi.advanceTimersByTimeAsync(0) + + for (const index of [3, 4, 5, 6, 7]) { + expectRestarted(index) + } + const state = useAppStore.getState() + expect(state.ptyIdsByTabId[pane(1).tabId]).toEqual([pane(1).ptyId]) + expect(awaitsCodexRestartAnswer(state.codexRestartNoticeByPtyId[pane(2).ptyId])).toBe(true) + expect(state.ptyIdsByTabId[pane(2).tabId]).toEqual([pane(2).ptyId]) + expect(state.pendingPaneAccountRestarts).toEqual({}) + expect(spawn).toHaveBeenCalledTimes(7) + expect(kill).not.toHaveBeenCalled() + } finally { + first.resolve({ id: pane(1).replacementId }) + await vi.advanceTimersByTimeAsync(0) + uninstall() + warn.mockRestore() + } + expectRestarted(1) + }) + + it('admits later requests within a shared limit and rechecks mounted ownership as slots free', async () => { + const pending = Array.from({ length: 7 }, () => Promise.withResolvers<{ id: string }>()) + spawn.mockImplementation(({ replacesPtyId }) => { + const index = pending.findIndex((_, position) => pane(position + 1).ptyId === replacesPtyId) + const result = pending[index] + if (!result) { + throw new Error('unexpected spawn') + } + return result.promise + }) + seedPanes(7) + acceptRestarts([1]) + const uninstall = installCodexDetachedPaneRestartExecutor() + let unregister = () => {} + try { + await vi.advanceTimersByTimeAsync(0) + acceptRestarts([2, 3, 4, 5, 6, 7]) + await vi.advanceTimersByTimeAsync(0) + + expect(spawn).toHaveBeenCalledTimes(4) + expect(Object.keys(useAppStore.getState().pendingPaneAccountRestarts)).toEqual( + [5, 6, 7].map((index) => pane(index).ptyId) + ) + unregister = registerRuntimeTerminalTab({ + tabId: pane(5).tabId, + worktreeId: 'wt1', + getManager: () => null, + getContainer: () => null, + getPtyIdForPane: () => pane(5).ptyId, + getTabWideAgentHintLeafId: () => null + }) + pending[1]?.resolve({ id: pane(2).replacementId }) + await vi.advanceTimersByTimeAsync(0) + + expectRestarted(2) + expect(spawn).toHaveBeenCalledTimes(5) + expect(spawn.mock.calls[4]?.[0].replacesPtyId).toBe(pane(6).ptyId) + expect(useAppStore.getState().pendingPaneAccountRestarts[pane(5).ptyId]).toBe('codex') + expect(useAppStore.getState().pendingPaneAccountRestarts[pane(7).ptyId]).toBe('codex') + expect(kill).not.toHaveBeenCalled() + } finally { + for (const [position, result] of pending.entries()) { + result.resolve({ id: pane(position + 1).replacementId }) + } + await vi.advanceTimersByTimeAsync(0) + uninstall() + unregister() + } + for (const index of [1, 2, 3, 4, 6, 7]) { + expectRestarted(index) + } + expect(useAppStore.getState().ptyIdsByTabId[pane(5).tabId]).toEqual([pane(5).ptyId]) + }) + + it('resumes queued work when a claim from an earlier executor installation settles', async () => { + const firstBatch = Promise.withResolvers<{ id: string }>() + spawn.mockImplementationOnce(() => firstBatch.promise) + seedPanes(5) + acceptRestarts([1]) + const firstUninstall = installCodexDetachedPaneRestartExecutor() + await vi.advanceTimersByTimeAsync(0) + firstUninstall() + acceptRestarts([2, 3, 4, 5]) + const pending = Array.from({ length: 4 }, () => Promise.withResolvers<{ id: string }>()) + spawn.mockImplementation(({ replacesPtyId }) => { + const index = pending.findIndex((_, position) => pane(position + 2).ptyId === replacesPtyId) + const result = pending[index] + if (!result) { + throw new Error('unexpected spawn') + } + return result.promise + }) + const uninstall = installCodexDetachedPaneRestartExecutor() + try { + await vi.advanceTimersByTimeAsync(0) + expect(spawn).toHaveBeenCalledTimes(4) + firstBatch.resolve({ id: pane(1).replacementId }) + await vi.advanceTimersByTimeAsync(0) + + expectRestarted(1) + expect(spawn).toHaveBeenCalledTimes(5) + expect(useAppStore.getState().pendingPaneAccountRestarts).toEqual({}) + } finally { + uninstall() + firstBatch.resolve({ id: pane(1).replacementId }) + for (const [position, result] of pending.entries()) { + result.resolve({ id: pane(position + 2).replacementId }) + } + await vi.advanceTimersByTimeAsync(0) + } + }) + it('asks again and frees the slot when a spawn stalls, then adopts its late result', async () => { + const stalled = Array.from({ length: 4 }, () => Promise.withResolvers<{ id: string }>()) + spawn.mockImplementation(async ({ replacesPtyId }) => { + const index = stalled.findIndex((_, position) => pane(position + 1).ptyId === replacesPtyId) + return stalled[index]?.promise ?? { id: pane(5).replacementId } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedPanes(5) + acceptRestarts([1, 2, 3, 4, 5]) + const uninstall = installCodexDetachedPaneRestartExecutor() + try { + await vi.advanceTimersByTimeAsync(0) + expect(spawn).toHaveBeenCalledTimes(4) + expect( + awaitsCodexRestartAnswer(useAppStore.getState().codexRestartNoticeByPtyId[pane(1).ptyId]) + ).toBe(false) + + await vi.advanceTimersByTimeAsync(STALLED_DETACHED_RESTART_MS) + expectRestarted(5) + expect( + awaitsCodexRestartAnswer(useAppStore.getState().codexRestartNoticeByPtyId[pane(1).ptyId]) + ).toBe(true) + + stalled[0]?.resolve({ id: pane(1).replacementId }) + await vi.advanceTimersByTimeAsync(0) + expectRestarted(1) + expect(kill).not.toHaveBeenCalled() + } finally { + for (const [position, result] of stalled.entries()) { + result.resolve({ id: pane(position + 1).replacementId }) + } + await vi.advanceTimersByTimeAsync(0) + uninstall() + warn.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart-scheduler.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart-scheduler.ts index 5f358fb0873..cf99af4eef6 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart-scheduler.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart-scheduler.ts @@ -8,8 +8,6 @@ import { let executorInstalled = false let executorGeneration = 0 let sweepQueued = false -let sweepRunning = false -let sweepRequestedAfterRun = false export function hasAddedPendingCodexPaneRestart( current: Record, @@ -45,7 +43,6 @@ export function installCodexDetachedPaneRestartExecutor(): () => void { executorInstalled = false executorGeneration += 1 sweepQueued = false - sweepRequestedAfterRun = false } } } @@ -54,8 +51,6 @@ export function resetCodexDetachedPaneRestartExecutorForTests(): void { executorInstalled = false executorGeneration += 1 sweepQueued = false - sweepRunning = false - sweepRequestedAfterRun = false resetDetachedCodexPaneRestartClaimsForTests() } @@ -63,10 +58,6 @@ function scheduleClaimSweep(): void { if (!executorInstalled) { return } - if (sweepRunning) { - sweepRequestedAfterRun = true - return - } if (sweepQueued) { return } @@ -78,21 +69,8 @@ function scheduleClaimSweep(): void { return } sweepQueued = false - if (sweepRunning) { - sweepRequestedAfterRun = true - return - } - sweepRunning = true - void sweepUnclaimedCodexPaneRestarts() - .catch((err) => { - console.warn('[codex-restart] detached restart sweep failed:', err) - }) - .finally(() => { - sweepRunning = false - if (executorInstalled && sweepRequestedAfterRun) { - sweepRequestedAfterRun = false - scheduleClaimSweep() - } - }) + void sweepUnclaimedCodexPaneRestarts(scheduleClaimSweep).catch((err) => { + console.warn('[codex-restart] detached restart sweep failed:', err) + }) }) } diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 7157e9c043b..d45c78d50b8 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -24,28 +24,42 @@ import { getCachedWindowsTerminalCapabilities, hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' -import { ptyDataHandlers, unregisterPtyDataHandlers } from './pty-dispatcher' +import { ptyDataHandlers } from './pty-dispatcher' import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' -import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' -import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry' import { buildPaneIdentityEnv, getWorkspacePath } from './detached-pane-restart-spawn-context' import { restartPaneOwner } from './codex-restart-main-pane' +import { + hasFreeDetachedRestartSlot, + holdDetachedRestartSlot, + resetDetachedRestartSlotsForTests +} from './detached-pane-restart-slots' +import { + killReplacedCodexPanePty, + reapUnboundCodexPty, + releaseReplacedCodexPanePty +} from './detached-pane-restart-pty-release' const inFlightPtyIds = new Set() export function resetDetachedCodexPaneRestartClaimsForTests(): void { inFlightPtyIds.clear() + resetDetachedRestartSlotsForTests() } -export async function sweepUnclaimedCodexPaneRestarts(): Promise { +export async function sweepUnclaimedCodexPaneRestarts(onSettled?: () => void): Promise { + const restarts: Promise[] = [] for (const ptyId of Object.keys(useAppStore.getState().pendingPaneAccountRestarts)) { - await sweepUnclaimedCodexPaneRestart(ptyId) + if (!hasFreeDetachedRestartSlot()) { + break + } + restarts.push(restartUnclaimedPane(ptyId, onSettled)) } + await Promise.all(restarts) } -async function sweepUnclaimedCodexPaneRestart(ptyId: string): Promise { +async function restartUnclaimedPane(ptyId: string, onSettled?: () => void): Promise { let located: LocatedCodexPane | null = null - let claimed = false + let releaseSlot: (() => void) | null = null try { // Why: remote-runtime spawns need that machine's transport assembly, which // only the mounted pane path carries today; leave those queued for mount. @@ -90,7 +104,13 @@ async function sweepUnclaimedCodexPaneRestart(ptyId: string): Promise { return } inFlightPtyIds.add(ptyId) - claimed = true + const claimedPane = located + releaseSlot = holdDetachedRestartSlot(ptyId, () => { + console.warn('[codex-restart] detached restart spawn stalled; asking again') + // Why: a late result still adopts and clears this prompt, since main may have stopped the old PTY. + reopenCurrentCodexRestartPrompt(claimedPane, ptyId) + onSettled?.() + }) await executeDetachedCodexPaneRestart(located, ptyId, agent) } catch (err) { console.warn('[codex-restart] detached pane restart failed:', err) @@ -102,8 +122,10 @@ async function sweepUnclaimedCodexPaneRestart(ptyId: string): Promise { useAppStore.getState().reopenCodexRestartPrompt(ptyId) } } finally { - if (claimed) { + if (releaseSlot) { + releaseSlot() inFlightPtyIds.delete(ptyId) + onSettled?.() } } } @@ -330,29 +352,3 @@ function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: stri } store.replaceTerminalLayoutPanePtyId(tabId, leafId, newPtyId) } - -function reapUnboundCodexPty(ptyId: string, reason: string): void { - try { - void window.api.pty.kill(ptyId).catch((err) => { - console.warn(`[codex-restart] failed to reap ${reason}:`, err) - }) - } catch (err) { - console.warn(`[codex-restart] failed to reap ${reason}:`, err) - } - discardPreHandlerPtyState(ptyId) -} - -function releaseReplacedCodexPanePty(ptyId: string): void { - // Why the disposal: a parked tab's exit sidecar treats an unlabeled exit as the pane - // dying — it would collapse the just-rebound leaf or close the whole tab. - disposeParkedTerminalWatchersForPtyIds([ptyId]) - for (const snapshot of unregisterPtyDataHandlers([ptyId])) { - snapshot.commit() - } - discardPreHandlerPtyState(ptyId) -} - -function killReplacedCodexPanePty(ptyId: string): void { - releaseReplacedCodexPanePty(ptyId) - reapUnboundCodexPty(ptyId, 'replaced Codex pane PTY') -} diff --git a/src/renderer/src/components/terminal-pane/detached-pane-restart-pty-release.ts b/src/renderer/src/components/terminal-pane/detached-pane-restart-pty-release.ts new file mode 100644 index 00000000000..17ecd1bfd1f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/detached-pane-restart-pty-release.ts @@ -0,0 +1,29 @@ +import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { unregisterPtyDataHandlers } from './pty-dispatcher' +import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry' + +export function reapUnboundCodexPty(ptyId: string, reason: string): void { + try { + void window.api.pty.kill(ptyId).catch((err) => { + console.warn(`[codex-restart] failed to reap ${reason}:`, err) + }) + } catch (err) { + console.warn(`[codex-restart] failed to reap ${reason}:`, err) + } + discardPreHandlerPtyState(ptyId) +} + +export function releaseReplacedCodexPanePty(ptyId: string): void { + // Why the disposal: a parked tab's exit sidecar treats an unlabeled exit as the pane + // dying — it would collapse the just-rebound leaf or close the whole tab. + disposeParkedTerminalWatchersForPtyIds([ptyId]) + for (const snapshot of unregisterPtyDataHandlers([ptyId])) { + snapshot.commit() + } + discardPreHandlerPtyState(ptyId) +} + +export function killReplacedCodexPanePty(ptyId: string): void { + releaseReplacedCodexPanePty(ptyId) + reapUnboundCodexPty(ptyId, 'replaced Codex pane PTY') +} diff --git a/src/renderer/src/components/terminal-pane/detached-pane-restart-slots.ts b/src/renderer/src/components/terminal-pane/detached-pane-restart-slots.ts new file mode 100644 index 00000000000..ac97d306309 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/detached-pane-restart-slots.ts @@ -0,0 +1,27 @@ +const slotHolderPtyIds = new Set() +// Why: one stalled spawn must not block the batch, but a large batch must not fan out every agent launch at once. +const MAX_CONCURRENT_RESTARTS = 4 +// Why: an unsettled spawn must not hold a slot or a silent input block forever; its claim stays until it settles. +export const STALLED_DETACHED_RESTART_MS = 90_000 + +export function hasFreeDetachedRestartSlot(): boolean { + return slotHolderPtyIds.size < MAX_CONCURRENT_RESTARTS +} + +/** Holds a slot for `ptyId` until released or, after the stall deadline, frees it and calls `onStall`. */ +export function holdDetachedRestartSlot(ptyId: string, onStall: () => void): () => void { + slotHolderPtyIds.add(ptyId) + const timer = setTimeout(() => { + if (slotHolderPtyIds.delete(ptyId)) { + onStall() + } + }, STALLED_DETACHED_RESTART_MS) + return () => { + clearTimeout(timer) + slotHolderPtyIds.delete(ptyId) + } +} + +export function resetDetachedRestartSlotsForTests(): void { + slotHolderPtyIds.clear() +}