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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Tauri-EPO <176725071+Tauri-EPO@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-22 21:23:21 -07:00
committed by GitHub
co-authored by Claude Tauri-EPO
parent 601e900617
commit 72864c1844
6 changed files with 458 additions and 60 deletions
+86
View File
@@ -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",
@@ -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<typeof window.api.pty.spawn>()
const kill = vi.fn<typeof window.api.pty.kill>()
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()
}
})
})
@@ -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<string, AccountRestartAgent>,
@@ -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)
})
})
}
@@ -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<string>()
export function resetDetachedCodexPaneRestartClaimsForTests(): void {
inFlightPtyIds.clear()
resetDetachedRestartSlotsForTests()
}
export async function sweepUnclaimedCodexPaneRestarts(): Promise<void> {
export async function sweepUnclaimedCodexPaneRestarts(onSettled?: () => void): Promise<void> {
const restarts: Promise<void>[] = []
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<void> {
async function restartUnclaimedPane(ptyId: string, onSettled?: () => void): Promise<void> {
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<void> {
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<void> {
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')
}
@@ -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')
}
@@ -0,0 +1,27 @@
const slotHolderPtyIds = new Set<string>()
// 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()
}