mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Fixes #10757. Switching Codex accounts broke three ways, all rooted in the self-contained per-account CODEX_HOME from #9501. HISTORY DISAPPEARED. Codex's own /resume picker only lists rollouts under the launch CODEX_HOME, and nothing bridged history into a per-account home — only the AI Vault's discovery scan knew about the other homes. Every other Orca-visible home's rollouts are now hardlinked in, on selection and again at launch, so one physical log is listed everywhere. THE RESTART PANEL STUCK. A queued restart was only drained by a mounted TerminalPane, but the prompt covered every stale pane in the worktree including parked and cold-deferred tabs. Requesting a restart now answers the prompt immediately while the pane keeps its pending restart, and a pane drains it when its reconnected PTY binds. PANES STAYED ON THE OLD ACCOUNT. CODEX_HOME is fixed in a shell's environment at spawn and the daemon keeps those shells alive across app restarts, while the restart notices are renderer state and are discarded. Each PTY's launch account is now recorded on disk and compared against the current selection at startup. Also merged in: #10802 (a dismissed notice no longer kills the pane's keyboard), #10803 (the sweep arms on real PTY binds, and launcher Codex panes are no longer filtered out by Windows deepest-process reporting), #10804 (a resume-pinned pane now says which account it is on), #10870 (the restart card no longer parks focus on its destructive Restart button), #10853 (the retry ladder is widened past the Windows worst case). Six independent reviews found real defects in every original PR, several of them dead-keyboard bugs and three introduced by the fix for another defect in the same loop. Live QA on macOS covered every PR; Windows was validated three times. WINDOWS: pass 1 found two defects that made the stale-account fix a no-op there (the sweep fired before any PTY was bound and never retried; launcher panes were filtered out). Pass 3 at the merged head: the prompt appears on its own after a restart — warm ~3.7-4.2s, cold ~21s needing rung 4, so #10853's widening was load-bearing rather than precautionary; an ordinary sentence typed into a healthy pane while another pane's card is up reaches that pane and kills nothing; a pane running vim after exiting Codex gets no card, still none 45s later. auth.json byte-identical across every pass. KNOWN GAPS, stated rather than implied: #10804 is unverified on Windows (auto-resume could not be manufactured there); cross-volume Windows is untested and expected to yield no bridged history (EXDEV, and Codex ignores symlinked rollouts); a cold-parked pane never binds so the sweep never covers it; the subagent-deepest launcher shape could not be reproduced on Windows, so that branch is fixture-verified only; WSL passed isolation but the resume mechanism is host-lane only. A host-account switch also marks and mutes live SSH remote panes — confirmed pre-existing on main by two independent QA runs — tracked separately in #10992. Related pre-existing defect filed as #10863.
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
renameSync,
|
|
rmSync
|
|
} from 'node:fs'
|
|
import { dirname, isAbsolute, join, relative, sep } from 'node:path'
|
|
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
|
|
import {
|
|
listCodexSessionJsonlFiles,
|
|
listCodexSessionJsonlFilesIncrementally
|
|
} from './codex-session-file-listing'
|
|
import { linkCodexSessionFile, tryHardlinkCodexSessionFile } from './codex-session-link'
|
|
import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing'
|
|
|
|
export type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing'
|
|
|
|
type LegacyCopiedSessionMarker = {
|
|
sourcePath: string
|
|
sourceSize: number
|
|
sourceMtimeMs: number
|
|
targetSize: number
|
|
targetMtimeMs: number
|
|
}
|
|
|
|
export type LegacyCopiedCodexSessionBridgeScanPreference = {
|
|
sourcePath: string
|
|
preferManagedCopy: boolean
|
|
sourceSkipBytes: number | null
|
|
}
|
|
|
|
export type CodexSessionBridgeSummary = {
|
|
scannedFiles: number
|
|
linkedFiles: number
|
|
}
|
|
|
|
let backgroundSessionBridgeTask: Promise<void> | null = null
|
|
|
|
/**
|
|
* Synchronously mirrors system session files into the managed runtime home.
|
|
*
|
|
* `sourceCodexHomePath` overrides the default ~/.codex history source for users
|
|
* who run Codex with a custom CODEX_HOME; it only affects history discovery.
|
|
*/
|
|
export function syncSystemCodexSessionsIntoManagedHome(sourceCodexHomePath?: string): void {
|
|
const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
|
|
if (!existsSync(systemSessionsRoot)) {
|
|
return
|
|
}
|
|
|
|
const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions')
|
|
for (const systemSessionFilePath of listCodexSessionJsonlFiles(systemSessionsRoot)) {
|
|
bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts a single background bridge task for historical system sessions.
|
|
*
|
|
* Concurrent callers share the same in-flight task so launch code can request
|
|
* background bridging without starting duplicate directory walks.
|
|
*/
|
|
export function startSystemCodexSessionBridgeInBackground(
|
|
options: CodexSessionBridgeIncrementalOptions = {},
|
|
sourceCodexHomePath?: string
|
|
): Promise<void> {
|
|
if (backgroundSessionBridgeTask) {
|
|
return backgroundSessionBridgeTask
|
|
}
|
|
const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options, sourceCodexHomePath)
|
|
.catch((error: unknown) => {
|
|
console.warn('[codex-session-bridge] Background session bridge failed:', error)
|
|
})
|
|
.then(() => undefined)
|
|
backgroundSessionBridgeTask = task
|
|
void task.finally(() => {
|
|
if (backgroundSessionBridgeTask === task) {
|
|
backgroundSessionBridgeTask = null
|
|
}
|
|
})
|
|
return task
|
|
}
|
|
|
|
/**
|
|
* Incrementally mirrors system session files into the managed runtime home.
|
|
*
|
|
* Returns scan/link counts for tests and diagnostics while keeping each file
|
|
* bridge operation equivalent to the synchronous path.
|
|
*/
|
|
export async function syncSystemCodexSessionsIntoManagedHomeIncrementally(
|
|
options: CodexSessionBridgeIncrementalOptions = {},
|
|
sourceCodexHomePath?: string
|
|
): Promise<CodexSessionBridgeSummary> {
|
|
const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
|
|
if (!existsSync(systemSessionsRoot)) {
|
|
return { scannedFiles: 0, linkedFiles: 0 }
|
|
}
|
|
|
|
const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions')
|
|
const summary: CodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 }
|
|
for await (const systemSessionFilePath of listCodexSessionJsonlFilesIncrementally(
|
|
systemSessionsRoot,
|
|
options
|
|
)) {
|
|
summary.scannedFiles += 1
|
|
if (
|
|
bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath)
|
|
) {
|
|
summary.linkedFiles += 1
|
|
}
|
|
}
|
|
return summary
|
|
}
|
|
|
|
/**
|
|
* Bridges one system session file into the managed sessions tree.
|
|
*
|
|
* Existing managed files are migrated when possible; missing files are linked
|
|
* and counted as newly available to the managed runtime home.
|
|
*/
|
|
function bridgeSystemCodexSessionFile(
|
|
systemSessionsRoot: string,
|
|
managedSessionsRoot: string,
|
|
systemSessionFilePath: string
|
|
): boolean {
|
|
const relativePath = relative(systemSessionsRoot, systemSessionFilePath)
|
|
const managedSessionFilePath = join(managedSessionsRoot, relativePath)
|
|
if (existsSync(managedSessionFilePath)) {
|
|
if (
|
|
replaceSymlinkSessionBridgeWithHardlink(
|
|
systemSessionFilePath,
|
|
managedSessionFilePath,
|
|
relativePath
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath)
|
|
return false
|
|
}
|
|
mkdirSync(dirname(managedSessionFilePath), { recursive: true })
|
|
return linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath)
|
|
}
|
|
|
|
/**
|
|
* Links a source session file and clears any stale copied-session marker.
|
|
*/
|
|
function linkSystemCodexSessionFile(
|
|
sourcePath: string,
|
|
targetPath: string,
|
|
relativePath: string
|
|
): boolean {
|
|
const linked = linkCodexSessionFile(sourcePath, targetPath)
|
|
if (linked) {
|
|
clearLegacyCopiedSessionMarker(relativePath)
|
|
}
|
|
return linked
|
|
}
|
|
|
|
/**
|
|
* Replaces an older symlink bridge with a hardlink when the target still points
|
|
* at the expected source session.
|
|
*/
|
|
function replaceSymlinkSessionBridgeWithHardlink(
|
|
sourcePath: string,
|
|
targetPath: string,
|
|
relativePath: string
|
|
): boolean {
|
|
let replacementPath: string | null = null
|
|
try {
|
|
const targetStat = lstatSync(targetPath)
|
|
if (!targetStat.isSymbolicLink()) {
|
|
return false
|
|
}
|
|
const linkTarget = readlinkSync(targetPath)
|
|
const absoluteLinkTarget = isAbsolute(linkTarget)
|
|
? linkTarget
|
|
: join(dirname(targetPath), linkTarget)
|
|
if (absoluteLinkTarget !== sourcePath) {
|
|
return false
|
|
}
|
|
|
|
replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}`
|
|
if (!tryHardlinkCodexSessionFile(sourcePath, replacementPath)) {
|
|
return false
|
|
}
|
|
rmSync(targetPath, { force: true })
|
|
renameSync(replacementPath, targetPath)
|
|
clearLegacyCopiedSessionMarker(relativePath)
|
|
return true
|
|
} catch (error) {
|
|
console.warn(
|
|
'[codex-session-bridge] Failed to replace symlinked Codex session bridge:',
|
|
sourcePath,
|
|
error
|
|
)
|
|
if (replacementPath) {
|
|
rmSync(replacementPath, { force: true })
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Migrates a legacy copied bridge to a linked bridge when the copied file still
|
|
* matches its marker.
|
|
*/
|
|
function migrateLegacyCopiedSessionBridge(
|
|
sourcePath: string,
|
|
targetPath: string,
|
|
relativePath: string
|
|
): void {
|
|
const marker = readLegacyCopiedSessionMarker(relativePath)
|
|
if (!marker || marker.sourcePath !== sourcePath) {
|
|
return
|
|
}
|
|
let replacementPath: string | null = null
|
|
try {
|
|
const targetStat = lstatSync(targetPath)
|
|
if (targetStat.isSymbolicLink()) {
|
|
clearLegacyCopiedSessionMarker(relativePath)
|
|
return
|
|
}
|
|
if (!fileStatsMatchMarker(targetStat, marker, 'target')) {
|
|
return
|
|
}
|
|
replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}`
|
|
if (!linkCodexSessionFile(sourcePath, replacementPath)) {
|
|
return
|
|
}
|
|
rmSync(targetPath, { force: true })
|
|
renameSync(replacementPath, targetPath)
|
|
clearLegacyCopiedSessionMarker(relativePath)
|
|
} catch (error) {
|
|
console.warn(
|
|
'[codex-session-bridge] Failed to migrate copied system Codex session:',
|
|
sourcePath,
|
|
error
|
|
)
|
|
if (replacementPath) {
|
|
rmSync(replacementPath, { force: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves how scanners should treat a legacy copied session bridge.
|
|
*
|
|
* The result keeps resume scans coherent until the copied bridge is migrated to
|
|
* a hardlink or symlink.
|
|
*/
|
|
export function getLegacyCopiedCodexSessionBridgeScanPreference(
|
|
sessionFilePath: string
|
|
): LegacyCopiedCodexSessionBridgeScanPreference | null {
|
|
const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions')
|
|
const relativePath = relative(managedSessionsRoot, sessionFilePath)
|
|
if (
|
|
relativePath === '' ||
|
|
relativePath === '..' ||
|
|
relativePath.startsWith(`..${sep}`) ||
|
|
isAbsolute(relativePath)
|
|
) {
|
|
return null
|
|
}
|
|
const marker = readLegacyCopiedSessionMarker(relativePath)
|
|
if (!marker) {
|
|
return null
|
|
}
|
|
|
|
let targetMatchesMarker = false
|
|
let sourceMatchesMarker = false
|
|
try {
|
|
targetMatchesMarker = fileStatsMatchMarker(lstatSync(sessionFilePath), marker, 'target')
|
|
} catch {}
|
|
try {
|
|
sourceMatchesMarker = fileStatsMatchMarker(lstatSync(marker.sourcePath), marker, 'source')
|
|
} catch {}
|
|
|
|
return {
|
|
sourcePath: marker.sourcePath,
|
|
// Why: legacy copied bridges share a prefix with the source. Scanner must
|
|
// choose one full log until the bridge can be replaced with a real link.
|
|
preferManagedCopy: !targetMatchesMarker || sourceMatchesMarker,
|
|
sourceSkipBytes: !targetMatchesMarker && !sourceMatchesMarker ? marker.sourceSize : null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the marker path for a legacy copied session bridge.
|
|
*/
|
|
function getLegacySessionCopyMarkerPath(relativePath: string): string {
|
|
return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`)
|
|
}
|
|
|
|
/**
|
|
* Reads and validates the marker for a legacy copied session bridge.
|
|
*/
|
|
function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null {
|
|
try {
|
|
const parsed: unknown = JSON.parse(
|
|
readFileSync(getLegacySessionCopyMarkerPath(relativePath), 'utf-8')
|
|
)
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return null
|
|
}
|
|
const marker = parsed as Record<string, unknown>
|
|
if (
|
|
typeof marker.sourcePath !== 'string' ||
|
|
typeof marker.sourceSize !== 'number' ||
|
|
typeof marker.sourceMtimeMs !== 'number' ||
|
|
typeof marker.targetSize !== 'number' ||
|
|
typeof marker.targetMtimeMs !== 'number'
|
|
) {
|
|
return null
|
|
}
|
|
return marker as LegacyCopiedSessionMarker
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether source or target file stats still match a legacy bridge marker.
|
|
*/
|
|
function fileStatsMatchMarker(
|
|
stat: { size: number; mtimeMs: number },
|
|
marker: LegacyCopiedSessionMarker,
|
|
kind: 'source' | 'target'
|
|
): boolean {
|
|
const expectedSize = kind === 'source' ? marker.sourceSize : marker.targetSize
|
|
const expectedMtimeMs = kind === 'source' ? marker.sourceMtimeMs : marker.targetMtimeMs
|
|
return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs
|
|
}
|
|
|
|
/**
|
|
* Removes the marker after a copied session bridge has been migrated or retired.
|
|
*/
|
|
function clearLegacyCopiedSessionMarker(relativePath: string): void {
|
|
rmSync(getLegacySessionCopyMarkerPath(relativePath), { force: true })
|
|
}
|