mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(codex): sweep stale panes on real PTY binds and stop filtering out launcher panes (#10803)
Fixes the two defects a live Windows run found in #10770's stale-account detection, which together made it a no-op on the reporter's platform. 1. The startup sweep fired once during hydration, before any PTY was bound, so getLiveCodexSessionPtyIds found nothing, returned early and never retried — the prompt never appeared after an app restart. It now arms off real PTY binds with a bounded per-PTY ladder (300/1500/4000ms), keyed on the layout binding that every bind path rewrites rather than on a point in time. 2. Windows reports the DEEPEST process in the PTY tree, so an Orca launcher Codex pane running a subagent read as "claude" and was filtered out before the stale-account registry was ever consulted — no card on switch, none from the sweep. Eligibility now falls back to tab.launchAgent, deliberately paired with a foreground that can only be a deeper agent or an unresolved wrapper, so a pane where the user exited Codex back to a shell is never marked. Four further defects found in review: a HIGH dead-keyboard regression where the widened predicate marked a Codex tab running less/vim/ssh; a restored-reattach bind site with zero coverage; a retry-ladder rung collapse where one pane's timer burned another's rung; and a thrown sweep that permanently stranded every pane parked on a later rung (that one introduced by the fix for the third). Live-validated in a real Orca dev build: all five cases pass, including the less/vim pane getting no card and typing fine, two worktrees prompting with independent per-PTY timings (741ms / 658ms after their binds, no rung collapse), and the eligibility module returning all 14 matrix cases correctly including the Windows claude.exe subagent shape. Bind-to-card ~0.66s, leaving ~3.3s of ladder unused. Not verified: the real Windows process tree (macOS fixtures only) — a Windows re-run of the original T5/W4 cases is still required before #10770 lands. Known and out of scope: a HOST account switch marks and mutes live SSH remote panes. Pre-existing (base filtered purely on isCodexForegroundProcess with no lane check) but this PR widens its blast radius via the launchAgent fallback; needs a follow-up gating markLiveCodexSessionsForRestart on the selection lane. This PR's own mechanism is safe there — remote panes are never written to the pane-account registry, verified on disk.
This commit is contained in:
@@ -122,7 +122,6 @@ import {
|
||||
shouldPersistWorkspaceSession
|
||||
} from './lib/workspace-session'
|
||||
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
|
||||
import { markRestoredStaleCodexSessionsForRestart } from './lib/codex-session-restart'
|
||||
import { buildActiveViewUnloadPatch } from './lib/active-view-persist'
|
||||
import {
|
||||
buildWorkspaceSessionHostSnapshots,
|
||||
@@ -1090,12 +1089,6 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
})()
|
||||
// Why: restored Codex panes keep the account that was selected when
|
||||
// their shell first opened, so re-raise the restart prompt the app
|
||||
// restart discarded. Deferred — it is a hint, never startup-critical.
|
||||
void markRestoredStaleCodexSessionsForRestart().catch((err: unknown) => {
|
||||
console.warn('Codex stale-pane restart sweep failed:', err)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Why (issue #1158): leave in-memory state untouched and keep hydrationSucceeded false (default-hydrating here once erased saved tabs); still flip the ready flags so the UI mounts.
|
||||
|
||||
@@ -96,6 +96,7 @@ async function renderHeadlessTerminalState(
|
||||
}
|
||||
|
||||
const toastInfo = vi.fn()
|
||||
const notifyCodexPaneBoundForStaleSweep = vi.fn()
|
||||
const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const
|
||||
const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const
|
||||
const UUID_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||
@@ -328,6 +329,10 @@ vi.mock('sonner', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
}))
|
||||
|
||||
// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React).
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
@@ -4327,6 +4332,9 @@ describe('connectPanePty', () => {
|
||||
expect(pane.container.dataset.ptyId).toBe('pty-daemon-reattach')
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-daemon-reattach')
|
||||
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-daemon-reattach')
|
||||
// Why: the restored shell keeps the CODEX_HOME it was spawned with, and this
|
||||
// bind is the first moment the daemon PTY can be inspected for it.
|
||||
expect(notifyCodexPaneBoundForStaleSweep).toHaveBeenCalledWith('pty-daemon-reattach')
|
||||
})
|
||||
|
||||
it('drops xterm onData while pane is replaying restored bytes', async () => {
|
||||
@@ -6330,6 +6338,10 @@ describe('connectPanePty', () => {
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
await Promise.resolve()
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'leaf-pty-2')
|
||||
// Why: a pane that outlived the app reaches its PTY only through this
|
||||
// restored-session reattach, so the stale-account sweep must be queued here
|
||||
// too — the fresh-spawn chokepoint never runs for it.
|
||||
expect(notifyCodexPaneBoundForStaleSweep).toHaveBeenCalledWith('leaf-pty-2')
|
||||
})
|
||||
|
||||
it('resizes a reattached PTY to the current grid when the pane narrows before reattach resolves', async () => {
|
||||
|
||||
@@ -130,6 +130,9 @@ import {
|
||||
registerPtyTitleSource
|
||||
} from './pty-buffer-serializer'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep'
|
||||
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
|
||||
import {
|
||||
discardTerminalOutput,
|
||||
@@ -2824,6 +2827,7 @@ export function connectPanePty(
|
||||
registerSideEffectFactConsumerForPty(ptyId)
|
||||
syncHiddenRendererPtyDelivery()
|
||||
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
|
||||
notifyCodexPaneBoundForStaleSweep(ptyId)
|
||||
const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? []
|
||||
if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) {
|
||||
if (options.replacePtyId) {
|
||||
@@ -7335,6 +7339,7 @@ export function connectPanePty(
|
||||
registerSideEffectFactConsumerForPty(ptyId)
|
||||
syncHiddenRendererPtyDelivery()
|
||||
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
|
||||
notifyCodexPaneBoundForStaleSweep(ptyId)
|
||||
deps.updateTabPtyId(deps.tabId, ptyId)
|
||||
agentCompletionCoordinator.startProcessTracking()
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isCodexRestartEligiblePane } from './codex-pane-restart-eligibility'
|
||||
|
||||
describe('isCodexRestartEligiblePane', () => {
|
||||
it('accepts a pane whose foreground is Codex itself', () => {
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: 'codex', hasChildProcesses: false },
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the shipped Codex binary name and the Windows .exe suffix', () => {
|
||||
for (const foregroundProcess of ['codex-aarch64-ap', 'codex.exe']) {
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess, hasChildProcesses: false },
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a launcher-started Codex pane whose deepest process is a subagent', () => {
|
||||
// Windows reports pwsh -> node -> codex.exe -> claude.exe as "claude".
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: 'claude.exe', hasChildProcesses: true },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a launcher-started Codex pane whose wrapper has not resolved to an agent yet', () => {
|
||||
// Windows reports pwsh while the descendant scan warms up, then `node`
|
||||
// (the `node .../bin/codex` wrapper) before it resolves to "codex".
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: 'node', hasChildProcesses: true },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a Codex-launched pane the user exited and is now typing into', () => {
|
||||
// Why: the process scans only ever surface a recognized agent or the shell,
|
||||
// so `less`/`vim`/`ssh` means Codex is gone — and a notice would eat the
|
||||
// keystrokes the user needs to leave that program.
|
||||
for (const foregroundProcess of ['less', 'vim', 'ssh', 'tmux', 'git']) {
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess, hasChildProcesses: true },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a Codex-launched pane the user exited back to a shell prompt', () => {
|
||||
// A restart notice drops every keystroke, so a bare prompt must stay unmarked.
|
||||
for (const foregroundProcess of ['pwsh.exe', 'powershell.exe', 'bash', 'zsh']) {
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess, hasChildProcesses: false },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a Codex-launched pane whose shell is reported with no live child', () => {
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: 'pwsh.exe', hasChildProcesses: true },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-Codex pane with a live child process', () => {
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: 'claude.exe', hasChildProcesses: true },
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: 'node', hasChildProcesses: true },
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a pane whose process could not be read', () => {
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: null, hasChildProcesses: false },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe(false)
|
||||
// Why: a stale remote handle reports the last-known name; it is not evidence.
|
||||
expect(
|
||||
isCodexRestartEligiblePane({
|
||||
inspection: { foregroundProcess: 'codex', hasChildProcesses: true, unavailable: true },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
recognizeAgentProcess
|
||||
} from '../../../shared/agent-process-recognition'
|
||||
import { isShellProcess } from '../../../shared/shell-process-detection'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
|
||||
|
||||
function normalizeProcessName(processName: string | null): string | null {
|
||||
if (!processName) {
|
||||
return null
|
||||
}
|
||||
return processName.toLowerCase().replace(/\.exe$/, '')
|
||||
}
|
||||
|
||||
export function isCodexForegroundProcess(processName: string | null): boolean {
|
||||
const normalized = normalizeProcessName(processName)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
// Why: node-pty exposes the OS foreground process name, which can be the
|
||||
// shipped Codex binary name (for example "codex-aarch64-ap" on macOS)
|
||||
// instead of the shell command the user typed. Match on a Codex prefix so
|
||||
// account-switch restart prompts still appear for real Codex sessions.
|
||||
return normalized === 'codex' || normalized.startsWith('codex-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a pane may be shown a Codex account-restart prompt.
|
||||
*
|
||||
* Why this is not just a foreground-name match: Windows reports the DEEPEST
|
||||
* process in the PTY tree, so an Orca "Codex" pane running a subagent reads as
|
||||
* `pwsh -> node -> codex.exe -> claude.exe` => "claude" and was filtered out
|
||||
* before the stale-account registry was ever consulted. `launchAgent` is
|
||||
* recorded metadata (Orca started Codex in this tab), not a repaintable label,
|
||||
* so it survives that. A restart notice makes the pane drop every keystroke, so
|
||||
* the fallback is deliberately paired with a foreground that can only be a
|
||||
* deeper *agent* (or the node/python wrapper that has not resolved to one yet):
|
||||
* both the Windows descendant scan and the POSIX `ps` walk only ever surface a
|
||||
* recognized agent or the shell/tty foreground, so `less`, `vim` or `ssh` in
|
||||
* that pane means the user exited Codex and is typing at their own program.
|
||||
*/
|
||||
export function isCodexRestartEligiblePane(args: {
|
||||
inspection: RuntimeTerminalProcessInspection
|
||||
launchAgent: TuiAgent | undefined
|
||||
}): boolean {
|
||||
const { foregroundProcess, hasChildProcesses, unavailable } = args.inspection
|
||||
if (unavailable === true) {
|
||||
return false
|
||||
}
|
||||
if (isCodexForegroundProcess(foregroundProcess)) {
|
||||
return true
|
||||
}
|
||||
if (args.launchAgent !== 'codex' || foregroundProcess === null || !hasChildProcesses) {
|
||||
return false
|
||||
}
|
||||
if (isShellProcess(foregroundProcess)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
recognizeAgentProcess(foregroundProcess) !== null ||
|
||||
isAgentForegroundWrapperProcess(foregroundProcess)
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import {
|
||||
CODEX_ACCOUNT_RESTART_STARTUP,
|
||||
markLiveCodexSessionsForRestart,
|
||||
@@ -16,6 +17,14 @@ const ACCOUNT_A = 'account-a@example.com'
|
||||
const ACCOUNT_B = 'account-b@example.com'
|
||||
const ACCOUNT_C = 'account-c@example.com'
|
||||
|
||||
function setLaunchAgentOnFirstTab(launchAgent: TuiAgent): void {
|
||||
const [tab, ...rest] = useAppStore.getState().tabsByWorktree.wt1 ?? []
|
||||
if (!tab) {
|
||||
throw new Error('expected a seeded tab')
|
||||
}
|
||||
useAppStore.setState({ tabsByWorktree: { wt1: [{ ...tab, launchAgent }, ...rest] } })
|
||||
}
|
||||
|
||||
describe('CODEX_ACCOUNT_RESTART_STARTUP', () => {
|
||||
it('waits for shell readiness before relaunching Codex after an account switch', () => {
|
||||
expect(CODEX_ACCOUNT_RESTART_STARTUP).toEqual({
|
||||
@@ -181,6 +190,58 @@ describe('markLiveCodexSessionsForRestart', () => {
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
})
|
||||
|
||||
it('marks a launcher-started Codex pane whose deepest process is a subagent', async () => {
|
||||
// Windows reports pwsh -> node -> codex.exe -> claude.exe as "claude".
|
||||
setLaunchAgentOnFirstTab('codex')
|
||||
vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
|
||||
foregroundProcess: 'claude.exe',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
|
||||
await markLiveCodexSessionsForRestart({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a Codex-launched pane alone once the user exits back to the shell', async () => {
|
||||
setLaunchAgentOnFirstTab('codex')
|
||||
vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
|
||||
foregroundProcess: 'pwsh.exe',
|
||||
hasChildProcesses: false
|
||||
})
|
||||
|
||||
await markLiveCodexSessionsForRestart({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
|
||||
// Why: a restart notice drops every keystroke in that pane.
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
})
|
||||
|
||||
it('leaves a Codex-launched pane alone while the user is inside their own program', async () => {
|
||||
// Why: the switch path never consults the stale-pane registry, so an exited
|
||||
// Codex tab now running a pager would lose the keystrokes needed to quit it.
|
||||
setLaunchAgentOnFirstTab('codex')
|
||||
vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
|
||||
foregroundProcess: 'less',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
|
||||
await markLiveCodexSessionsForRestart({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
})
|
||||
|
||||
it('still marks a confirmed Codex pane when another pane is unreachable', async () => {
|
||||
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-stale'] } })
|
||||
vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (ptyId) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppState } from '@/store'
|
||||
import { useAppStore } from '@/store'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isCodexRestartEligiblePane } from './codex-pane-restart-eligibility'
|
||||
|
||||
// Why: prompt integrations such as Starship can outlast the daemon's 300ms
|
||||
// Codex fast-path timeout; account restarts must wait until the shell accepts input.
|
||||
@@ -10,52 +11,54 @@ export const CODEX_ACCOUNT_RESTART_STARTUP = {
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
} as const
|
||||
|
||||
function normalizeProcessName(processName: string | null): string | null {
|
||||
if (!processName) {
|
||||
return null
|
||||
}
|
||||
return processName.toLowerCase().replace(/\.exe$/, '')
|
||||
export type CodexPaneScanResult = {
|
||||
ptyId: string
|
||||
/** The pane may be shown a restart prompt (see isCodexRestartEligiblePane). */
|
||||
eligible: boolean
|
||||
/** Inspection failed or the handle was stale, so a later read may answer differently. */
|
||||
inconclusive: boolean
|
||||
/** Orca launched Codex in this tab, so a shell foreground can still be reattach settle. */
|
||||
launchedCodex: boolean
|
||||
/** A restart notice was raised for this pane by this scan. */
|
||||
notified: boolean
|
||||
}
|
||||
|
||||
function isCodexForegroundProcess(processName: string | null): boolean {
|
||||
const normalized = normalizeProcessName(processName)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
// Why: node-pty exposes the OS foreground process name, which can be the
|
||||
// shipped Codex binary name (for example "codex-aarch64-ap" on macOS)
|
||||
// instead of the shell command the user typed. Match on a Codex prefix so
|
||||
// account-switch restart prompts still appear for real Codex sessions.
|
||||
return normalized === 'codex' || normalized.startsWith('codex-')
|
||||
}
|
||||
|
||||
async function getLiveCodexSessionPtyIds(state: AppState): Promise<string[]> {
|
||||
async function scanCodexPanes(
|
||||
state: AppState,
|
||||
ptyIdFilter: ReadonlySet<string> | null
|
||||
): Promise<CodexPaneScanResult[]> {
|
||||
const tabs = Object.values(state.tabsByWorktree).flat()
|
||||
const checks = await Promise.all(
|
||||
const scans = await Promise.all(
|
||||
tabs.map(async (tab) => {
|
||||
const ptyIds = state.ptyIdsByTabId[tab.id] ?? []
|
||||
if (ptyIds.length === 0) {
|
||||
return [] as string[]
|
||||
}
|
||||
|
||||
const ptyIds = (state.ptyIdsByTabId[tab.id] ?? []).filter(
|
||||
(ptyId) => ptyIdFilter === null || ptyIdFilter.has(ptyId)
|
||||
)
|
||||
// Why: Codex sessions are not reliably discoverable from tab labels.
|
||||
// Tabs keep fallback names until a CLI emits an OSC title, and Codex
|
||||
// does not always do that. The foreground PTY process is the stable
|
||||
// source of truth for whether this live tab is actually running Codex.
|
||||
const foregroundProcesses = await Promise.all(
|
||||
ptyIds.map((ptyId) =>
|
||||
inspectRuntimeTerminalProcess(state.settings, ptyId).then(
|
||||
(inspection) => inspection.foregroundProcess,
|
||||
// does not always do that. The live process tree plus the tab's recorded
|
||||
// launchAgent are the stable evidence that this pane is running Codex.
|
||||
return Promise.all(
|
||||
ptyIds.map(async (ptyId) => {
|
||||
const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId).then(
|
||||
(result) => result,
|
||||
// Why: one stale remote pane must not hide restart notices for other confirmed Codex panes.
|
||||
() => null
|
||||
)
|
||||
)
|
||||
return {
|
||||
ptyId,
|
||||
eligible:
|
||||
inspection !== null &&
|
||||
isCodexRestartEligiblePane({ inspection, launchAgent: tab.launchAgent }),
|
||||
inconclusive: inspection === null || inspection.unavailable === true,
|
||||
launchedCodex: tab.launchAgent === 'codex',
|
||||
notified: false
|
||||
}
|
||||
})
|
||||
)
|
||||
return ptyIds.filter((_, index) => isCodexForegroundProcess(foregroundProcesses[index]))
|
||||
})
|
||||
)
|
||||
|
||||
return checks.flat()
|
||||
return scans.flat()
|
||||
}
|
||||
|
||||
export async function markLiveCodexSessionsForRestart(args: {
|
||||
@@ -63,7 +66,8 @@ export async function markLiveCodexSessionsForRestart(args: {
|
||||
nextAccountLabel: string
|
||||
}): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
const liveCodexSessionPtyIds = await getLiveCodexSessionPtyIds(state)
|
||||
const scans = await scanCodexPanes(state, null)
|
||||
const liveCodexSessionPtyIds = scans.filter((scan) => scan.eligible).map((scan) => scan.ptyId)
|
||||
if (liveCodexSessionPtyIds.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -84,18 +88,24 @@ export async function markLiveCodexSessionsForRestart(args: {
|
||||
* the PTY daemon and survive a full app restart with the old account still
|
||||
* baked into their environment. Without this, quitting Orca before restarting a
|
||||
* stale pane silently strands it on the previous account forever.
|
||||
*
|
||||
* Returns one result per inspected pane so the bind-driven sweep can tell an
|
||||
* answered pane from one whose PTY has not reported a usable process yet.
|
||||
*/
|
||||
export async function markRestoredStaleCodexSessionsForRestart(): Promise<void> {
|
||||
export async function markRestoredStaleCodexSessionsForRestart(args?: {
|
||||
ptyIds?: readonly string[]
|
||||
}): Promise<CodexPaneScanResult[]> {
|
||||
const state = useAppStore.getState()
|
||||
const liveCodexSessionPtyIds = await getLiveCodexSessionPtyIds(state)
|
||||
const scans = await scanCodexPanes(state, args?.ptyIds ? new Set(args.ptyIds) : null)
|
||||
const liveCodexSessionPtyIds = scans.filter((scan) => scan.eligible).map((scan) => scan.ptyId)
|
||||
if (liveCodexSessionPtyIds.length === 0) {
|
||||
return
|
||||
return scans
|
||||
}
|
||||
const stalePanes = await window.api.codexAccounts.listStalePanes({
|
||||
ptyIds: liveCodexSessionPtyIds
|
||||
})
|
||||
if (stalePanes.length === 0) {
|
||||
return
|
||||
return scans
|
||||
}
|
||||
|
||||
const resolveAccountLabel = await createCodexAccountLabelResolver()
|
||||
@@ -106,6 +116,8 @@ export async function markRestoredStaleCodexSessionsForRestart(): Promise<void>
|
||||
nextAccountLabel: resolveAccountLabel(pane.activeAccountId)
|
||||
}))
|
||||
)
|
||||
const notifiedPtyIds = new Set(stalePanes.map((pane) => pane.ptyId))
|
||||
return scans.map((scan) => (notifiedPtyIds.has(scan.ptyId) ? { ...scan, notified: true } : scan))
|
||||
}
|
||||
|
||||
async function createCodexAccountLabelResolver(): Promise<(accountId: string | null) => string> {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
resetCodexStalePaneSweepForTests
|
||||
} from './codex-stale-pane-sweep'
|
||||
|
||||
const ACCOUNT_A = 'account-a@example.com'
|
||||
const ACCOUNT_B = 'account-b@example.com'
|
||||
|
||||
const STALE_PANE = {
|
||||
ptyId: 'pty-1',
|
||||
launchAccountId: 'account-a',
|
||||
activeAccountId: 'account-b'
|
||||
}
|
||||
|
||||
function inspectCallCountFor(ptyId: string): number {
|
||||
return vi.mocked(window.api.pty.inspectProcess).mock.calls.filter(([id]) => id === ptyId).length
|
||||
}
|
||||
|
||||
describe('notifyCodexPaneBoundForStaleSweep', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
resetCodexStalePaneSweepForTests()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'orca-1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
launchAgent: 'codex'
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
pendingCodexPaneRestartIds: {},
|
||||
codexRestartNoticeByPtyId: {}
|
||||
})
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
inspectProcess: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: false })
|
||||
},
|
||||
codexAccounts: {
|
||||
...originalWindow?.api?.codexAccounts,
|
||||
list: vi.fn().mockResolvedValue({
|
||||
accounts: [
|
||||
{ id: 'account-a', email: ACCOUNT_A },
|
||||
{ id: 'account-b', email: ACCOUNT_B }
|
||||
],
|
||||
activeAccountId: 'account-b'
|
||||
}),
|
||||
listStalePanes: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCodexStalePaneSweepForTests()
|
||||
vi.useRealTimers()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('raises the prompt once the pane PTY actually binds', async () => {
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
// Nothing inspected yet: the bind has not settled.
|
||||
expect(window.api.pty.inspectProcess).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] })
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces a burst of startup binds into one sweep', async () => {
|
||||
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-2'] } })
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
notifyCodexPaneBoundForStaleSweep('pty-2')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledExactlyOnceWith({
|
||||
ptyIds: ['pty-1', 'pty-2']
|
||||
})
|
||||
})
|
||||
|
||||
it('still coalesces a burst whose binds land milliseconds apart', async () => {
|
||||
// Real startup binds are staggered, so per-PTY due times must not turn one
|
||||
// sweep into one registry round-trip per pane.
|
||||
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-2'] } })
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
notifyCodexPaneBoundForStaleSweep('pty-2')
|
||||
await vi.advanceTimersByTimeAsync(290)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledExactlyOnceWith({
|
||||
ptyIds: ['pty-1', 'pty-2']
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a PTY whose process read is unusable until the reattach settles', async () => {
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
vi.mocked(window.api.pty.inspectProcess).mockRejectedValueOnce(new Error('terminal_gone'))
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
})
|
||||
|
||||
it('gives up after a bounded ladder instead of polling forever', async () => {
|
||||
vi.mocked(window.api.pty.inspectProcess).mockRejectedValue(new Error('terminal_gone'))
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(window.api.pty.inspectProcess).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('spends a pane its full retry ladder even when another pane binds later', async () => {
|
||||
// Regression: one shared timer used to drain every queued PTY, so a pane
|
||||
// already waiting on its 1500ms rung had the next rung consumed by the newer
|
||||
// pane's shorter delay — exhausting its 3 attempts ~2.5s early and dropping
|
||||
// a Windows daemon reattach that had not settled yet.
|
||||
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-2'] } })
|
||||
vi.mocked(window.api.pty.inspectProcess).mockRejectedValue(new Error('terminal_gone'))
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(310)
|
||||
notifyCodexPaneBoundForStaleSweep('pty-2')
|
||||
|
||||
// t = 3310: past pty-1's second look (1800), well before its third (5800).
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(inspectCallCountFor('pty-1')).toBe(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(inspectCallCountFor('pty-1')).toBe(3)
|
||||
})
|
||||
|
||||
it('does not park a fresh bind behind a pending retry wait', async () => {
|
||||
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-2'] } })
|
||||
vi.mocked(window.api.pty.inspectProcess).mockRejectedValue(new Error('terminal_gone'))
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(310)
|
||||
notifyCodexPaneBoundForStaleSweep('pty-2')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
// The pending timer was pty-1's 1500ms rung; pty-2 must not inherit that wait.
|
||||
expect(inspectCallCountFor('pty-2')).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a pane whose sweep threw instead of dropping it', async () => {
|
||||
// Why: listStalePanes reads the on-disk pane-account registry and is the one
|
||||
// call in the sweep with no catch of its own, so a transient rejection must
|
||||
// spend a rung like any other unusable read.
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes)
|
||||
.mockRejectedValueOnce(new Error('registry unreadable'))
|
||||
.mockResolvedValue([STALE_PANE])
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
})
|
||||
|
||||
it('still fires a pane waiting on a later rung when an earlier sweep throws', async () => {
|
||||
// Regression: the failing sweep took its own PTYs out of the queue and
|
||||
// returned without re-aiming the timer, so pty-1 — parked on its 1500ms rung
|
||||
// and never touched by that sweep — was left with nothing to fire it, ever.
|
||||
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-2'] } })
|
||||
vi.mocked(window.api.pty.inspectProcess).mockRejectedValueOnce(new Error('terminal_gone'))
|
||||
// Why: keyed on the swept PTY rather than call order, so the rejection stays
|
||||
// bound to pty-2's sweep even if the flush sequence changes.
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockImplementation(async ({ ptyIds }) => {
|
||||
if (ptyIds.includes('pty-2')) {
|
||||
throw new Error('registry unreadable')
|
||||
}
|
||||
return [STALE_PANE]
|
||||
})
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
// t = 300: pty-1 reads inconclusive and parks on its 1500ms rung (due 1800).
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
notifyCodexPaneBoundForStaleSweep('pty-2')
|
||||
// t = 600: pty-2's sweep is the one that throws; pty-1 is not due and untouched.
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
// Why: assert the throw really landed on a sweep pty-1 was absent from, or a
|
||||
// later change to the flush sequence could leave this passing while the
|
||||
// stranding it exists to catch is no longer being set up at all.
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-2'] })
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1200)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] })
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
})
|
||||
|
||||
it('stops retrying a pane the registry reports as not stale', async () => {
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not re-prompt a pane that already got its notice', async () => {
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never marks a plain shell pane, so its input is never blocked', async () => {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'orca-1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
|
||||
foregroundProcess: 'zsh',
|
||||
hasChildProcesses: false
|
||||
})
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
})
|
||||
|
||||
it('retries a Codex tab still showing its shell, then prompts when Codex is up', async () => {
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
vi.mocked(window.api.pty.inspectProcess).mockResolvedValueOnce({
|
||||
foregroundProcess: 'pwsh.exe',
|
||||
hasChildProcesses: false
|
||||
})
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a PTY the store has not yet listed against its tab', async () => {
|
||||
useAppStore.setState({ ptyIdsByTabId: {} })
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
|
||||
notifyCodexPaneBoundForStaleSweep('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(window.api.pty.inspectProcess).not.toHaveBeenCalled()
|
||||
|
||||
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
markRestoredStaleCodexSessionsForRestart,
|
||||
type CodexPaneScanResult
|
||||
} from './codex-session-restart'
|
||||
|
||||
// Why: the first delay coalesces the startup burst of binds and lets
|
||||
// updateTabPtyId (written just after the layout binding) land, since the scan
|
||||
// walks tabs. The later two cover a reattached daemon shell that answers
|
||||
// `inspectProcess` with terminal_gone for a beat. Bounded on purpose — this is
|
||||
// a hint, and a pane that never resolves must not become a polling loop.
|
||||
const SWEEP_ATTEMPT_DELAYS_MS = [300, 1500, 4000] as const
|
||||
|
||||
// Why: each PTY owns its rung, so the queue has to carry a per-PTY due time. One
|
||||
// shared timer coalesces the startup burst, but it must always target the
|
||||
// EARLIEST due entry — a single timer that drained everything let one pane's
|
||||
// short delay consume another's later rung and cut its ladder short.
|
||||
const dueAtByPtyId = new Map<string, number>()
|
||||
const attemptsByPtyId = new Map<string, number>()
|
||||
const notifiedPtyIds = new Set<string>()
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let flushTimerDueAt: number | null = null
|
||||
|
||||
/**
|
||||
* Queues a stale-account check for a PTY that has just bound to a pane.
|
||||
*
|
||||
* Why a bind signal rather than a startup call: nothing is attached while the
|
||||
* session hydrates, so a one-shot sweep inspects zero PTYs and never retries.
|
||||
* Every real bind rewrites the pane→PTY layout binding, which is the earliest
|
||||
* point the daemon shell can be inspected at all.
|
||||
*/
|
||||
export function notifyCodexPaneBoundForStaleSweep(ptyId: string): void {
|
||||
if (notifiedPtyIds.has(ptyId)) {
|
||||
return
|
||||
}
|
||||
queue(ptyId, SWEEP_ATTEMPT_DELAYS_MS[0])
|
||||
armForEarliestDue()
|
||||
}
|
||||
|
||||
export function resetCodexStalePaneSweepForTests(): void {
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
flushTimerDueAt = null
|
||||
dueAtByPtyId.clear()
|
||||
attemptsByPtyId.clear()
|
||||
notifiedPtyIds.clear()
|
||||
}
|
||||
|
||||
function queue(ptyId: string, delayMs: number): void {
|
||||
const dueAt = Date.now() + delayMs
|
||||
const existing = dueAtByPtyId.get(ptyId)
|
||||
// Why: a rebind of an already-queued PTY must never push its look later.
|
||||
dueAtByPtyId.set(ptyId, existing === undefined ? dueAt : Math.min(existing, dueAt))
|
||||
}
|
||||
|
||||
function armForEarliestDue(): void {
|
||||
let earliestDueAt: number | null = null
|
||||
for (const dueAt of dueAtByPtyId.values()) {
|
||||
if (earliestDueAt === null || dueAt < earliestDueAt) {
|
||||
earliestDueAt = dueAt
|
||||
}
|
||||
}
|
||||
if (earliestDueAt === null) {
|
||||
return
|
||||
}
|
||||
if (flushTimer !== null) {
|
||||
if (flushTimerDueAt !== null && flushTimerDueAt <= earliestDueAt) {
|
||||
return
|
||||
}
|
||||
clearTimeout(flushTimer)
|
||||
}
|
||||
flushTimerDueAt = earliestDueAt
|
||||
flushTimer = setTimeout(
|
||||
() => {
|
||||
flushTimer = null
|
||||
flushTimerDueAt = null
|
||||
void flush()
|
||||
},
|
||||
Math.max(0, earliestDueAt - Date.now())
|
||||
)
|
||||
}
|
||||
|
||||
function takeDuePtyIds(): string[] {
|
||||
const now = Date.now()
|
||||
const duePtyIds: string[] = []
|
||||
for (const [ptyId, dueAt] of dueAtByPtyId) {
|
||||
// Why: folding a never-inspected PTY into an earlier sweep is what coalesces
|
||||
// the startup burst, and costs it nothing. A PTY already waiting on a retry
|
||||
// rung must wait for its own due time, or that rung is spent for free.
|
||||
if (dueAt <= now || !attemptsByPtyId.has(ptyId)) {
|
||||
duePtyIds.push(ptyId)
|
||||
}
|
||||
}
|
||||
for (const ptyId of duePtyIds) {
|
||||
dueAtByPtyId.delete(ptyId)
|
||||
}
|
||||
return duePtyIds
|
||||
}
|
||||
|
||||
function shouldRetry(scan: CodexPaneScanResult): boolean {
|
||||
// Why: an eligible-but-unlisted pane got an authoritative "not stale" from the
|
||||
// registry, so only an unusable read or a Codex tab still showing its shell
|
||||
// (mid-reattach) can change answer on a later attempt.
|
||||
return !scan.eligible && (scan.inconclusive || scan.launchedCodex)
|
||||
}
|
||||
|
||||
function queueNextRung(ptyId: string): void {
|
||||
const attempt = (attemptsByPtyId.get(ptyId) ?? 0) + 1
|
||||
const delayMs = SWEEP_ATTEMPT_DELAYS_MS[attempt]
|
||||
if (delayMs === undefined) {
|
||||
attemptsByPtyId.delete(ptyId)
|
||||
return
|
||||
}
|
||||
attemptsByPtyId.set(ptyId, attempt)
|
||||
queue(ptyId, delayMs)
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
const ptyIds = takeDuePtyIds()
|
||||
if (ptyIds.length === 0) {
|
||||
// Why: a timer can fire a hair early; re-aim it rather than dropping the queue.
|
||||
armForEarliestDue()
|
||||
return
|
||||
}
|
||||
|
||||
let scans: CodexPaneScanResult[]
|
||||
try {
|
||||
scans = await markRestoredStaleCodexSessionsForRestart({ ptyIds })
|
||||
} catch (err) {
|
||||
console.warn('Codex stale-pane restart sweep failed:', err)
|
||||
// Why: a thrown sweep is no more conclusive than an unusable process read, so
|
||||
// spend a rung rather than dropping these panes. Re-aiming is what keeps the
|
||||
// panes this sweep never touched — parked on a later rung, and no longer
|
||||
// holding the timer — from being stranded with nothing left to fire them.
|
||||
for (const ptyId of ptyIds) {
|
||||
queueNextRung(ptyId)
|
||||
}
|
||||
armForEarliestDue()
|
||||
return
|
||||
}
|
||||
|
||||
const scanByPtyId = new Map(scans.map((scan) => [scan.ptyId, scan]))
|
||||
for (const ptyId of ptyIds) {
|
||||
const scan = scanByPtyId.get(ptyId)
|
||||
if (scan?.notified === true) {
|
||||
notifiedPtyIds.add(ptyId)
|
||||
attemptsByPtyId.delete(ptyId)
|
||||
continue
|
||||
}
|
||||
// Why: a PTY the scan never saw is not yet listed against its tab, which is
|
||||
// the same "ask again shortly" case as an unusable process read.
|
||||
if (scan !== undefined && !shouldRetry(scan)) {
|
||||
attemptsByPtyId.delete(ptyId)
|
||||
continue
|
||||
}
|
||||
queueNextRung(ptyId)
|
||||
}
|
||||
|
||||
armForEarliestDue()
|
||||
}
|
||||
Reference in New Issue
Block a user