diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 8f35a37d391..5fdbbff03bc 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -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 diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 7c70dad7cc4..8261e52f9db 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -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 }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index d449593b955..72cc9983192 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -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' }) } } diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d7fae082341..6c793a316b8 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -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 { + 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 diff --git a/src/main/index.ts b/src/main/index.ts index 8491fd2f2fe..6a5595f381c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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())) { diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 75b1c97a2bb..60cece6cd68 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -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)