fix(codex): prune retired pane routes safely

This commit is contained in:
Brennan Benson
2026-07-31 17:59:32 -07:00
parent 9921f359dd
commit f8bc8055e9
6 changed files with 105 additions and 9 deletions
@@ -248,6 +248,22 @@ export function hasRecordedLegacySharedCodexPane(): boolean {
)
}
/** Drops records whose daemon PTYs are authoritatively absent. */
export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void {
const registry = readRegistry()
const livePtyIdSet = new Set(livePtyIds)
let changed = false
for (const ptyId of Object.keys(registry.panes)) {
if (!livePtyIdSet.has(ptyId)) {
delete registry.panes[ptyId]
changed = true
}
}
if (changed) {
writeRegistry(registry)
}
}
export const _internals = {
resetCache: (): void => {
cachedRegistry = null
@@ -8,6 +8,7 @@ import {
forgetCodexPaneAccount,
getCodexPaneAccount,
hasRecordedLegacySharedCodexPane,
reconcileCodexPaneAccountsWithLivePtys,
recordCodexPaneAccount
} from './codex-pane-account-registry'
import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts'
@@ -113,6 +114,29 @@ describe('codex pane account registry', () => {
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
})
it('drops leaked records that are absent from the authoritative daemon inventory', () => {
recordCodexPaneAccount('pty-live', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home'
})
recordCodexPaneAccount('pty-dead', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home'
})
reconcileCodexPaneAccountsWithLivePtys(['pty-live'])
_internals.resetCache()
expect(getCodexPaneAccount('pty-live')).not.toBeNull()
expect(getCodexPaneAccount('pty-dead')).toBeNull()
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
reconcileCodexPaneAccountsWithLivePtys([])
expect(hasRecordedLegacySharedCodexPane()).toBe(false)
})
it('forgets a PTY so a reused id cannot inherit a dead pane account', () => {
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
@@ -244,6 +268,29 @@ describe('listStaleCodexPanes', () => {
])
})
it('keeps account-switch copy when the account and home route both change', () => {
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'real-home'
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection('account-a'),
activeHostHomeRoute: 'account-home'
})
).toEqual([
{
ptyId: 'pty-1',
launchAccountId: null,
activeAccountId: 'account-a',
reason: 'account-change'
}
])
})
it('does not guess a route for panes recorded before route provenance', () => {
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null })
+1 -1
View File
@@ -43,7 +43,7 @@ export function listStaleCodexPanes(args: {
ptyId,
launchAccountId: record.accountId,
activeAccountId,
reason: homeRouteChanged ? 'home-route-change' : 'account-change'
reason: accountChanged ? 'account-change' : 'home-route-change'
})
}
}
+20
View File
@@ -830,6 +830,26 @@ export function getDaemonProvider(): DaemonProvider | null {
return adapter
}
/** Returns null unless every daemon generation supplied an authoritative inventory. */
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
if (!adapter) {
return null
}
const adapters =
adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider
? adapter.getAllAdapters()
: [adapter]
const inventories = await Promise.allSettled(
adapters.map((daemonAdapter) => daemonAdapter.listProcesses())
)
if (inventories.some((inventory) => inventory.status === 'rejected')) {
return null
}
return inventories.flatMap((inventory) =>
inventory.status === 'fulfilled' ? inventory.value.map((process) => process.id) : []
)
}
// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference.
export function replaceDaemonProvider(newAdapter: DaemonProvider): void {
adapter = newAdapter
+13 -2
View File
@@ -35,8 +35,13 @@ import {
initDaemonPtyProvider,
disconnectDaemon,
getDaemonProvider,
listLiveDaemonPtyIds,
shutdownDaemon
} from './daemon/daemon-init'
import {
hasRecordedLegacySharedCodexPane,
reconcileCodexPaneAccountsWithLivePtys
} from './codex/codex-pane-account-registry'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
@@ -844,6 +849,14 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
await initDaemonPtyProvider(signal, {
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
})
if (hasRecordedLegacySharedCodexPane()) {
const livePtyIds = await listLiveDaemonPtyIds()
if (livePtyIds) {
reconcileCodexPaneAccountsWithLivePtys(livePtyIds)
}
}
// Why: retained shells can invoke Codex immediately after the startup gate.
codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
},
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect.
@@ -2586,8 +2599,6 @@ void app.whenReady().then(async () => {
userDataPath: app.getPath('userData')
})
}
// Why: retained daemon shells can invoke Codex without crossing launch prep.
codexRuntimeHome.reconcileLegacySharedHomeForRetainedPanes()
if (shouldInstallManagedHooks(is.dev)) {
// Why: check the persisted off switch before any auto-install so removed hooks don't silently reappear on launch.
if (isAgentStatusHooksEnabled(store.getSettings())) {
@@ -95,16 +95,18 @@ describe('startup ordering', () => {
)
})
it('reconciles retained Codex homes once before serve and desktop startup diverge', () => {
it('reconciles retained Codex homes after authoritative daemon inventory', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const hookStateIndex = source.indexOf('ensureRealHomeCodexHookState({')
const reconciliation = 'codexRuntimeHome.reconcileLegacySharedHomeForRetainedPanes()'
const reconciliationIndex = source.indexOf(reconciliation, hookStateIndex)
const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal')
const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex)
const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()'
const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex)
const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex)
const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex)
expect(hookStateIndex).toBeGreaterThanOrEqual(0)
expect(reconciliationIndex).toBeGreaterThan(hookStateIndex)
expect(daemonInitIndex).toBeGreaterThanOrEqual(0)
expect(inventoryIndex).toBeGreaterThan(daemonInitIndex)
expect(reconciliationIndex).toBeGreaterThan(inventoryIndex)
expect(serveIndex).toBeGreaterThan(reconciliationIndex)
expect(desktopIndex).toBeGreaterThan(serveIndex)
expect(source.split(reconciliation)).toHaveLength(2)