Merge remote-tracking branch 'origin/main' into brennanb2025/fix-9454-agent-resume

This commit is contained in:
Brennan Benson
2026-07-23 16:39:02 -07:00
43 changed files with 3199 additions and 681 deletions
+68 -13
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-20",
"updatedAt": "2026-07-22",
"policy": {
"maturityLevels": [
"experimental",
@@ -2556,7 +2556,7 @@
"id": "terminal-performance.no-hot-list-sessions",
"title": "Hot terminal interactions do not call global PTY session listing",
"maturity": "experimental",
"protection": "none",
"protection": "partial",
"owner": "terminal-performance",
"layer": "ipc-count-contract",
"surfaces": [
@@ -2580,19 +2580,74 @@
"wsl",
"remote-runtime"
],
"coveredPlatforms": [],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.",
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, and installs no interval. Broader terminal interaction coverage remains on the pending reliability stack.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7002",
"https://github.com/stablyai/orca/pull/6858"
"https://github.com/stablyai/orca/pull/6858",
"https://github.com/stablyai/orca/issues/9386",
"https://github.com/stablyai/orca/pull/9387"
],
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.",
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager status badges avoid pty:listSessions; targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
"commands": [],
"testFiles": [],
"assertionRefs": [],
"evidenceRuns": [],
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs; known-session reattach signals and steady closed time perform zero reads. Targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
],
"testFiles": [
"src/main/ipc/pty.test.ts",
"src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
"assertions": [
"the false-to-true workspace readiness transition performs one daemon inventory seed",
"a failed readiness seed surfaces an error and a later inventory refresh recovers",
"known-session reattach signals perform zero additional inventory reads",
"multiple unknown background spawn signals coalesce to one inventory read",
"spawn signals during a slow inventory read never overlap provider-wide scans and cause at most one required follow-up",
"unknown sessions that exit before reconciliation cancel their queued inventory read",
"unmount during a slow inventory read cannot schedule follow-up work",
"exit and out-of-order refresh races cannot resurrect stale sessions"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"global inventory starts local and SSH provider listings concurrently"
]
},
{
"file": "src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
"assertions": [
"daemon inventory construction copies its source and preserves count parity",
"single and batch removals preserve unrelated sessions and no-op references"
]
},
{
"file": "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"assertions": [
"the closed inventory hook installs no interval",
"the badge count comes from cached daemon inventory rather than wake-hint bindings"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"result": "passed",
"durationSeconds": 4.3,
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "unit or focused Electron count gate"
@@ -2603,7 +2658,7 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover rather than its closed badge, and panes close only on authoritative false. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
},
"performanceBudget": {
"required": true,
@@ -2615,8 +2670,8 @@
"Run with enough preserved sessions/providers to make a broad listing observable."
],
"knownGaps": [
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
"Current command covers targeted visibility/first-input liveness, resize re-assertion on visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager session-poll avoidance, but not every hot interaction listed in the invariant.",
"Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.",
"Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.",
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls."
],
"demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path."
+6
View File
@@ -27,6 +27,9 @@
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-config-settings-preservation.ts",
"../src/main/codex/codex-config-settings-removal.ts",
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-host-retry-deadlines.ts",
"../src/main/codex/codex-hook-identity.ts",
@@ -41,7 +44,10 @@
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/codex-wsl-reconciliation-generations.ts",
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
+24 -13
View File
@@ -8,8 +8,10 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
import {
promoteCodexRuntimeSettingsToSystem,
snapshotCodexRuntimeSettingsBaseline,
type CodexSettingsPromotionHomes
type CodexSettingsPromotionHomes,
type CodexSettingsPromotionPlan
} from './config-settings-promotion'
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
import {
createTomlLineScanState,
getTomlTableHeader,
@@ -31,32 +33,37 @@ export function syncSystemConfigIntoManagedCodexHome(
// Why: the mirror overwrites runtime settings from ~/.codex, so changes the
// user made inside Orca-launched Codex (/model, /approvals) must be written
// back to ~/.codex first or this very pass silently reverts them.
if (!promoteCodexRuntimeSettingsToSystem(homes)) {
const promotionPlan = promoteCodexRuntimeSettingsToSystem(homes)
if (!promotionPlan) {
// Why: mirroring after a failed write-back would erase the runtime change;
// leave both runtime and its old baseline intact so the next launch retries.
return
}
let preservedConflictKeys: ReadonlySet<string>
try {
syncSystemConfigIntoManagedCodexHomeUnsafe(homes)
preservedConflictKeys = syncSystemConfigIntoManagedCodexHomeUnsafe(homes, promotionPlan)
} catch (error) {
console.warn('[codex-config] Failed to mirror system Codex config:', error)
return
}
// Why: the baseline advances only after a successful mirror; recording an
// unpromoted runtime change as Orca-written would strand it forever.
snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath)
snapshotCodexRuntimeSettingsBaseline(
homes.runtimeHomePath,
new Map([...promotionPlan.conflicts].filter(([key]) => preservedConflictKeys.has(key)))
)
}
function syncSystemConfigIntoManagedCodexHomeUnsafe({
runtimeHomePath,
systemHomePath
}: CodexSettingsPromotionHomes): void {
function syncSystemConfigIntoManagedCodexHomeUnsafe(
{ runtimeHomePath, systemHomePath }: CodexSettingsPromotionHomes,
promotionPlan: CodexSettingsPromotionPlan
): ReadonlySet<string> {
const systemConfigPath = join(systemHomePath, 'config.toml')
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
const systemConfigExists = existsSync(systemConfigPath)
const runtimeConfigExists = existsSync(runtimeConfigPath)
if (!systemConfigExists && !runtimeConfigExists) {
return
return new Set()
}
const rawSystemConfig = systemConfigExists ? readAgentStateFileSync(systemConfigPath) : ''
@@ -66,15 +73,19 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe({
runtimeConfigPath,
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
)
return
return new Set()
}
const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir)
const runtimeConfig = readAgentStateFileSync(runtimeConfigPath)
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
if (mergedConfig !== runtimeConfig) {
writeFileAtomically(runtimeConfigPath, mergedConfig)
const preserved = preserveRuntimeConflictValues(
mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig),
promotionPlan.runtimeValuesToPreserve
)
if (preserved.content !== runtimeConfig) {
writeFileAtomically(runtimeConfigPath, preserved.content)
}
return preserved.keys
}
export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string {
@@ -0,0 +1,22 @@
import { removePromotedSettingsFromContent } from './codex-config-settings-removal'
import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
export function preserveRuntimeConflictValues(
content: string,
values: ReadonlyMap<string, string | null>
): { content: string; keys: ReadonlySet<string> } {
let result = content
const keys = new Set<string>()
for (const [key, raw] of values) {
const previous = result
result =
raw === null
? removePromotedSettingsFromContent(result, new Set([key]))
: upsertPromotedSettingsInContent(result, new Map([[key, raw]]))
if (result !== previous) {
keys.add(key)
}
}
// Why: only schema-new ambiguous keys stay runtime-local; every unrelated setting still mirrors.
return { content: result, keys }
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { removePromotedSettingsFromContent } from './codex-config-settings-removal'
describe('removePromotedSettingsFromContent', () => {
it('removes a top-level preamble key without touching nested copies', () => {
expect(
removePromotedSettingsFromContent(
'model = "root"\n\n[profiles.dev]\nmodel = "nested"\n',
new Set(['model'])
)
).toBe('\n[profiles.dev]\nmodel = "nested"\n')
})
it('removes a bare key from the first tui table body', () => {
expect(
removePromotedSettingsFromContent(
'[tui]\ntheme = "dark"\nanimations = true\n\n[tui.notifications]\ntheme = "nested"\n',
new Set(['tui.theme'])
)
).toBe('[tui]\nanimations = true\n\n[tui.notifications]\ntheme = "nested"\n')
})
it('removes dotted and quoted dotted tui keys from the preamble', () => {
expect(
removePromotedSettingsFromContent(
'tui.theme = "dark"\n"tui" . "status_line" = ["model"]\n',
new Set(['tui.theme', 'tui.status_line'])
)
).toBe('')
})
it('does not remove a tui-shaped key inside another table', () => {
const content = '[[profiles]]\ntui.theme = "profile-theme"\n'
expect(removePromotedSettingsFromContent(content, new Set(['tui.theme']))).toBe(content)
})
it('does not remove a nested tui descendant with the same first segment', () => {
const content = '[tui]\ntheme.variant = "dark"\n'
expect(removePromotedSettingsFromContent(content, new Set(['tui.theme']))).toBe(content)
})
})
@@ -0,0 +1,73 @@
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
import { tuiStructuredKey } from './codex-config-settings-upsert'
export function removePromotedSettingsFromContent(
content: string,
removals: ReadonlySet<string>
): string {
if (removals.size === 0) {
return content
}
const lines = content.split('\n')
const indexes: number[] = []
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
const header = getTomlTableHeader(line)
if (header) {
const table = parseTomlTableHeaderPath(header)
tuiBodyActive =
table !== null &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
tuiTableSeen ||= tuiBodyActive
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
const parsed = parseTomlKeyPath(line)
if (parsed && line[parsed.end] === '=') {
const structuredKey = getStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
if (structuredKey && removals.has(structuredKey)) {
indexes.push(index)
}
}
}
state = updateTomlLineScanState(state, line)
}
for (const index of indexes.toReversed()) {
lines.splice(index, 1)
}
return lines.join('\n')
}
function getStructuredKey(
segments: string[],
inPreamble: boolean,
tuiBodyActive: boolean
): string | null {
if (inPreamble && segments.length === 1) {
return segments[0] ?? null
}
if (inPreamble && segments.length === 2 && segments[0] === 'tui') {
return segments[1] ? tuiStructuredKey(segments[1]) : null
}
if (tuiBodyActive && segments.length === 1) {
return segments[0] ? tuiStructuredKey(segments[0]) : null
}
return null
}
@@ -0,0 +1,322 @@
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
const TUI_STRUCTURED_PREFIX = 'tui.'
// Why: promoted [tui] settings are keyed by structured path (tui.<key>) so their
// baseline/update entries can never collide with a top-level key of the same name.
export function tuiStructuredKey(key: string): string {
return `${TUI_STRUCTURED_PREFIX}${key}`
}
export function isTuiStructuredKey(structuredKey: string): boolean {
return structuredKey.startsWith(TUI_STRUCTURED_PREFIX)
}
export function tuiKeyFromStructuredKey(structuredKey: string): string {
return structuredKey.slice(TUI_STRUCTURED_PREFIX.length)
}
// Why: promoted updates arrive keyed by structured path; the preamble and [tui]
// regions are disjoint, so a mixed batch (e.g. /model + a status-line change)
// composes in one rewrite — top-level keys land in the preamble, tui.<key>
// entries wherever the [tui] placement rule puts them.
export function upsertPromotedSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const topLevelUpdates = new Map<string, string>()
const tuiUpdates = new Map<string, string>()
for (const [key, raw] of updates) {
if (isTuiStructuredKey(key)) {
tuiUpdates.set(tuiKeyFromStructuredKey(key), raw)
} else {
topLevelUpdates.set(key, raw)
}
}
let result = content
if (topLevelUpdates.size > 0) {
result = upsertTopLevelSettingsInContent(result, topLevelUpdates)
}
if (tuiUpdates.size > 0) {
result = upsertTuiSettingsInContent(result, tuiUpdates)
}
return result
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const parsed = parseTomlKeyPath(line)
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
keyLineIndexes.set(key, index)
}
}
state = updateTomlLineScanState(state, line)
}
// Why: CRLF configs keep a trailing \r after the split; new lines must use
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
return joinPreservingTrailingNewline(lines, usesCrlf)
}
type TuiPlacementScan = {
bareKeyIndexes: Map<string, number>
dottedKeyIndexes: Map<string, number>
hasBareTuiTable: boolean
hasDottedTuiKey: boolean
blocksNewTuiTable: boolean
blockedAbsentKeys: Set<string>
bareBodyInsertIndex: number
lastDottedTuiIndex: number
}
/**
* Upserts promoted `[tui]` keys (keyed by bare name) into the system config,
* placing each per the design's total placement rule: replace an existing key
* in place keeping its form; else insert bare into the first `[tui]` body; else
* dotted in the preamble beside existing dotted `tui.*` keys; else create one
* `[tui]` table at EOF for every key that reaches that branch. Rendering follows
* the destination — bare inside a table, dotted in the preamble — so no `tui`
* table is ever defined twice.
*/
export function upsertTuiSettingsInContent(content: string, updates: Map<string, string>): string {
const lines = content.split('\n')
const scan = scanTuiPlacement(lines, updates)
const usesCrlf = content.includes('\r\n')
const bareBodyInserts: string[] = []
const dottedPreambleInserts: string[] = []
const newTableKeys: string[] = []
for (const [key, raw] of updates) {
const dottedIndex = scan.dottedKeyIndexes.get(key)
if (dottedIndex !== undefined) {
lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`)
continue
}
const bareIndex = scan.bareKeyIndexes.get(key)
if (bareIndex !== undefined) {
lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`)
continue
}
// Why: adding a scalar beside an existing tui.<key> descendant would turn valid TOML invalid.
if (scan.blockedAbsentKeys.has(key)) {
continue
}
if (scan.hasBareTuiTable) {
bareBodyInserts.push(`${key} = ${raw}`)
} else if (scan.hasDottedTuiKey) {
dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`)
} else if (!scan.blocksNewTuiTable) {
// Why: inline/array tui definitions block this branch because adding a
// plain [tui] beside either would make the config invalid.
newTableKeys.push(`${key} = ${raw}`)
}
}
// Why: the config shape routes every absent key to the same branch, so at most
// one insert group is non-empty; still apply EOF→body→preamble so a splice
// never shifts a lower index a later splice depends on.
if (newTableKeys.length > 0) {
appendNewTuiTable(lines, newTableKeys, usesCrlf)
}
if (bareBodyInserts.length > 0) {
lines.splice(
scan.bareBodyInsertIndex,
0,
...bareBodyInserts.map((line) => withCrLine(line, usesCrlf))
)
}
if (dottedPreambleInserts.length > 0) {
lines.splice(
scan.lastDottedTuiIndex + 1,
0,
...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf))
)
}
return joinPreservingTrailingNewline(lines, usesCrlf)
}
function scanTuiPlacement(lines: string[], updates: Map<string, string>): TuiPlacementScan {
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
let tuiBodyHeaderIndex = -1
let tuiBodyEndIndex = -1
let hasDottedTuiKey = false
let blocksNewTuiTable = false
let lastDottedTuiIndex = -1
const bareKeyIndexes = new Map<string, number>()
const dottedKeyIndexes = new Map<string, number>()
const blockedAbsentKeys = new Set<string>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
const header = getTomlTableHeader(line)
if (header) {
if (tuiBodyActive) {
tuiBodyEndIndex = index
tuiBodyActive = false
}
const table = parseTomlTableHeaderPath(header)
if (
table &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
) {
tuiTableSeen = true
tuiBodyActive = true
tuiBodyHeaderIndex = index
}
// Why: a root [[tui]] is already an array, so appending [tui] would
// redefine it and make an otherwise valid config unparseable.
if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') {
blocksNewTuiTable = true
}
const descendantKey =
table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
if (inPreamble) {
// Why: any dotted `tui.*` key (allowlisted or not) already defines the
// implicit tui table, so a new `[tui]` table at EOF would duplicate it.
const parsed = parseTomlKeyPath(line)
const isAssignment = parsed && line[parsed.end] === '='
if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) {
hasDottedTuiKey = true
lastDottedTuiIndex = index
const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null
if (promotedKey && updates.has(promotedKey)) {
dottedKeyIndexes.set(promotedKey, index)
}
const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
} else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') {
blocksNewTuiTable = true
}
} else if (tuiBodyActive) {
const parsed = parseTomlKeyPath(line)
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
bareKeyIndexes.set(key, index)
}
const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
}
}
state = updateTomlLineScanState(state, line)
}
if (tuiBodyActive) {
tuiBodyEndIndex = lines.length
}
return {
bareKeyIndexes,
dottedKeyIndexes,
hasBareTuiTable: tuiTableSeen,
hasDottedTuiKey,
blocksNewTuiTable,
blockedAbsentKeys,
bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex),
lastDottedTuiIndex
}
}
// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens,
// so absent keys land at the body's end — before trailing blanks and before the
// next header — which is the only valid spot.
function computeBareBodyInsertIndex(
lines: string[],
headerIndex: number,
endIndex: number
): number {
if (headerIndex === -1) {
return -1
}
let insertAt = endIndex
while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
return insertAt
}
function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void {
let appendAt = lines.length
while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') {
appendAt -= 1
}
// Why: separate the new table from prior content with a blank line, unless the
// file was empty/blank, where a leading blank would be spurious.
const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders]
lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf)))
}
function withTrailingCr(originalLine: string, rendered: string): string {
return originalLine.endsWith('\r') ? `${rendered}\r` : rendered
}
function withCrLine(rendered: string, usesCrlf: boolean): string {
return usesCrlf ? `${rendered}\r` : rendered
}
// Why: a missing trailing newline is restored in the file's own EOL so a
// preamble-only or table-appended rewrite matches the source's newline behavior.
function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string {
const result = lines.join('\n')
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return { ...actual, homedir: homedirMock }
})
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
let tmpHome: string
let userDataDir: string
let previousUserDataPath: string | undefined
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-upgrade-home-'))
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-upgrade-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
if (homedir() !== tmpHome) {
throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex')
}
})
afterEach(() => {
rmSync(tmpHome, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
vi.clearAllMocks()
})
function systemConfigPath(): string {
return join(tmpHome, '.codex', 'config.toml')
}
function runtimeHomePath(): string {
return join(userDataDir, 'codex-runtime-home', 'home')
}
function runtimeConfigPath(): string {
return join(runtimeHomePath(), 'config.toml')
}
function baselinePath(): string {
return join(runtimeHomePath(), '.orca-config-settings-baseline.json')
}
function prepareLegacyState(systemConfig: string, runtimeConfig: string): void {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
mkdirSync(runtimeHomePath(), { recursive: true })
writeFileSync(systemConfigPath(), systemConfig, 'utf-8')
writeFileSync(runtimeConfigPath(), runtimeConfig, 'utf-8')
writeFileSync(
baselinePath(),
`${JSON.stringify({ version: 1, settings: { model: '"gpt-5"' } }, null, 2)}\n`,
'utf-8'
)
}
function readBaseline(): {
version: number
settings: Record<string, string | null>
conflicts?: Record<string, { runtime: string | null; system: string | null }>
} {
return JSON.parse(readFileSync(baselinePath(), 'utf-8'))
}
describe('Codex settings baseline schema upgrade', () => {
it('upgrades an aligned legacy baseline without creating a conflict', () => {
const config = 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n'
prepareLegacyState(config, config)
syncSystemConfigIntoManagedCodexHome()
expect(readBaseline()).toMatchObject({
version: 2,
settings: { model: '"gpt-5"', 'tui.theme': '"dark"' }
})
expect(readBaseline().conflicts).toBeUndefined()
})
it('anchors a schema-new conflict while promoting an unrelated known key', () => {
prepareLegacyState(
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
'model = "o4"\n\n[tui]\ntheme = "runtime"\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('model = "o4"')
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "system"')
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime"')
expect(readBaseline().conflicts).toEqual({
'tui.theme': { runtime: '"runtime"', system: '"system"' }
})
})
it('promotes the runtime side when its anchored value changes', () => {
prepareLegacyState(
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
)
syncSystemConfigIntoManagedCodexHome()
writeFileSync(
runtimeConfigPath(),
readFileSync(runtimeConfigPath(), 'utf-8').replace('theme = "runtime"', 'theme = "chosen"'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "chosen"')
expect(readBaseline().conflicts).toBeUndefined()
expect(readBaseline().settings['tui.theme']).toBe('"chosen"')
})
it('accepts the system side when its anchored value changes', () => {
prepareLegacyState(
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
)
syncSystemConfigIntoManagedCodexHome()
writeFileSync(
systemConfigPath(),
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "outside"'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "outside"')
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "outside"')
expect(readBaseline().conflicts).toBeUndefined()
})
it('ignores unrelated config writes while a value pair is anchored', () => {
prepareLegacyState(
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
)
syncSystemConfigIntoManagedCodexHome()
writeFileSync(
runtimeConfigPath(),
`${readFileSync(runtimeConfigPath(), 'utf-8')}\n[projects."/tmp/repo"]\ntrust_level = "trusted"\n`,
'utf-8'
)
writeFileSync(
systemConfigPath(),
`${readFileSync(systemConfigPath(), 'utf-8')}\n[features]\nhooks = true\n`,
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "system"')
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime"')
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('[projects."/tmp/repo"]')
expect(readBaseline().conflicts).toEqual({
'tui.theme': { runtime: '"runtime"', system: '"system"' }
})
})
it('re-anchors two new divergent values until one side changes again', () => {
prepareLegacyState(
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
)
syncSystemConfigIntoManagedCodexHome()
writeFileSync(
runtimeConfigPath(),
readFileSync(runtimeConfigPath(), 'utf-8').replace(
'theme = "runtime"',
'theme = "runtime-2"'
),
'utf-8'
)
writeFileSync(
systemConfigPath(),
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "system-2"'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
expect(readBaseline().conflicts).toEqual({
'tui.theme': { runtime: '"runtime-2"', system: '"system-2"' }
})
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime-2"')
})
it('preserves an absent runtime value until the user chooses one', () => {
prepareLegacyState('model = "gpt-5"\n\n[tui]\ntheme = "system"\n', 'model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme =')
expect(readBaseline().conflicts).toEqual({
'tui.theme': { runtime: null, system: '"system"' }
})
writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n\n[tui]\ntheme = "chosen"\n', 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
expect(readBaseline().conflicts).toBeUndefined()
})
it('applies the migration rule to future top-level schema additions', () => {
prepareLegacyState(
'model = "gpt-5"\napproval_policy = "never"\n',
'model = "gpt-5"\napproval_policy = "on-request"\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('approval_policy = "never"')
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('approval_policy = "on-request"')
expect(readBaseline().conflicts).toEqual({
approval_policy: { runtime: '"on-request"', system: '"never"' }
})
})
it('lets an incompatible system TOML shape win instead of stranding a conflict', () => {
prepareLegacyState(
'model = "gpt-5"\ntui = { animations = false }\n',
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('tui = { animations = false }')
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme = "runtime"')
expect(readBaseline().conflicts).toBeUndefined()
expect(readBaseline().settings['tui.theme']).toBeNull()
})
it('does not require filesystem timestamp mutation during migration', () => {
prepareLegacyState(
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
)
const baselineBefore = readFileSync(baselinePath(), 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(existsSync(baselinePath())).toBe(true)
expect(readFileSync(baselinePath(), 'utf-8')).not.toBe(baselineBefore)
expect(readBaseline().conflicts?.['tui.theme']).toBeDefined()
})
})
@@ -0,0 +1,89 @@
import { existsSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader'
const SETTINGS_BASELINE_FILE = '.orca-config-settings-baseline.json'
export type CodexSettingsConflict = {
runtime: string | null
system: string | null
}
export type CodexSettingsBaseline = {
settings: ReadonlyMap<string, string | null>
conflicts: ReadonlyMap<string, CodexSettingsConflict>
}
type StoredSettingsBaseline = {
version: 1 | 2
settings: Record<string, string | null>
conflicts?: Record<string, CodexSettingsConflict>
}
export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSettingsBaseline | null {
const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath)
if (!existsSync(baselinePath)) {
return null
}
try {
const parsed: unknown = readAgentStateJsonFileSync(baselinePath)
if (!isStoredSettingsBaseline(parsed)) {
return null
}
const settings = new Map(
Object.entries(parsed.settings).filter((entry): entry is [string, string | null] => {
return typeof entry[1] === 'string' || entry[1] === null
})
)
const conflicts = new Map<string, CodexSettingsConflict>()
for (const [key, conflict] of Object.entries(parsed.conflicts ?? {})) {
if (
conflict &&
(typeof conflict.runtime === 'string' || conflict.runtime === null) &&
(typeof conflict.system === 'string' || conflict.system === null)
) {
conflicts.set(key, conflict)
}
}
return { settings, conflicts }
} catch {
return null
}
}
export function writeCodexSettingsBaseline(
runtimeHomePath: string,
baseline: CodexSettingsBaseline
): void {
const file: StoredSettingsBaseline = {
version: 2,
settings: Object.fromEntries(baseline.settings)
}
if (baseline.conflicts.size > 0) {
file.conflicts = Object.fromEntries(baseline.conflicts)
}
const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath)
const serialized = `${JSON.stringify(file, null, 2)}\n`
// Why: launch prep runs repeatedly; byte-identical baselines should not churn disk metadata.
if (existsSync(baselinePath) && readAgentStateFileSync(baselinePath) === serialized) {
return
}
writeFileSync(baselinePath, serialized, { encoding: 'utf-8', mode: 0o600 })
}
function getCodexSettingsBaselinePath(runtimeHomePath: string): string {
return join(runtimeHomePath, SETTINGS_BASELINE_FILE)
}
function isStoredSettingsBaseline(value: unknown): value is StoredSettingsBaseline {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const candidate = value as Partial<StoredSettingsBaseline>
return (
(candidate.version === 1 || candidate.version === 2) &&
!!candidate.settings &&
typeof candidate.settings === 'object' &&
!Array.isArray(candidate.settings)
)
}
@@ -0,0 +1,35 @@
import type { CodexSettingsConflict } from './config-settings-baseline'
export type CodexSettingsConflictResolution =
| { action: 'aligned' }
| { action: 'preserve'; conflict: CodexSettingsConflict }
| { action: 'promote-runtime'; raw: string }
| { action: 'use-system' }
export function resolveUntrackedCodexSetting(
runtime: string | null,
system: string | null,
existingConflict?: CodexSettingsConflict
): CodexSettingsConflictResolution {
if (runtime === system) {
return { action: 'aligned' }
}
if (!existingConflict) {
return { action: 'preserve', conflict: { runtime, system } }
}
const runtimeChanged = runtime !== existingConflict.runtime
const systemChanged = system !== existingConflict.system
if (runtimeChanged && !systemChanged) {
// Why: steady-state promotion intentionally does not propagate deletions.
return runtime === null ? { action: 'use-system' } : { action: 'promote-runtime', raw: runtime }
}
if (!runtimeChanged && systemChanged) {
return { action: 'use-system' }
}
if (runtimeChanged && systemChanged) {
// Why: two new divergent values remain ambiguous; re-anchor their content without blocking other keys.
return { action: 'preserve', conflict: { runtime, system } }
}
return { action: 'preserve', conflict: existingConflict }
}
@@ -19,7 +19,7 @@ import type * as CodexFsUtils from '../codex-accounts/fs-utils'
const { homedirMock, promotionTestState } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>(),
promotionTestState: { failAtomicWrite: false }
promotionTestState: { failAtomicWrite: false, atomicWritePaths: [] as string[] }
}))
vi.mock('node:os', async (importOriginal) => {
@@ -35,6 +35,7 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
return {
...actual,
writeFileAtomically: (...args: Parameters<typeof actual.writeFileAtomically>) => {
promotionTestState.atomicWritePaths.push(args[0])
if (promotionTestState.failAtomicWrite) {
throw new Error('injected atomic write failure')
}
@@ -44,7 +45,15 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
})
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
import {
upsertPromotedSettingsInContent,
upsertTopLevelSettingsInContent
} from './codex-config-settings-upsert'
// The exact [tui] block codex 0.144.6 writes via config/batchWrite (all four
// promoted keys single-line, theme a string).
const CODEX_TUI_BLOCK =
'[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n'
let tmpHome: string
let userDataDir: string
@@ -57,6 +66,7 @@ beforeEach(() => {
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
promotionTestState.failAtomicWrite = false
promotionTestState.atomicWritePaths.length = 0
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
// intercept, these tests would rewrite the developer's real Codex config.
if (homedir() !== tmpHome) {
@@ -117,6 +127,13 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void {
writeFileSync(runtimeConfigPath(), next, 'utf-8')
}
// Codex reads then rewrites the whole runtime config; simulate that by writing
// a known runtime config directly (its EOL is normalized by the mirror anyway).
function setRuntimeConfig(content: string): void {
mkdirSync(runtimeHomeDir(), { recursive: true })
writeFileSync(runtimeConfigPath(), content, 'utf-8')
}
function simulateCodexSettingRemoval(key: string): void {
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
@@ -184,7 +201,7 @@ describe('codex settings write-back promotion', () => {
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 1 })
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 })
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
@@ -402,6 +419,363 @@ describe('codex settings write-back promotion', () => {
})
})
describe('codex [tui] settings write-back promotion', () => {
it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
// The user customizes the status line/theme inside Orca-launched Codex.
writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`)
const runtime = readRuntimeConfig()
expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]')
expect(runtime).toContain('status_line_use_colors = true')
expect(runtime).toContain('terminal_title = ["model"]')
expect(runtime).toContain('theme = "dark-photon"')
const settledSystem = readSystemConfig()
const settledRuntime = readRuntimeConfig()
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(settledSystem)
expect(readRuntimeConfig()).toBe(settledRuntime)
})
it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n'
)
})
it('promotes a changed status_line array value', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
)
})
it('promotes a model change and a status-line change in one pass into their regions', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n'
)
})
it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => {
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
// toml_edit preserves the dotted form when codex rewrites the value.
setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n')
expect(readSystemConfig()).not.toContain('[tui]')
})
it('promotes through a quoted tui table without creating a duplicate table', () => {
writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
expect(readSystemConfig()).not.toContain('\n[tui]\n')
})
it('inserts a second dotted tui key beside an existing dotted-only tui config', () => {
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n'
)
expect(readSystemConfig()).not.toContain('[tui]')
})
it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => {
// Why: any dotted tui.* key already defines the implicit tui table, so a
// fresh [tui] table at EOF would be a duplicate-definition parse error.
writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
expect(readSystemConfig()).not.toContain('[tui]')
})
it('creates a [tui] table at EOF when the only tui presence is a subtable', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig(
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
)
})
it('creates exactly one [tui] table for two keys promoted in one pass', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system.match(/^\[tui\]$/gm)?.length).toBe(1)
expect(system).toBe(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\ntheme = "dark-photon"\n'
)
})
it('lets an outside ~/.codex [tui] edit win over a conflicting in-Codex tui change', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "in-codex"\n')
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
expect(readRuntimeConfig()).toContain('theme = "outside-edit"')
})
it('does not promote a [tui] key deletion', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toContain('theme = "dark"')
})
it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => {
writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system).toContain('status_line = ["model"]\r\n')
expect(system).toBe(
'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n'
)
})
it('never appends a [tui] table when the system config defines tui inline', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
// In-Codex tui change racing an outside edit that adds an inline tui table:
// appending [tui] would make the system config unparseable, so the change
// is dropped instead.
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n')
promotionTestState.atomicWritePaths.length = 0
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n')
expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath())
})
it('ignores an allowlisted key nested under a [tui.*] subtable', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n'
)
})
})
describe('upsertPromotedSettingsInContent', () => {
it('replaces a bare key in place inside the [tui] table', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\ntheme = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('[tui]\ntheme = "light"\n')
})
it('inserts a bare key at the end of the [tui] body, before a subtable', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\ntheme = "dark"\n\n[tui.notifications]\nenabled = true\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe(
'[tui]\ntheme = "dark"\nstatus_line = ["model"]\n\n[tui.notifications]\nenabled = true\n'
)
})
it('replaces a dotted preamble tui key in place, keeping the dotted form', () => {
expect(
upsertPromotedSettingsInContent('tui.theme = "dark"\n', new Map([['tui.theme', '"light"']]))
).toBe('tui.theme = "light"\n')
})
it('inserts a dotted tui key beside an existing dotted tui key', () => {
expect(
upsertPromotedSettingsInContent(
'tui.theme = "dark"\n\n[features]\nx = 1\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe('tui.theme = "dark"\ntui.status_line = ["model"]\n\n[features]\nx = 1\n')
})
it('creates a [tui] table from empty content', () => {
expect(upsertPromotedSettingsInContent('', new Map([['tui.theme', '"dark"']]))).toBe(
'[tui]\ntheme = "dark"\n'
)
})
it('drops an absent key instead of appending [tui] beside an inline tui table', () => {
expect(
upsertPromotedSettingsInContent(
'tui = { animations = false }\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('tui = { animations = false }\n')
})
it('drops an absent key beside a quoted inline tui table', () => {
expect(
upsertPromotedSettingsInContent(
'"tui" = { animations = false }\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('"tui" = { animations = false }\n')
})
it('inserts beside a quoted dotted tui key instead of appending a table', () => {
expect(
upsertPromotedSettingsInContent('"tui" . "pet" = "cat"\n', new Map([['tui.theme', '"dark"']]))
).toBe('"tui" . "pet" = "cat"\ntui.theme = "dark"\n')
})
it('creates a [tui] super-table at EOF after a [tui.*] subtable', () => {
expect(
upsertPromotedSettingsInContent(
'[tui.notifications]\nenabled = true\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('[tui.notifications]\nenabled = true\n\n[tui]\ntheme = "dark"\n')
})
it('drops an absent scalar that would redefine an existing tui key table', () => {
expect(
upsertPromotedSettingsInContent(
'[tui."theme"]\nvariant = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('[tui."theme"]\nvariant = "dark"\n')
})
it('drops an absent scalar that would redefine a dotted tui key table', () => {
expect(
upsertPromotedSettingsInContent(
'tui.theme.variant = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('tui.theme.variant = "dark"\n')
})
it('does not mistake a dotted tui key inside an array table for a root key', () => {
expect(
upsertPromotedSettingsInContent(
'[[profiles]]\ntui.theme = "profile-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n')
})
it('does not append a table beside a root tui array-of-tables', () => {
expect(
upsertPromotedSettingsInContent(
'[[tui]]\ntheme = "array-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[[tui]]\ntheme = "array-theme"\n')
})
it('does not append a table beside a quoted root tui array-of-tables', () => {
expect(
upsertPromotedSettingsInContent(
'[["tui"]]\ntheme = "array-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[["tui"]]\ntheme = "array-theme"\n')
})
it('creates one [tui] table for multiple keys reaching the new-table branch', () => {
expect(
upsertPromotedSettingsInContent(
'',
new Map([
['tui.status_line', '["model"]'],
['tui.theme', '"dark"']
])
)
).toBe('[tui]\nstatus_line = ["model"]\ntheme = "dark"\n')
})
it('routes a mixed top-level + tui batch to its two regions in one rewrite', () => {
expect(
upsertPromotedSettingsInContent(
'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n',
new Map([
['model', '"o4"'],
['tui.theme', '"light"']
])
)
).toBe('model = "o4"\n\n[tui]\ntheme = "light"\n')
})
it('inserts into a CRLF [tui] table with CRLF endings', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\r\ntheme = "dark"\r\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe('[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n')
})
})
describe('upsertTopLevelSettingsInContent', () => {
it('writes into empty content', () => {
expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n')
@@ -428,6 +802,12 @@ describe('upsertTopLevelSettingsInContent', () => {
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
})
it('replaces a quoted top-level key instead of adding its bare equivalent', () => {
expect(
upsertTopLevelSettingsInContent('"model" = "old"\n', new Map([['model', '"new"']]))
).toBe('model = "new"\n')
})
it('inserts with CRLF endings into CRLF content', () => {
expect(
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
+193 -140
View File
@@ -8,7 +8,7 @@ import {
writeFileSync
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader'
import { readAgentStateFileSync } from '../agent-state-file-reader'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
@@ -18,6 +18,15 @@ import {
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
import {
readCodexSettingsBaseline,
writeCodexSettingsBaseline,
type CodexSettingsBaseline,
type CodexSettingsConflict
} from './config-settings-baseline'
import { resolveUntrackedCodexSetting } from './config-settings-conflict-resolution'
// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline.
@@ -29,65 +38,112 @@ export const PROMOTED_CODEX_SETTING_KEYS = [
'sandbox_mode'
] as const
// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line,
// terminal title, theme). Like the top-level list, every key here gets written
// into the user's real ~/.codex/config.toml on promotion — grow it deliberately.
export const PROMOTED_CODEX_TUI_SETTING_KEYS = [
'status_line',
'status_line_use_colors',
'terminal_title',
'theme'
] as const
// Why: promotion diffs and upserts operate on structured keys — top-level keys
// keep their bare name, [tui] keys are namespaced tui.<key> so their baseline
// entries cannot collide with a top-level key of the same name.
const PROMOTED_STRUCTURED_KEYS: readonly string[] = [
...PROMOTED_CODEX_SETTING_KEYS,
...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey)
]
function isPromotedTuiKey(key: string): boolean {
return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key)
}
// Returns the structured tui key a scanned line's key represents, or null. In
// the preamble it recognizes the dotted `tui.<key>` form a user may hand-author;
// inside the first `[tui]` table body it recognizes the bare `<key>` form Codex
// writes. Both map to the same structured key so either config shape promotes.
function matchTuiStructuredKey(
keyPath: string[],
inPreamble: boolean,
tuiBodyActive: boolean
): string | null {
if (inPreamble) {
const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null
return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
const tuiKey = keyPath.length === 1 ? keyPath[0] : null
return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
type TopLevelSettingValue = {
raw: string
// Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion.
multiline: boolean
}
type SettingsBaselineFile = {
version: 1
settings: Record<string, string>
}
function getSettingsBaselinePath(runtimeHomePath: string): string {
return join(runtimeHomePath, '.orca-config-settings-baseline.json')
}
function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | null {
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
if (!existsSync(baselinePath)) {
function matchPromotedStructuredKey(
line: string,
inPreamble: boolean,
tuiBodyActive: boolean
): { structuredKey: string; raw: string } | null {
const parsed = parseTomlKeyPath(line)
if (!parsed || line[parsed.end] !== '=') {
return null
}
try {
const parsed = readAgentStateJsonFileSync(baselinePath)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const settings = (parsed as SettingsBaselineFile).settings
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
return null
}
const result = new Map<string, string>()
for (const [key, value] of Object.entries(settings)) {
if (typeof value === 'string') {
result.set(key, value)
}
}
return result
} catch {
return null
const raw = line.slice(parsed.end + 1).trim()
const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null
if (
inPreamble &&
topLevelKey &&
(PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey)
) {
return { structuredKey: topLevelKey, raw }
}
const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
return tuiKey ? { structuredKey: tuiKey, raw } : null
}
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
// Why: top-level preamble scalars keep the historical behavior; [tui] keys are
// collected from the first bare [tui] table body or the dotted preamble form,
// keyed by structured path. Any table header (including [tui.*] subtables) ends
// the [tui] body, and [profiles.*]/other tables are still ignored.
function readPromotedSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
if (!existsSync(configPath)) {
return result
}
const lines = readAgentStateFileSync(configPath).split('\n')
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
for (const line of lines) {
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
break
const header = getTomlTableHeader(line)
if (header) {
const table = parseTomlTableHeaderPath(header)
tuiBodyActive =
table !== null &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
if (tuiBodyActive) {
tuiTableSeen = true
}
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line)
const key = match?.[1]
if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) {
const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive)
if (matched) {
const nextState = updateTomlLineScanState(state, line)
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
result.set(matched.structuredKey, {
raw: matched.raw,
multiline: !isTomlStructuralLine(nextState)
})
state = nextState
continue
}
@@ -103,28 +159,22 @@ function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSett
* Call after a successful mirror only — advancing past an unpromoted change strands it forever.
*/
export function snapshotCodexRuntimeSettingsBaseline(
runtimeHomePath = getOrcaManagedCodexHomePath()
runtimeHomePath = getOrcaManagedCodexHomePath(),
conflicts: ReadonlyMap<string, CodexSettingsConflict> = new Map()
): void {
try {
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes.
const settings: Record<string, string> = {}
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
if (!value.multiline) {
settings[key] = value.raw
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
const settings = new Map<string, string | null>()
for (const key of PROMOTED_STRUCTURED_KEYS) {
const value = runtimeValues.get(key)
if (!conflicts.has(key) && !value?.multiline) {
// Why: explicit nulls distinguish a schema-aware absence from a key added by a later schema.
settings.set(key, value?.raw ?? null)
}
}
const file: SettingsBaselineFile = { version: 1, settings }
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
const serialized = `${JSON.stringify(file, null, 2)}\n`
// Why: launch prep runs repeatedly; skip byte-identical rewrites to avoid needless disk writes.
if (existsSync(baselinePath) && readAgentStateFileSync(baselinePath) === serialized) {
return
}
writeFileSync(baselinePath, serialized, {
encoding: 'utf-8',
mode: 0o600
})
writeCodexSettingsBaseline(runtimeHomePath, { settings, conflicts })
} catch (error) {
console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error)
}
@@ -135,6 +185,11 @@ export type CodexSettingsPromotionHomes = {
systemHomePath: string
}
export type CodexSettingsPromotionPlan = {
conflicts: ReadonlyMap<string, CodexSettingsConflict>
runtimeValuesToPreserve: ReadonlyMap<string, string | null>
}
function getHostPromotionHomes(): CodexSettingsPromotionHomes {
return {
runtimeHomePath: getOrcaManagedCodexHomePath(),
@@ -147,56 +202,50 @@ function getHostPromotionHomes(): CodexSettingsPromotionHomes {
* Runs before the config mirror so promoted values survive it instead of reverting.
* WSL callers pass explicit per-distro homes; default is the host runtime home and ~/.codex.
*/
export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean {
export function promoteCodexRuntimeSettingsToSystem(
homes?: CodexSettingsPromotionHomes
): CodexSettingsPromotionPlan | null {
try {
promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
return true
return promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
} catch (error) {
// Why: promotion is best-effort launch prep; a malformed file must not block Codex launch.
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
return false
return null
}
}
function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotionHomes): void {
function promoteCodexRuntimeSettingsToSystemUnsafe(
homes: CodexSettingsPromotionHomes
): CodexSettingsPromotionPlan {
const { runtimeHomePath, systemHomePath } = homes
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
const systemTomlPath = join(systemHomePath, 'config.toml')
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
return
return emptyPromotionPlan()
}
if (!existsSync(runtimeTomlPath)) {
return
return emptyPromotionPlan()
}
// Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one.
const baseline = readSettingsBaseline(runtimeHomePath)
const baseline = readCodexSettingsBaseline(runtimeHomePath)
if (!baseline) {
return
return emptyPromotionPlan()
}
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
const systemValues = readTopLevelSettingValues(systemTomlPath)
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
const systemValues = readPromotedSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
const runtime = runtimeValues.get(key)
if (!runtime || runtime.multiline) {
continue
}
if (runtime.raw === baseline.get(key)) {
// Orca mirrored this value and nothing touched it since — not a change.
continue
}
const system = systemValues.get(key)
if (system?.multiline) {
continue
}
// Why: ~/.codex is source of truth — an outside edit since the baseline wins over the in-Codex change.
if (system?.raw !== baseline.get(key)) {
continue
}
updates.set(key, runtime.raw)
}
const conflicts = new Map<string, CodexSettingsConflict>()
const runtimeValuesToPreserve = new Map<string, string | null>()
collectPromotionChanges({
baseline,
runtimeValues,
systemValues,
updates,
conflicts,
runtimeValuesToPreserve
})
if (updates.size === 0) {
return
return { conflicts, runtimeValuesToPreserve }
}
// Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it.
mkdirSync(systemHomePath, { recursive: true, mode: 0o700 })
@@ -205,15 +254,71 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
const targetExists = existsSync(writeTarget.path)
const systemContent = targetExists ? readAgentStateFileSync(writeTarget.path) : ''
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
const nextContent = upsertPromotedSettingsInContent(systemContent, updates)
if (nextContent === systemContent) {
return { conflicts, runtimeValuesToPreserve }
}
if (targetExists && parseWslUncPath(writeTarget.path)) {
// Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode.
writeFileSync(writeTarget.path, nextContent, 'utf-8')
return
return { conflicts, runtimeValuesToPreserve }
}
writeFileAtomically(writeTarget.path, nextContent, {
mode: writeTarget.mode
})
return { conflicts, runtimeValuesToPreserve }
}
type PromotionCollectionContext = {
baseline: CodexSettingsBaseline
runtimeValues: ReadonlyMap<string, TopLevelSettingValue>
systemValues: ReadonlyMap<string, TopLevelSettingValue>
updates: Map<string, string>
conflicts: Map<string, CodexSettingsConflict>
runtimeValuesToPreserve: Map<string, string | null>
}
function collectPromotionChanges(context: PromotionCollectionContext): void {
for (const key of PROMOTED_STRUCTURED_KEYS) {
const runtimeRaw = getComparableRaw(context.runtimeValues.get(key))
const systemRaw = getComparableRaw(context.systemValues.get(key))
if (runtimeRaw === undefined || systemRaw === undefined) {
continue
}
const existingConflict = context.baseline.conflicts.get(key)
if (existingConflict || !context.baseline.settings.has(key)) {
const resolution = resolveUntrackedCodexSetting(runtimeRaw, systemRaw, existingConflict)
if (resolution.action === 'promote-runtime') {
context.updates.set(key, resolution.raw)
} else if (resolution.action === 'preserve') {
// Why: a schema-new key has no three-way ancestor; preserve both values until content changes one side.
context.conflicts.set(key, resolution.conflict)
context.runtimeValuesToPreserve.set(key, runtimeRaw)
}
continue
}
if (runtimeRaw === null || runtimeRaw === context.baseline.settings.get(key)) {
continue
}
// Why: ~/.codex remains source of truth when both sides changed from a known baseline.
if (systemRaw !== context.baseline.settings.get(key)) {
continue
}
context.updates.set(key, runtimeRaw)
}
}
function getComparableRaw(value: TopLevelSettingValue | undefined): string | null | undefined {
if (!value) {
return null
}
return value.multiline ? undefined : value.raw
}
function emptyPromotionPlan(): CodexSettingsPromotionPlan {
return { conflicts: new Map(), runtimeValuesToPreserve: new Map() }
}
// Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config.
@@ -252,55 +357,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string {
// Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead.
throw new Error(`Codex config symlink cycle at ${linkPath}`)
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line)
if (match?.[1] && updates.has(match[1])) {
keyLineIndexes.set(match[1], index)
}
}
state = updateTomlLineScanState(state, line)
}
// Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
+67
View File
@@ -0,0 +1,67 @@
import { parseTomlSingleLineStringValue } from './config-toml-line-scan'
export type ParsedTomlKeyPath = {
segments: string[]
end: number
}
export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & {
isArray: boolean
}
export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null {
const trimmed = header.trim()
let source: string
let isArray: boolean
if (trimmed.startsWith('[[')) {
if (!trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(2, -2)
isArray = true
} else {
if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(1, -1)
isArray = false
}
const parsed = parseTomlKeyPath(source)
if (!parsed || parsed.end !== source.length) {
return null
}
return { ...parsed, isArray }
}
export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null {
const segments: string[] = []
let index = skipTomlKeyWhitespace(source, offset)
while (index < source.length) {
const quoted = parseTomlSingleLineStringValue(source, index)
if (quoted) {
segments.push(quoted.value)
index = quoted.end
} else {
const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index))
if (!bare) {
return null
}
segments.push(bare[0])
index += bare[0].length
}
index = skipTomlKeyWhitespace(source, index)
if (source[index] !== '.') {
return { segments, end: index }
}
index = skipTomlKeyWhitespace(source, index + 1)
}
return null
}
function skipTomlKeyWhitespace(source: string, offset: number): number {
let index = offset
while (source[index] === ' ' || source[index] === '\t') {
index += 1
}
return index
}
+55 -1
View File
@@ -1674,6 +1674,14 @@ describe('registerPtyHandlers', () => {
}
describe('spawn environment', () => {
it('publishes a lifecycle signal after a successful renderer spawn', async () => {
await spawnAndGetEnv()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
id: expect.any(String)
})
})
it('marks local Claude launches live until the PTY is killed', async () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
spawnMock.mockReturnValue({
@@ -5472,6 +5480,48 @@ describe('registerPtyHandlers', () => {
})
})
it('starts local and SSH session inventories concurrently', async () => {
let resolveLocal!: (sessions: { id: string; cwd: string; title: string }[]) => void
const localSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
resolveLocal = resolve
})
vi.spyOn(getLocalPtyProvider(), 'listProcesses').mockReturnValue(localSessions)
registerPtyHandlers(mainWindow as never)
let resolveSsh!: (sessions: { id: string; cwd: string; title: string }[]) => void
const sshSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
resolveSsh = resolve
})
const sshListProcesses = vi.fn(() => sshSessions)
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: sshListProcesses,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const pendingInventory = handlers.get('pty:listSessions')!(null, undefined)
expect(sshListProcesses).toHaveBeenCalledTimes(1)
resolveLocal([])
resolveSsh([])
await pendingInventory
})
it('reports authoritative snapshot capability with the owning provider context', () => {
const capabilityProvider = {
authoritativeIds: new Set(['current-pty']),
@@ -6479,7 +6529,7 @@ describe('registerPtyHandlers', () => {
registerPtyHandlers(mainWindow as never, runtime as never)
expect(controller).not.toBeNull()
const spawnController = controller as unknown as RuntimeSpawnController
await spawnController.spawn({
const spawned = await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
@@ -6494,6 +6544,9 @@ describe('registerPtyHandlers', () => {
expect.any(String),
'term_expected'
)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
id: spawned.id
})
})
it('does not update cached PTY size when runtime controller resize fails', async () => {
@@ -10265,6 +10318,7 @@ describe('registerPtyHandlers', () => {
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
mainWindow.webContents.send.mockClear()
const pendingOutput = 'x'.repeat(1020)
mockProc.emitData(pendingOutput)
+10
View File
@@ -2709,6 +2709,12 @@ export function registerPtyHandlers(
})
}
function sendPtySpawnedToRenderer(id: string): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:spawned', { id })
}
}
function sendPtyReplayToRenderer(payload: { id: string; data: string }): void {
const pending = pendingData.get(payload.id)
if (pending) {
@@ -3771,6 +3777,8 @@ export function registerPtyHandlers(
: null
})
}
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
sendPtySpawnedToRenderer(result.id)
const response = {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
@@ -4943,6 +4951,8 @@ export function registerPtyHandlers(
// Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped.
...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {})
}
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
sendPtySpawnedToRenderer(result.id)
return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response)
} catch (err) {
if (pendingRegistrationPtyId) {
+1
View File
@@ -1458,6 +1458,7 @@ export type PreloadApi = {
onExit: (
callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void
) => () => void
onSpawned: (callback: (data: { id: string }) => void) => () => void
onSerializeBufferRequest: (
callback: (data: {
requestId: string
+6
View File
@@ -1064,6 +1064,12 @@ const api = {
return () => ipcRenderer.removeListener('pty:exit', listener)
},
onSpawned: (callback: (data: { id: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data)
ipcRenderer.on('pty:spawned', listener)
return () => ipcRenderer.removeListener('pty:spawned', listener)
},
onSerializeBufferRequest: (
callback: (data: {
requestId: string
+60 -37
View File
@@ -42,6 +42,7 @@ import {
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { useAppStore } from './store'
import { WORKTREE_REFRESH_CONCURRENCY } from './store/slices/worktrees'
import { useShallow } from 'zustand/react/shallow'
import { isRemoteWorkspaceSnapshotApplyInProgress, useIpcEvents } from './hooks/useIpcEvents'
import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents'
@@ -131,7 +132,10 @@ import {
createShutdownCheckpointBeforeUnloadHandler,
createShutdownCheckpointGuard
} from './lib/shutdown-checkpoint-guard'
import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys'
import {
collectFolderWorkspaceKeysFromSession,
collectWorktreeHydrationRepoIdsFromSession
} from './lib/workspace-session-hydration-keys'
import {
getStartupErrorFallbackUI,
hydratePersistedUIAfterStartupRead
@@ -178,10 +182,13 @@ import {
type PhysicalModifierToken
} from '../../shared/keybindings'
import {
getRepoExecutionHostId,
isRuntimeOwnedSshTargetId,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
@@ -430,6 +437,7 @@ function App(): React.JSX.Element {
fetchFolderWorkspaces: s.fetchFolderWorkspaces,
fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktrees: s.fetchWorktrees,
fetchWorktreeLineage: s.fetchWorktreeLineage,
fetchOrcaProfiles: s.fetchOrcaProfiles,
fetchSettings: s.fetchSettings,
@@ -872,8 +880,7 @@ function App(): React.JSX.Element {
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
)
// Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts),
// so keep this two-step catalog chain internally ordered; it is otherwise independent of
// repos/worktrees/session and overlaps the worktree scan below.
// so keep this chain ordered while overlapping it with session-scoped hydration.
const localCatalogChain = (async () => {
await timeRendererStartupStep('fetch-project-groups-local', () =>
actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
@@ -882,10 +889,6 @@ function App(): React.JSX.Element {
actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
)
})()
// Why: chain session-get off runtimeHostsPromise instead of awaiting the host ids here, so
// fetch-worktrees and the catalog chain start immediately (neither needs the ids) — awaiting
// the host-list IPC first would re-serialize the worktree scan behind host discovery when the
// IPC is the slower of the two. Only session-get waits on the ids.
const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) =>
// Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip.
timeRendererStartupStep('session-get', () =>
@@ -896,27 +899,30 @@ function App(): React.JSX.Element {
)
)
)
// Why: once repos is loaded, fetch-worktrees (snapshots repos), session-get (repos-independent
// local disk read), and the local catalog chain are mutually independent — run them concurrently
// so the two disk reads hide behind the O(repos) worktree git scan (the startup long pole).
// fetchAllWorktrees({hydrationPurge:'defer'}) returns before its folderWorkspaces read (the purge
// guard in worktrees.ts), so it needs no catalog ordering here. session-get is a pure read;
// hydrate-session-stores below still runs only after all three settle. See #18.
// Why (#18 review): join on allSettled, NOT fail-fast Promise.all. A fast rejection from one branch
// would drop into the catch/recovery path (which reconnects terminals and flips readiness) while a
// sibling hydration task is still in flight and mutating catalog/worktree state — the old serial flow
// guaranteed no hydration step ran during recovery. Wait for all three to settle, then surface the
// first rejection so recovery still triggers, but only once nothing is left writing to the store.
const [worktreesOutcome, sessionOutcome, catalogOutcome] = await Promise.allSettled([
timeRendererStartupStep('fetch-worktrees', () =>
actions.fetchAllWorktrees({ hydrationPurge: 'defer' })
),
sessionReadPromise,
const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => {
const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession(
sessionRead.session,
sessionRead.runtimeHostIdByWorkspaceSessionKey
)
const hydrationRepoIdSet = new Set(hydrationRepoIds)
const hydrationRepos = useAppStore.getState().repos.filter(
(repo) =>
hydrationRepoIdSet.has(repo.id) &&
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
)
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) })
)
)
return sessionRead
})
// Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward.
const [sessionOutcome, catalogOutcome] = await Promise.allSettled([
hydrationSessionChain,
localCatalogChain
])
if (worktreesOutcome.status === 'rejected') {
throw worktreesOutcome.reason
}
if (sessionOutcome.status === 'rejected') {
throw sessionOutcome.reason
}
@@ -1042,19 +1048,34 @@ function App(): React.JSX.Element {
})
void (async () => {
try {
await timeRendererStartupStep('remote-catalog-refresh', async () => {
await actions.fetchReposForAllHosts()
await actions.fetchProjectGroupsForAllHosts()
await actions.fetchFolderWorkspacesForAllHosts()
})
if (!cancelled) {
await timeRendererStartupStep('remote-worktree-refresh', async () => {
await actions.fetchAllWorktrees()
await actions.fetchWorktreeLineage()
try {
await timeRendererStartupStep('remote-catalog-refresh', async () => {
await actions.fetchReposForAllHosts()
await actions.fetchProjectGroupsForAllHosts()
await actions.fetchFolderWorkspacesForAllHosts()
})
} catch (err) {
console.warn('Remote startup catalog refresh failed:', err)
}
if (!cancelled) {
try {
await timeRendererStartupStep('remote-worktree-refresh', async () => {
// Why: the full scan is not required for session recovery, so keep it off the startup-critical path.
await actions.fetchAllWorktrees()
// Why: the startup prune only saw session-referenced repos; use the deferred scan's
// authoritative results to drop deleted-worktree visit timestamps that would
// otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept).
actions.pruneLastVisitedTimestamps()
await actions.fetchWorktreeLineage()
})
} catch (err) {
console.warn('Deferred startup worktree refresh failed:', err)
}
}
} finally {
if (!cancelled) {
useAppStore.setState({ startupWorktreeRefreshCompleted: true })
}
} catch (err) {
console.warn('Remote startup catalog refresh failed:', err)
}
})()
}
@@ -1067,6 +1088,8 @@ function App(): React.JSX.Element {
error
)
if (!cancelled) {
// Why: degraded mode stays interactive; later repo/runtime changes must not remain gated forever.
useAppStore.setState({ startupWorktreeRefreshCompleted: true })
// Why (issue #1158): only apply default UI if ui.get() never hydrated; otherwise defaults would clobber ui.json via the debounced writer.
const fallbackUI = getStartupErrorFallbackUI(uiHydrated)
if (fallbackUI) {
+80 -30
View File
@@ -6,30 +6,31 @@ describe('renderer startup runtime routing', () => {
it('hydrates persisted UI before local catalog and worktree hydration', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const startupBlockStart = source.indexOf('void (async () => {')
// Why (#18): worktrees/session-get/catalog now run concurrently, so session-get is no longer the
// block terminator. End the slice at hydrate-session-stores (runs after the Promise.all settles) so
// every concurrent fetch falls inside the analyzed block.
// Why: concurrent startup branches all settle before hydrate-session-stores.
const startupBlockEnd = source.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'")
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
const settingsIndex = startupBlock.indexOf('actions.fetchSettings()')
const uiGetIndex = startupBlock.indexOf("timeRendererStartupStep('ui-get'")
const hydrateUiIndex = startupBlock.indexOf(
"timeRendererStartupSyncStep('hydrate-persisted-ui'"
)
const localReposIndex = startupBlock.indexOf(
const indexInStartupBlock = (needle: string): number => {
const relativeIndex = startupBlock.indexOf(needle)
return relativeIndex === -1 ? -1 : startupBlockStart + relativeIndex
}
const settingsIndex = indexInStartupBlock('actions.fetchSettings()')
const uiGetIndex = indexInStartupBlock("timeRendererStartupStep('ui-get'")
const hydrateUiIndex = indexInStartupBlock("timeRendererStartupSyncStep('hydrate-persisted-ui'")
const localReposIndex = indexInStartupBlock(
"actions.fetchReposForAllHosts({ remoteHosts: 'skip' })"
)
const localGroupsIndex = startupBlock.indexOf(
const localGroupsIndex = indexInStartupBlock(
"actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })"
)
const localFoldersIndex = startupBlock.indexOf(
const localFoldersIndex = indexInStartupBlock(
"actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })"
)
const sessionGetIndex = startupBlock.indexOf("timeRendererStartupStep('session-get'")
const localWorktreesIndex = startupBlock.indexOf(
"actions.fetchAllWorktrees({ hydrationPurge: 'defer' })"
const sessionIndex = indexInStartupBlock("timeRendererStartupStep('session-get'")
const hydrationWorktreesIndex = source.indexOf(
"timeRendererStartupStep('fetch-hydration-worktrees'"
)
const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()')
const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
expect(settingsIndex).toBeGreaterThanOrEqual(0)
@@ -41,26 +42,44 @@ describe('renderer startup runtime routing', () => {
// The local catalog chain stays internally ordered (folders merge against project groups).
expect(localReposIndex).toBeLessThan(localGroupsIndex)
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
// Worktree scan and session read both start only after repos is loaded (they snapshot/route on it),
// but run concurrently with the catalog chain — see the Promise.all assertion below.
expect(localReposIndex).toBeLessThan(sessionGetIndex)
expect(localReposIndex).toBeLessThan(localWorktreesIndex)
// Lineage is deferred to post-hydration remote refresh, not part of the local hydration block.
expect(localReposIndex).toBeLessThan(sessionIndex)
expect(sessionIndex).toBeLessThan(hydrationWorktreesIndex)
const hydrationWorktreeBlock = source.slice(
hydrationWorktreesIndex,
source.indexOf('await keybindingsPromise')
)
expect(hydrationWorktreeBlock).toContain(
'mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY'
)
expect(hydrationWorktreeBlock).toContain('executionHostId: getRepoExecutionHostId(repo)')
// Why: the pre-hydration fetch must include SSH repos (only runtime-owned repos are
// excluded); gating on local-only drops SSH tab/editor/browser chrome at hydration.
const hydrationFilterBlock = source.slice(
source.indexOf('const hydrationRepos'),
hydrationWorktreesIndex
)
expect(hydrationFilterBlock).toContain(
"parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'"
)
expect(hydrationFilterBlock).not.toContain('=== LOCAL_EXECUTION_HOST_ID')
expect(fullWorktreesIndex).toBeGreaterThan(
source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'")
)
// Why: the deferred full scan must be followed by a re-prune so deleted-worktree visit
// timestamps for non-session repos are dropped once every repo is authoritative.
expect(
source.indexOf('actions.pruneLastVisitedTimestamps()', fullWorktreesIndex)
).toBeGreaterThan(fullWorktreesIndex)
expect(lineageIndex).toBe(-1)
// Guard the concurrency itself: fetch-worktrees, the session read, and the local catalog chain must be
// joined in a single allSettled so the two disk reads hide behind the O(repos) worktree scan — AND so a
// fast rejection can't enter recovery while a sibling is still mutating the store (allSettled, not
// fail-fast Promise.all).
const joinStart = startupBlock.indexOf('await Promise.allSettled([')
// The catalog and selective hydration chains overlap, but both settle before recovery or hydration.
const joinStart = indexInStartupBlock('await Promise.allSettled([')
expect(joinStart).toBeGreaterThan(hydrateUiIndex)
const joinBlock = startupBlock.slice(joinStart)
expect(joinBlock).toContain("timeRendererStartupStep('fetch-worktrees'")
expect(joinBlock).toContain('sessionReadPromise')
const joinBlock = source.slice(joinStart, startupBlockEnd)
expect(joinBlock).toContain('hydrationSessionChain')
expect(joinBlock).toContain('localCatalogChain')
// The join must not be fail-fast: a bare `Promise.all([` on these branches would re-introduce the
// recovery-during-hydration race.
expect(startupBlock).not.toContain('await Promise.all([')
expect(startupBlock).not.toContain("actions.fetchAllWorktrees({ hydrationPurge: 'defer' })")
})
it('refreshes remote catalogs after startup hydration succeeds', () => {
@@ -70,15 +89,30 @@ describe('renderer startup runtime routing', () => {
)
const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'")
const remoteWorktreeIndex = source.indexOf("timeRendererStartupStep('remote-worktree-refresh'")
const remoteCatalogFailureIndex = source.indexOf(
"console.warn('Remote startup catalog refresh failed:'"
)
const lineageIndex = source.indexOf('actions.fetchWorktreeLineage()')
const startupRefreshCompletedIndex = source.indexOf('startupWorktreeRefreshCompleted: true')
expect(hydrationDoneIndex).toBeGreaterThanOrEqual(0)
expect(hydrationDoneIndex).toBeLessThan(remoteCatalogIndex)
expect(remoteCatalogIndex).toBeLessThan(remoteWorktreeIndex)
expect(remoteCatalogIndex).toBeLessThan(remoteCatalogFailureIndex)
// Why: a project-group/folder catalog failure must not suppress the independent full worktree scan.
expect(remoteCatalogFailureIndex).toBeLessThan(remoteWorktreeIndex)
expect(remoteWorktreeIndex).toBeLessThan(lineageIndex)
expect(lineageIndex).toBeLessThan(startupRefreshCompletedIndex)
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
'actions.fetchReposForAllHosts()'
)
const startupFailureIndex = source.indexOf(
'[startup] Workspace session hydration failed; leaving disk state untouched:'
)
expect(startupFailureIndex).toBeGreaterThanOrEqual(0)
expect(
source.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex)
).toBeGreaterThan(startupFailureIndex)
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
'actions.fetchProjectGroupsForAllHosts()'
)
@@ -96,6 +130,22 @@ describe('renderer startup runtime routing', () => {
expect(servicesIndex).toBeLessThan(reconnectIndex)
})
it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/automations/AutomationsPage.tsx'),
'utf8'
)
const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef')
const fullRefreshEffect = source.slice(
fullRefreshStart,
source.indexOf('void refresh()', fullRefreshStart)
)
expect(fullRefreshEffect).toContain('if (!startupWorktreeRefreshCompleted)')
expect(fullRefreshEffect).toContain('mountedBeforeStartupWorktreeRefreshRef.current')
expect(fullRefreshEffect).toContain('void fetchAllWorktrees()')
})
it('does not eagerly import the floating terminal panel on startup', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
@@ -368,6 +368,7 @@ export default function AutomationsPage(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const updateSettings = useAppStore((s) => s.updateSettings)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@@ -1051,10 +1052,22 @@ export default function AutomationsPage(): React.JSX.Element {
useAppStore.getState().hydratePersistedUI(await window.api.ui.get(), 'sync')
}, [])
const mountedBeforeStartupWorktreeRefreshRef = useRef(!startupWorktreeRefreshCompleted)
useEffect(() => {
if (!startupWorktreeRefreshCompleted) {
return
}
if (mountedBeforeStartupWorktreeRefreshRef.current) {
// Why: App just supplied this mount's initial worktrees; a second full scan would duplicate every repo probe.
mountedBeforeStartupWorktreeRefreshRef.current = false
return
}
void fetchAllWorktrees()
}, [fetchAllWorktrees, startupWorktreeRefreshCompleted])
useEffect(() => {
void refresh()
}, [fetchAllWorktrees, refresh])
}, [refresh])
useEffect(() => {
// Pause the relative-time clock while the window is hidden.
@@ -1,6 +1,10 @@
// @vitest-environment happy-dom
import type { CSSProperties, ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/types'
@@ -105,10 +109,18 @@ function renderSidebar(): string {
)
}
function sidebarElement(): ReactNode {
return (
<Sidebar worktreeScrollOffsetRef={{ current: 0 }} worktreeScrollAnchorRef={{ current: null }} />
)
}
afterEach(cleanup)
describe('Sidebar', () => {
it('applies left sidebar appearance variables to the workspace sidebar surface', () => {
setSidebarState({
...getDefaultSettings('/tmp'),
...getDefaultSettings(tmpdir()),
leftSidebarAppearanceMode: 'match-terminal',
terminalColorOverrides: {
background: '#101820',
@@ -125,11 +137,69 @@ describe('Sidebar', () => {
})
it('passes status bar visibility into the workspace board drawer', () => {
setSidebarState(getDefaultSettings('/tmp'), false)
setSidebarState(getDefaultSettings(tmpdir()), false)
const markup = renderSidebar()
expect(markup).toContain('data-testid="workspace-kanban-drawer"')
expect(markup).toContain('data-status-bar-visible="false"')
})
it('does not start a full worktree scan while the startup session is hydrating', () => {
setSidebarState(getDefaultSettings(tmpdir()))
const fetchAllWorktrees = vi.fn().mockResolvedValue(undefined)
mocks.state = {
...mocks.state,
fetchAllWorktrees,
repos: [],
startupWorktreeRefreshCompleted: false
}
const view = render(sidebarElement())
mocks.state = { ...mocks.state, repos: [{ id: 'repo-a' }] }
view.rerender(sidebarElement())
expect(fetchAllWorktrees).not.toHaveBeenCalled()
mocks.state = { ...mocks.state, startupWorktreeRefreshCompleted: true }
view.rerender(sidebarElement())
expect(fetchAllWorktrees).not.toHaveBeenCalled()
mocks.state = { ...mocks.state, repos: [{ id: 'repo-a' }, { id: 'repo-b' }] }
view.rerender(sidebarElement())
expect(fetchAllWorktrees).toHaveBeenCalledTimes(1)
})
it('does not scan when runtime hosts come online during the startup refresh', async () => {
setSidebarState(getDefaultSettings(tmpdir()))
const fetchAllWorktrees = vi.fn().mockResolvedValue(undefined)
mocks.state = {
...mocks.state,
fetchAllWorktrees,
fetchWorktreeLineage: vi.fn().mockResolvedValue(undefined),
runtimeStatusByEnvironmentId: new Map(),
startupWorktreeRefreshCompleted: false
}
const view = render(sidebarElement())
mocks.state = {
...mocks.state,
runtimeStatusByEnvironmentId: new Map([['runtime-a', { status: 'connected' }]])
}
view.rerender(sidebarElement())
expect(fetchAllWorktrees).not.toHaveBeenCalled()
mocks.state = { ...mocks.state, startupWorktreeRefreshCompleted: true }
view.rerender(sidebarElement())
expect(fetchAllWorktrees).not.toHaveBeenCalled()
mocks.state = {
...mocks.state,
runtimeStatusByEnvironmentId: new Map([
['runtime-a', { status: 'connected' }],
['runtime-b', { status: 'connected' }]
])
}
view.rerender(sidebarElement())
await waitFor(() => expect(fetchAllWorktrees).toHaveBeenCalledTimes(1))
})
})
+17 -14
View File
@@ -46,6 +46,7 @@ function Sidebar({
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
const setSidebarWidth = useAppStore((s) => s.setSidebarWidth)
const repos = useAppStore((s) => s.repos)
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const settings = useAppStore((s) => s.settings)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const activeModal = useAppStore((s) => s.activeModal)
@@ -74,13 +75,16 @@ function Sidebar({
document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`)
}, [])
// Fetch worktrees when repos are added/removed
const repoCount = repos.length
const previousRepoCountRef = React.useRef(repoCount)
useEffect(() => {
if (repoCount > 0) {
fetchAllWorktrees()
const repoCountChanged = previousRepoCountRef.current !== repoCount
previousRepoCountRef.current = repoCount
// Why: App owns the initial all-host scan; partial startup catalogs must not trigger broad scans or stale-state purges.
if (startupWorktreeRefreshCompleted && repoCountChanged && repoCount > 0) {
void fetchAllWorktrees()
}
}, [repoCount, fetchAllWorktrees])
}, [repoCount, startupWorktreeRefreshCompleted, fetchAllWorktrees])
// Why: a runtime host coming online/offline must refresh the sidebar so its
// worktrees appear/drop, the same way SSH state changes already refetch. Only
@@ -109,18 +113,17 @@ function Sidebar({
)
}
useEffect(() => {
// Skip the initial value — startup/repoCount effects already fetch. Only
// refetch when the online-host set actually changes.
if (previousOnlineRuntimeEnvKeyRef.current === null) {
previousOnlineRuntimeEnvKeyRef.current = onlineRuntimeEnvKey
return
}
if (previousOnlineRuntimeEnvKeyRef.current === onlineRuntimeEnvKey) {
return
}
const previousOnlineRuntimeEnvKey = previousOnlineRuntimeEnvKeyRef.current
previousOnlineRuntimeEnvKeyRef.current = onlineRuntimeEnvKey
if (
previousOnlineRuntimeEnvKey === null ||
previousOnlineRuntimeEnvKey === onlineRuntimeEnvKey ||
!startupWorktreeRefreshCompleted
) {
return
}
reconnectRefreshRef.current?.request()
}, [onlineRuntimeEnvKey])
}, [onlineRuntimeEnvKey, startupWorktreeRefreshCompleted])
useEffect(() => {
if (!sidebarOpen && workspaceBoardRenderedOpen) {
@@ -3,21 +3,50 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const SOURCE_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx')
const INVENTORY_HOOK_PATH = resolve(__dirname, 'use-resource-session-inventory.ts')
describe('ResourceUsageStatusSegment session polling', () => {
describe('ResourceUsageStatusSegment session inventory', () => {
it('does not poll global terminal sessions while the popover is closed', () => {
const source = readFileSync(SOURCE_PATH, 'utf8')
const inventoryHookSource = readFileSync(INVENTORY_HOOK_PATH, 'utf8')
expect(source).not.toContain('installWindowVisibilityInterval')
expect(source).not.toContain('SESSIONS_POLL_MS')
expect(source.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
expect(inventoryHookSource).not.toContain('setInterval')
// Why: every seed/action/lifecycle refresh shares one guarded inventory
// read, and the closed path never installs a polling interval.
expect(inventoryHookSource.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
const openEffectIndex = source.indexOf('if (!open)')
const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex)
// Why: pty.listSessions() is a global daemon inventory and can pause input
// with large preserved-session sets. Keep it on explicit Resource Manager use.
// with large preserved-session sets. Keep continuous use off the closed path.
expect(openEffectIndex).toBeGreaterThanOrEqual(0)
expect(refreshIndex).toBeGreaterThan(openEffectIndex)
})
it('seeds the closed badge from daemon inventory instead of wake-hint bound PTYs', () => {
const source = readFileSync(SOURCE_PATH, 'utf8')
const inventoryHookSource = readFileSync(INVENTORY_HOOK_PATH, 'utf8')
expect(source).toContain('useResourceSessionInventory')
expect(source).toContain('sessionInventory.count')
expect(inventoryHookSource).toContain('window.api.pty.onSpawned')
expect(inventoryHookSource).toContain('window.api.pty.onExit')
expect(source).not.toContain('createClosedResourceSessionCountSelector')
expect(source).not.toContain('boundPtyIds.size')
expect(source).not.toContain('closedSessionCount')
expect(source).not.toContain('livePtyIdsByTabId')
})
it('seeds memory snapshot for the closed badge without requiring a click', () => {
const source = readFileSync(SOURCE_PATH, 'utf8')
// Why: the ready-seed effect must call fetchSnapshot so RAM is not "—"
// until the user opens Resource Manager.
const readySeedBlock = source.slice(source.indexOf('// Why: seed RAM after session restore'))
expect(readySeedBlock).toContain('void fetchSnapshot()')
expect(readySeedBlock).toContain('workspaceSessionReady')
})
})
@@ -38,7 +38,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind'
import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup'
import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
import type {
DaemonSession,
Metric,
UnifiedProjectGroup,
UnifiedSessionRow,
@@ -72,11 +71,10 @@ import {
countUnboundDaemonSessions,
type ResourceSessionBindingInputs
} from './resource-session-bindings'
import { createClosedResourceSessionCountSelector } from './resource-session-count-selector'
import { useResourceSessionInventory } from './use-resource-session-inventory'
import { translate } from '@/i18n/i18n'
const POLL_MS = 2_000
const selectClosedResourceSessionCount = createClosedResourceSessionCountSelector()
type SortOption = 'memory' | 'cpu' | 'name'
@@ -733,7 +731,6 @@ export function ResourceUsageStatusSegment({
const memorySnapshotError = useAppStore((s) => s.memorySnapshotError)
const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const closedSessionCount = useAppStore(selectClosedResourceSessionCount)
const setActiveView = useAppStore((s) => s.setActiveView)
const openModal = useAppStore((s) => s.openModal)
const openSpacePage = useAppStore((s) => s.openSpacePage)
@@ -748,8 +745,15 @@ export function ResourceUsageStatusSegment({
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
const [collapsedWorktrees, setCollapsedWorktrees] = useState<Set<string>>(new Set())
const [appCollapsed, setAppCollapsed] = useState(true)
const [sessions, setSessions] = useState<DaemonSession[]>([])
const [sessionsError, setSessionsError] = useState(false)
const {
sessionInventory,
sessionsError,
refreshSessions,
clearSessionsError,
removeSession,
removeSessions
} = useResourceSessionInventory(workspaceSessionReady)
const sessions = sessionInventory.sessions
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
const [killing, setKilling] = useState(false)
const [spaceScanSnapshot, setSpaceScanSnapshot] = useState<ResourceUsageSpaceScanSnapshot>(
@@ -806,24 +810,9 @@ export function ResourceUsageStatusSegment({
[cancelPopoverBodyFocusFrame]
)
const refreshSessions = useCallback(async () => {
try {
const result = await window.api.pty.listSessions()
if (!mountedRef.current) {
return
}
setSessions(result)
setSessionsError(false)
} catch {
if (mountedRef.current) {
setSessionsError(true)
}
}
}, [mountedRef])
const daemonActions = useDaemonActions({
onRestartSettled: () => {
setSessionsError(false)
clearSessionsError()
void fetchSnapshot()
void refreshSessions()
},
@@ -850,7 +839,17 @@ export function ResourceUsageStatusSegment({
}
const spaceScanReady = nextSpaceScanSnapshot.ready
// Poll memory only while open; a closed badge must not inventory daemon PTYs (large preserved-session sets stall typing).
// Why: seed RAM after session restore so the closed chip does not require a
// click; the session-inventory hook independently seeds daemon PTYs.
useEffect(() => {
if (workspaceSessionReady) {
void fetchSnapshot()
}
}, [workspaceSessionReady, fetchSnapshot])
// Poll memory only while the popover is open. Session inventory is still
// explicit-on-open/action/seed (not a closed interval) because full
// listSessions can pause input with large preserved-session sets.
useEffect(() => {
if (!open) {
return
@@ -959,7 +958,9 @@ export function ResourceUsageStatusSegment({
return countUnboundDaemonSessions(sessions, resourceSessionBindings)
}, [open, sessions, resourceSessionBindings, workspaceSessionReady])
const triggerSessionCount = open ? sessions.length : closedSessionCount
// Why: open and closed badges share the same daemon inventory cache. The old
// closed path used boundPtyIds (wake hints) and inflated the chip to 60+.
const triggerSessionCount = sessionInventory.count
const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => {
const memory = resourceSnapshot?.totalMemory ?? 0
@@ -1046,7 +1047,7 @@ export function ResourceUsageStatusSegment({
(session: UnifiedSessionRow): void => {
// Why: orphan sessions have no tab here (no unsaved work to lose), so skip the confirm dialog; bound sessions still confirm.
if (!session.bound) {
setSessions((prev) => prev.filter((s) => s.id !== session.sessionId))
removeSession(session.sessionId)
// Why: await the kill before refreshing, else the refresh re-reads the daemon list before the kill lands and re-adds the row.
void (async () => {
try {
@@ -1060,7 +1061,7 @@ export function ResourceUsageStatusSegment({
}
setKillConfirm(session)
},
[refreshSessions]
[refreshSessions, removeSession]
)
const handleKillOrphans = useCallback(async () => {
@@ -1074,10 +1075,10 @@ export function ResourceUsageStatusSegment({
}
// Why: optimistic removal so rows disappear immediately instead of waiting for the next daemon-side list refresh.
const orphanIds = new Set(orphans.map((s) => s.id))
setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id)))
removeSessions(orphanIds)
await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id)))
void refreshSessions()
}, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions])
}, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions, removeSessions])
const runKillConfirmed = useCallback(async () => {
if (!killConfirm) {
@@ -1086,7 +1087,7 @@ export function ResourceUsageStatusSegment({
const target = killConfirm
setKilling(true)
// Why: optimistic removal avoids a flash where the dialog closes but the killed row lingers until the next list refresh.
setSessions((prev) => prev.filter((s) => s.id !== target.sessionId))
removeSession(target.sessionId)
try {
await window.api.pty.kill(target.sessionId)
} catch {
@@ -1106,7 +1107,7 @@ export function ResourceUsageStatusSegment({
void refreshSessions()
}
}
}, [cancelPopoverBodyFocusFrame, killConfirm, mountedRef, refreshSessions])
}, [cancelPopoverBodyFocusFrame, killConfirm, mountedRef, refreshSessions, removeSession])
const openSpaceResults = useCallback((): void => {
setOpen(false)
@@ -1,219 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import {
buildResourceSessionBindingIndex,
type ResourceSessionBindingInputs
} from './resource-session-bindings'
import {
createClosedResourceSessionCountSelector,
type ClosedResourceSessionCountState
} from './resource-session-count-selector'
const TAB_COUNT = 100
const TITLE_WRITES = 600
function makeTab(id: string, ptyId: string | null = null, title = 'Terminal'): TerminalTab {
return {
id,
ptyId,
worktreeId: `wt-${id}`,
title,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function makeLayout(ptyIdsByLeafId: Record<string, string>): TerminalLayoutSnapshot {
return {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId
}
}
function makeState(
overrides: Partial<ClosedResourceSessionCountState> = {}
): ClosedResourceSessionCountState {
return {
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
workspaceSessionReady: true,
...overrides
}
}
function withCountedIteration(tabs: TerminalTab[], onVisit: () => void): TerminalTab[] {
return new Proxy(tabs, {
get(target, property, receiver) {
if (property !== Symbol.iterator) {
return Reflect.get(target, property, receiver)
}
return function* countedIterator(): Generator<TerminalTab> {
for (const tab of target) {
onVisit()
yield tab
}
}
}
})
}
function makeScaleTabs(onVisit: () => void): Record<string, TerminalTab[]> {
return Object.fromEntries(
Array.from({ length: TAB_COUNT }, (_, index) => {
const worktreeId = `wt-${index}`
return [worktreeId, withCountedIteration([makeTab(`tab-${index}`, `pty-${index}`)], onVisit)]
})
)
}
function countNextTabComparison(
tab: TerminalTab,
onComparison: () => void
): [TerminalTab[], () => void] {
let counting = true
const tabs = new Proxy([tab], {
get(target, property, receiver) {
if (counting && property === '0') {
onComparison()
}
return Reflect.get(target, property, receiver)
}
})
return [
tabs,
() => {
counting = false
}
]
}
describe('closed resource session count selector', () => {
it('removes full binding-index rebuilds from title-only tab churn at scale', () => {
let oldPathTabVisits = 0
const initialTabs = makeScaleTabs(() => {
oldPathTabVisits += 1
})
const inputs: ResourceSessionBindingInputs = {
tabsByWorktree: initialTabs,
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
workspaceSessionReady: true
}
// Why: this models the previous useMemo dependency on tabsByWorktree:
// every title write replaces that map and rebuilds both tab passes.
for (let index = 0; index < TITLE_WRITES; index += 1) {
buildResourceSessionBindingIndex({ ...inputs, tabsByWorktree: { ...initialTabs } })
}
expect(oldPathTabVisits).toBe(120_000)
let optimizedTabVisits = 0
const optimizedTabs = makeScaleTabs(() => {
optimizedTabVisits += 1
})
const buildIndex = vi.fn(buildResourceSessionBindingIndex)
const selectCount = createClosedResourceSessionCountSelector(buildIndex)
let state = makeState({ tabsByWorktree: optimizedTabs })
const currentTabByWorktree = Object.fromEntries(
Object.entries(optimizedTabs).map(([worktreeId, tabs]) => [worktreeId, tabs[0]])
)
let changedArrayTabComparisons = 0
expect(selectCount(state)).toBe(TAB_COUNT)
buildIndex.mockClear()
optimizedTabVisits = 0
for (let index = 0; index < TITLE_WRITES; index += 1) {
const worktreeId = `wt-${index % TAB_COUNT}`
const nextTab = { ...currentTabByWorktree[worktreeId], title: `Terminal ${index}` }
const [nextTabs, stopCounting] = countNextTabComparison(nextTab, () => {
changedArrayTabComparisons += 1
})
state = {
...state,
tabsByWorktree: {
...state.tabsByWorktree,
[worktreeId]: nextTabs
}
}
expect(selectCount(state)).toBe(TAB_COUNT)
stopCounting()
currentTabByWorktree[worktreeId] = nextTab
}
expect(buildIndex).not.toHaveBeenCalled()
expect(optimizedTabVisits).toBe(0)
expect(changedArrayTabComparisons).toBe(TITLE_WRITES)
})
it('reacts to every binding and readiness input while ignoring display-only tab fields', () => {
const buildIndex = vi.fn(buildResourceSessionBindingIndex)
const selectCount = createClosedResourceSessionCountSelector(buildIndex)
let state = makeState({
workspaceSessionReady: false,
tabsByWorktree: {
'wt-1': [makeTab('tab-1', 'pty-wake')]
},
ptyIdsByTabId: {
'tab-1': ['pty-live']
},
terminalLayoutsByTabId: {
'tab-1': makeLayout({ 'leaf-1': 'pty-layout' })
}
})
expect(selectCount(state)).toBe(0)
expect(buildIndex).not.toHaveBeenCalled()
state = { ...state, workspaceSessionReady: true }
expect(selectCount(state)).toBe(3)
expect(buildIndex).toHaveBeenCalledTimes(1)
state = {
...state,
tabsByWorktree: {
'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], title: 'Working' }]
}
}
expect(selectCount(state)).toBe(3)
expect(buildIndex).toHaveBeenCalledTimes(1)
state = {
...state,
ptyIdsByTabId: { 'tab-1': ['pty-live', 'pty-live-2'] }
}
expect(selectCount(state)).toBe(4)
expect(buildIndex).toHaveBeenCalledTimes(2)
state = {
...state,
terminalLayoutsByTabId: {
'tab-1': makeLayout({ 'leaf-1': 'pty-layout', 'leaf-2': 'pty-layout-2' })
}
}
expect(selectCount(state)).toBe(5)
expect(buildIndex).toHaveBeenCalledTimes(3)
state = {
...state,
tabsByWorktree: {
'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], ptyId: null }]
}
}
expect(selectCount(state)).toBe(4)
expect(buildIndex).toHaveBeenCalledTimes(4)
state = { ...state, tabsByWorktree: {} }
expect(selectCount(state)).toBe(2)
expect(buildIndex).toHaveBeenCalledTimes(5)
state = { ...state, workspaceSessionReady: false }
expect(selectCount(state)).toBe(0)
expect(buildIndex).toHaveBeenCalledTimes(5)
})
})
@@ -1,102 +0,0 @@
import type { AppState } from '../../store'
import {
buildResourceSessionBindingIndex,
type ResourceSessionBindingIndex,
type ResourceSessionBindingInputs
} from './resource-session-bindings'
export type ClosedResourceSessionCountState = Pick<
AppState,
'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'workspaceSessionReady'
>
type BuildResourceSessionBindingIndex = (
inputs: ResourceSessionBindingInputs
) => ResourceSessionBindingIndex
export type ClosedResourceSessionCountSelector = (state: ClosedResourceSessionCountState) => number
function haveSameTabBindings(
previous: AppState['tabsByWorktree'],
next: AppState['tabsByWorktree']
): boolean {
if (previous === next) {
return true
}
const previousWorktreeIds = Object.keys(previous)
const nextWorktreeIds = Object.keys(next)
if (previousWorktreeIds.length !== nextWorktreeIds.length) {
return false
}
for (const worktreeId of nextWorktreeIds) {
const previousTabs = previous[worktreeId]
const nextTabs = next[worktreeId]
if (previousTabs === nextTabs) {
continue
}
if (!previousTabs || previousTabs.length !== nextTabs.length) {
return false
}
for (let index = 0; index < nextTabs.length; index += 1) {
const previousTab = previousTabs[index]
const nextTab = nextTabs[index]
// Why: the closed badge counts PTY ownership only. Titles and other
// display fields can churn per terminal frame without changing it.
if (previousTab.id !== nextTab.id || previousTab.ptyId !== nextTab.ptyId) {
return false
}
}
}
return true
}
export function createClosedResourceSessionCountSelector(
buildBindingIndex: BuildResourceSessionBindingIndex = buildResourceSessionBindingIndex
): ClosedResourceSessionCountSelector {
// Why: Zustand runs selectors for every store notification. Keep the last
// liveness inputs here so unrelated and title-only writes stay scalar-cheap.
let initialized = false
let previousTabsByWorktree: AppState['tabsByWorktree'] = {}
let previousPtyIdsByTabId: AppState['ptyIdsByTabId'] = {}
let previousTerminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] = {}
let previousWorkspaceSessionReady = false
let count = 0
return (state): number => {
const bindingMapChanged =
state.ptyIdsByTabId !== previousPtyIdsByTabId ||
state.terminalLayoutsByTabId !== previousTerminalLayoutsByTabId
const readinessChanged = state.workspaceSessionReady !== previousWorkspaceSessionReady
const tabsReferenceChanged = state.tabsByWorktree !== previousTabsByWorktree
let tabBindingsChanged = tabsReferenceChanged
if (
initialized &&
state.workspaceSessionReady &&
!bindingMapChanged &&
!readinessChanged &&
tabsReferenceChanged
) {
tabBindingsChanged = !haveSameTabBindings(previousTabsByWorktree, state.tabsByWorktree)
}
const shouldRebuild =
state.workspaceSessionReady &&
(!initialized || bindingMapChanged || readinessChanged || tabBindingsChanged)
if (shouldRebuild) {
count = buildBindingIndex(state).boundPtyIds.size
} else if (!state.workspaceSessionReady) {
count = 0
}
previousTabsByWorktree = state.tabsByWorktree
previousPtyIdsByTabId = state.ptyIdsByTabId
previousTerminalLayoutsByTabId = state.terminalLayoutsByTabId
previousWorkspaceSessionReady = state.workspaceSessionReady
initialized = true
return count
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
EMPTY_DAEMON_SESSION_INVENTORY,
inventoryFromSessions,
removeSessionFromInventory,
removeSessionsFromInventory
} from './resource-session-inventory'
import type { DaemonSession } from './resource-usage-merge-types'
function session(id: string): DaemonSession {
return { id, cwd: '/workspace', title: id }
}
describe('resource session inventory', () => {
it('builds count from daemon listSessions payloads', () => {
const inventory = inventoryFromSessions([session('a'), session('b')])
expect(inventory.count).toBe(2)
expect(inventory.sessions.map((entry) => entry.id)).toEqual(['a', 'b'])
})
it('returns a detached sessions array so callers can mutate safely', () => {
const source = [session('a')]
const inventory = inventoryFromSessions(source)
source.pop()
expect(inventory.sessions).toEqual([session('a')])
expect(inventory.count).toBe(1)
})
it('removes killed or exited sessions without inventing wake-hint ids', () => {
const start = inventoryFromSessions([session('live'), session('orphan'), session('other')])
const afterOne = removeSessionFromInventory(start, 'orphan')
expect(afterOne.count).toBe(2)
expect(afterOne.sessions.map((entry) => entry.id)).toEqual(['live', 'other'])
const afterMany = removeSessionsFromInventory(start, new Set(['live', 'missing', 'other']))
expect(afterMany).toEqual(inventoryFromSessions([session('orphan')]))
})
it('is a no-op when removed ids are absent', () => {
const start = inventoryFromSessions([session('live')])
expect(removeSessionFromInventory(start, 'gone')).toBe(start)
expect(removeSessionsFromInventory(start, new Set())).toBe(start)
expect(EMPTY_DAEMON_SESSION_INVENTORY.count).toBe(0)
})
})
@@ -0,0 +1,43 @@
import type { DaemonSession } from './resource-usage-merge-types'
/** Last-known daemon terminal inventory for the Resource Manager badge. */
export type DaemonSessionInventory = {
sessions: DaemonSession[]
count: number
}
export const EMPTY_DAEMON_SESSION_INVENTORY: DaemonSessionInventory = {
sessions: [],
count: 0
}
export function inventoryFromSessions(sessions: readonly DaemonSession[]): DaemonSessionInventory {
return {
sessions: sessions.slice(),
count: sessions.length
}
}
export function removeSessionsFromInventory(
inventory: DaemonSessionInventory,
sessionIds: ReadonlySet<string>
): DaemonSessionInventory {
if (sessionIds.size === 0 || inventory.sessions.length === 0) {
return inventory
}
const sessions = inventory.sessions.filter((session) => !sessionIds.has(session.id))
if (sessions.length === inventory.sessions.length) {
return inventory
}
return {
sessions,
count: sessions.length
}
}
export function removeSessionFromInventory(
inventory: DaemonSessionInventory,
sessionId: string
): DaemonSessionInventory {
return removeSessionsFromInventory(inventory, new Set([sessionId]))
}
@@ -0,0 +1,266 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DaemonSession } from './resource-usage-merge-types'
import { useResourceSessionInventory } from './use-resource-session-inventory'
function session(id: string): DaemonSession {
return { id, cwd: '/workspace', title: id }
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('useResourceSessionInventory', () => {
const listSessions = vi.fn<() => Promise<DaemonSession[]>>()
const unsubscribeSpawned = vi.fn()
const unsubscribeExit = vi.fn()
let spawnedCallback: ((data: { id: string }) => void) | null = null
let exitCallback: ((data: { id: string; code: number }) => void) | null = null
beforeEach(() => {
spawnedCallback = null
exitCallback = null
listSessions.mockReset()
unsubscribeSpawned.mockReset()
unsubscribeExit.mockReset()
;(window as unknown as { api: unknown }).api = {
pty: {
listSessions,
onSpawned: (callback: (data: { id: string }) => void) => {
spawnedCallback = callback
return unsubscribeSpawned
},
onExit: (callback: (data: { id: string; code: number }) => void) => {
exitCallback = callback
return unsubscribeExit
}
}
}
})
afterEach(() => {
delete (window as unknown as { api?: unknown }).api
})
it('seeds from the daemon inventory and resets when session restore is not ready', async () => {
listSessions.mockResolvedValue([session('one'), session('two')])
const { result, rerender } = renderHook(({ ready }) => useResourceSessionInventory(ready), {
initialProps: { ready: false }
})
expect(result.current.sessionInventory.count).toBe(0)
expect(listSessions).not.toHaveBeenCalled()
rerender({ ready: true })
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
expect(listSessions).toHaveBeenCalledTimes(1)
rerender({ ready: false })
expect(result.current.sessionInventory.count).toBe(0)
expect(result.current.sessionsError).toBe(false)
})
it('recovers inventory and clears the error after a failed readiness seed', async () => {
listSessions
.mockRejectedValueOnce(new Error('daemon unavailable'))
.mockResolvedValueOnce([session('recovered')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionsError).toBe(true))
await act(async () => {
await result.current.refreshSessions()
})
expect(result.current.sessionInventory.sessions).toEqual([session('recovered')])
expect(result.current.sessionsError).toBe(false)
})
it('refreshes for background spawns without depending on mounted pane state', async () => {
listSessions
.mockResolvedValueOnce([session('one')])
.mockResolvedValue([session('one'), session('background')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
await act(async () => {
spawnedCallback?.({ id: 'one' })
spawnedCallback?.({ id: 'background' })
spawnedCallback?.({ id: 'background-2' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('does not inventory again when an existing session reattaches', async () => {
listSessions.mockResolvedValue([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
act(() => {
spawnedCallback?.({ id: 'one' })
})
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('does not overlap provider-wide inventory reads for spawns during a slow refresh', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const inFlight = deferred<DaemonSession[]>()
listSessions.mockReturnValueOnce(inFlight.promise)
await act(async () => {
spawnedCallback?.({ id: 'background-one' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
expect(listSessions).toHaveBeenCalledTimes(2)
await act(async () => {
spawnedCallback?.({ id: 'background-two' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
expect(listSessions).toHaveBeenCalledTimes(2)
await act(async () => {
inFlight.resolve([session('one'), session('background-one'), session('background-two')])
await inFlight.promise
})
await waitFor(() => expect(result.current.sessionInventory.count).toBe(3))
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('reconciles once when an in-flight inventory misses a later spawn', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const inFlight = deferred<DaemonSession[]>()
listSessions
.mockReturnValueOnce(inFlight.promise)
.mockResolvedValueOnce([session('one'), session('background-one'), session('background-two')])
await act(async () => {
spawnedCallback?.({ id: 'background-one' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
act(() => {
spawnedCallback?.({ id: 'background-two' })
})
await act(async () => {
inFlight.resolve([session('one'), session('background-one')])
await inFlight.promise
})
await waitFor(() => expect(result.current.sessionInventory.count).toBe(3))
expect(listSessions).toHaveBeenCalledTimes(3)
})
it('cancels a queued inventory read when the unknown session exits first', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
act(() => {
spawnedCallback?.({ id: 'short-lived' })
exitCallback?.({ id: 'short-lived', code: 0 })
})
await new Promise((resolve) => window.setTimeout(resolve, 0))
expect(listSessions).toHaveBeenCalledTimes(1)
expect(result.current.sessionInventory.count).toBe(1)
})
it('does not schedule follow-up inventory after unmount', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result, unmount } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const inFlight = deferred<DaemonSession[]>()
listSessions.mockReturnValueOnce(inFlight.promise)
await act(async () => {
spawnedCallback?.({ id: 'background-one' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
act(() => {
spawnedCallback?.({ id: 'background-two' })
})
unmount()
inFlight.resolve([session('one'), session('background-one')])
await inFlight.promise
await new Promise((resolve) => window.setTimeout(resolve, 0))
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('filters an exit from an in-flight list without losing other new sessions', async () => {
listSessions.mockResolvedValueOnce([session('one'), session('exited')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
const stale = deferred<DaemonSession[]>()
listSessions.mockReturnValueOnce(stale.promise)
let refresh!: Promise<void>
act(() => {
refresh = result.current.refreshSessions()
})
act(() => {
exitCallback?.({ id: 'exited', code: 0 })
})
expect(result.current.sessionInventory.sessions.map(({ id }) => id)).toEqual(['one'])
await act(async () => {
stale.resolve([session('one'), session('exited'), session('background')])
await refresh
})
expect(result.current.sessionInventory.sessions.map(({ id }) => id)).toEqual([
'one',
'background'
])
})
it('keeps the newest result when refreshes resolve out of order', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const older = deferred<DaemonSession[]>()
const newer = deferred<DaemonSession[]>()
listSessions.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
let olderRefresh!: Promise<void>
let newerRefresh!: Promise<void>
act(() => {
olderRefresh = result.current.refreshSessions()
newerRefresh = result.current.refreshSessions()
})
await act(async () => {
newer.resolve([session('one'), session('two')])
await newerRefresh
})
await act(async () => {
older.resolve([session('one')])
await olderRefresh
})
expect(result.current.sessionInventory.count).toBe(2)
})
it('unsubscribes from lifecycle events on unmount', () => {
listSessions.mockResolvedValue([])
const { unmount } = renderHook(() => useResourceSessionInventory(true))
unmount()
expect(unsubscribeSpawned).toHaveBeenCalledTimes(1)
expect(unsubscribeExit).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMountedRef } from '@/hooks/useMountedRef'
import {
EMPTY_DAEMON_SESSION_INVENTORY,
inventoryFromSessions,
removeSessionFromInventory,
removeSessionsFromInventory,
type DaemonSessionInventory
} from './resource-session-inventory'
type ResourceSessionInventory = {
sessionInventory: DaemonSessionInventory
sessionsError: boolean
refreshSessions: () => Promise<void>
clearSessionsError: () => void
removeSession: (sessionId: string) => void
removeSessions: (sessionIds: ReadonlySet<string>) => void
}
type ResourceSessionInventoryState = {
ready: boolean
sessionInventory: DaemonSessionInventory
sessionsError: boolean
}
export function useResourceSessionInventory(ready: boolean): ResourceSessionInventory {
const mountedRef = useMountedRef()
const refreshGenerationRef = useRef(0)
const lifecycleRevisionRef = useRef(0)
const removedAtRevisionRef = useRef(new Map<string, number>())
const knownSessionIdsRef = useRef(new Set<string>())
const [storedState, setStoredState] = useState<ResourceSessionInventoryState>(() => ({
ready,
sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY,
sessionsError: false
}))
const state =
storedState.ready === ready
? storedState
: {
ready,
sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY,
sessionsError: false
}
if (state !== storedState) {
// Why: readiness changes define a new inventory epoch. Reset during render
// so an old workspace count is never exposed for one committed frame.
setStoredState(state)
}
const refreshSessions = useCallback(async (): Promise<void> => {
if (!ready) {
return
}
const generation = ++refreshGenerationRef.current
const lifecycleRevision = lifecycleRevisionRef.current
try {
const sessions = await window.api.pty.listSessions()
// Why: an exit or newer refresh can land while the global provider list
// is in flight; stale results must not resurrect dead sessions.
if (!mountedRef.current || generation !== refreshGenerationRef.current) {
return
}
const currentRemovedAtRevision = removedAtRevisionRef.current
const liveSessions = sessions.filter(
({ id }) => (currentRemovedAtRevision.get(id) ?? 0) <= lifecycleRevision
)
// Tombstones at or before this request cannot suppress later ID reuse;
// only exits that raced this request must survive to the next refresh.
for (const [id, removedAtRevision] of currentRemovedAtRevision) {
if (removedAtRevision <= lifecycleRevision) {
currentRemovedAtRevision.delete(id)
}
}
knownSessionIdsRef.current = new Set(liveSessions.map(({ id }) => id))
setStoredState({
ready: true,
sessionInventory: inventoryFromSessions(liveSessions),
sessionsError: false
})
} catch {
if (mountedRef.current && generation === refreshGenerationRef.current) {
setStoredState((current) => ({ ...current, sessionsError: true }))
}
}
}, [mountedRef, ready])
const clearSessionsError = useCallback((): void => {
setStoredState((current) => ({ ...current, sessionsError: false }))
}, [])
const removeSession = useCallback((sessionId: string): void => {
// Why: mark the exact PTY removed while a list may be in flight; filtering
// only that id preserves unrelated sessions discovered by the same list.
const lifecycleRevision = ++lifecycleRevisionRef.current
removedAtRevisionRef.current.set(sessionId, lifecycleRevision)
knownSessionIdsRef.current.delete(sessionId)
setStoredState((current) => ({
...current,
sessionInventory: removeSessionFromInventory(current.sessionInventory, sessionId)
}))
}, [])
const removeSessions = useCallback((sessionIds: ReadonlySet<string>): void => {
const lifecycleRevision = ++lifecycleRevisionRef.current
for (const sessionId of sessionIds) {
removedAtRevisionRef.current.set(sessionId, lifecycleRevision)
knownSessionIdsRef.current.delete(sessionId)
}
setStoredState((current) => ({
...current,
sessionInventory: removeSessionsFromInventory(current.sessionInventory, sessionIds)
}))
}, [])
useEffect(() => {
refreshGenerationRef.current += 1
if (!ready) {
removedAtRevisionRef.current.clear()
knownSessionIdsRef.current.clear()
return
}
void refreshSessions()
}, [ready, refreshSessions])
useEffect(() => {
if (!ready) {
return
}
let disposed = false
let refreshTimer: number | null = null
let lifecycleRefresh: Promise<void> | null = null
const pendingSpawnIds = new Set<string>()
const scheduleLifecycleRefresh = (): void => {
if (disposed || refreshTimer !== null || lifecycleRefresh !== null) {
return
}
refreshTimer = window.setTimeout(() => {
refreshTimer = null
if (pendingSpawnIds.size === 0) {
return
}
pendingSpawnIds.clear()
const refresh = refreshSessions()
lifecycleRefresh = refresh
void refresh.finally(() => {
if (disposed || lifecycleRefresh !== refresh) {
return
}
lifecycleRefresh = null
for (const id of pendingSpawnIds) {
if (knownSessionIdsRef.current.has(id)) {
pendingSpawnIds.delete(id)
}
}
scheduleLifecycleRefresh()
})
}, 0)
}
const unsubscribeSpawned = window.api.pty.onSpawned(({ id }) => {
// Why: reattach emits the same lifecycle signal; known IDs must not turn remounts into global inventory scans.
if (knownSessionIdsRef.current.has(id)) {
return
}
pendingSpawnIds.add(id)
// Why: serialize slow provider-wide lists; retry once only when a result missed a later spawn.
scheduleLifecycleRefresh()
})
const unsubscribeExit = window.api.pty.onExit(({ id }) => {
pendingSpawnIds.delete(id)
if (pendingSpawnIds.size === 0 && refreshTimer !== null) {
window.clearTimeout(refreshTimer)
refreshTimer = null
}
removeSession(id)
})
return () => {
disposed = true
pendingSpawnIds.clear()
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer)
}
unsubscribeSpawned()
unsubscribeExit()
}
}, [ready, refreshSessions, removeSession])
return {
sessionInventory: state.sessionInventory,
sessionsError: state.sessionsError,
refreshSessions,
clearSessionsError,
removeSession,
removeSessions
}
}
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import type { WorkspaceSessionState } from '../../../shared/types'
import {
collectFolderWorkspaceKeysFromSession,
collectWorktreeHydrationRepoIdsFromSession
} from './workspace-session-hydration-keys'
describe('collectFolderWorkspaceKeysFromSession', () => {
it('keeps folder workspace selection markers valid without scheduling a Git scan', () => {
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
activeTabTypeByWorktree: { 'folder:folder-1': 'terminal' }
} as unknown as WorkspaceSessionState
expect(collectFolderWorkspaceKeysFromSession(session)).toEqual(['folder:folder-1'])
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual([])
})
})
describe('collectWorktreeHydrationRepoIdsFromSession', () => {
it('includes persisted terminal tabs and ignores folder workspaces', () => {
const session = {
activeWorktreeIdsOnShutdown: [
'repo-a::/worktree-a',
'repo-b::/worktree-b',
'folder:folder-1'
],
tabsByWorktree: {
'repo-a::/worktree-a': [{ ptyId: 'pty-a' }],
'repo-b::/worktree-b': [{ ptyId: null }],
'folder:folder-1': [{ ptyId: 'pty-folder' }]
}
} as unknown as WorkspaceSessionState
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
})
it('recognizes split-pane and remote persisted sessions', () => {
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
terminalLayoutsByTabId: {
'tab-a': { ptyIdsByLeafId: { 'pane:1': 'pty-a' } }
},
tabsByWorktree: {
'repo-a::/worktree-a': [{ id: 'tab-a', ptyId: null }],
'repo-b::/worktree-b': [{ id: 'tab-b', ptyId: null }]
},
remoteSessionIdsByTabId: { 'tab-b': 'remote-session' }
} as unknown as WorkspaceSessionState
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
})
it('matches canonical session keys against raw shutdown worktree IDs', () => {
const rawWorktreeId = 'repo-a::/worktree-a'
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
activeWorktreeIdsOnShutdown: [rawWorktreeId],
tabsByWorktree: {
[`worktree:${rawWorktreeId}`]: [{ ptyId: 'pty-a' }]
}
} as unknown as WorkspaceSessionState
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a'])
})
it('excludes runtime-owned session worktrees for raw and canonical owner keys', () => {
const rawWorktreeId = 'repo-a::/remote/worktree'
const canonicalWorktreeKey = `worktree:${rawWorktreeId}`
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {
[canonicalWorktreeKey]: [{ ptyId: 'pty-a' }]
}
} as unknown as WorkspaceSessionState
expect(
collectWorktreeHydrationRepoIdsFromSession(session, {
[rawWorktreeId]: 'runtime:env-1'
})
).toEqual([])
expect(
collectWorktreeHydrationRepoIdsFromSession(session, {
[canonicalWorktreeKey]: 'runtime:env-1'
})
).toEqual([])
})
it('keeps SSH-owned session worktrees eligible for local recovery routing', () => {
const rawWorktreeId = 'repo-a::/ssh/worktree'
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {
[rawWorktreeId]: [{ ptyId: 'ssh:ssh-target@@pty-a' }]
}
} as unknown as WorkspaceSessionState
expect(
collectWorktreeHydrationRepoIdsFromSession(session, {
[rawWorktreeId]: 'ssh:ssh-target'
})
).toEqual(['repo-a'])
})
it('returns repository IDs in deterministic order', () => {
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {
'repo-b::/worktree-b': [{ ptyId: 'pty-b' }],
'repo-a::/worktree-a': [{ ptyId: 'pty-a' }]
}
} as unknown as WorkspaceSessionState
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
})
it('excludes repositories referenced only by unbounded history maps (visit recency, default tabs)', () => {
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: { 'repo-chrome::/wt': [{ ptyId: 'pty-a' }] },
lastVisitedAtByWorktreeId: {
'repo-chrome::/wt': 10,
'repo-history-only::/visited': 20
},
defaultTerminalTabsAppliedByWorktreeId: {
'repo-default-only::/applied': true
}
} as unknown as WorkspaceSessionState
// Why: history-only repos hydrate their maps unfiltered and would bloat the selective fetch.
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-chrome'])
})
it('does not scan repositories retained only by empty chrome maps or selection markers', () => {
const emptyTabsByWorktree = Object.fromEntries(
Array.from({ length: 326 }, (_, index) => [`repo-empty-${index}::/wt`, []])
)
const staleActiveTabTypes = Object.fromEntries(
Array.from({ length: 326 }, (_, index) => [`repo-empty-${index}::/wt`, 'terminal'])
)
const session = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {
...emptyTabsByWorktree,
'repo-live::/wt': [{ ptyId: 'pty-a' }]
},
openFilesByWorktree: { 'repo-empty-editor::/wt': [] },
browserTabsByWorktree: { 'repo-empty-browser::/wt': [] },
activeFileIdByWorktree: { 'repo-empty-active-file::/wt': '/stale/file.ts' },
activeBrowserTabIdByWorktree: { 'repo-empty-active-browser::/wt': 'stale-browser' },
activeTabIdByWorktree: { 'repo-empty-active-terminal::/wt': 'stale-terminal' },
activeGroupIdByWorktree: { 'repo-empty-active-group::/wt': 'stale-group' },
activeTabTypeByWorktree: staleActiveTabTypes
} as unknown as WorkspaceSessionState
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-live'])
})
it('includes a repository referenced only by activeRepoId (no active worktree, no tabs)', () => {
const session = {
activeRepoId: 'repo-active-only',
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {}
} as unknown as WorkspaceSessionState
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-active-only'])
})
it('includes repositories referenced only by active, editor, browser, or sleeping-agent state', () => {
const session = {
activeRepoId: null,
activeWorktreeId: 'repo-active::/active',
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: { 'repo-editor::/editor': [{ filePath: '/editor/file.ts' }] },
browserTabsByWorktree: { 'repo-browser::/browser': [{ id: 'browser-a' }] },
sleepingAgentSessionsByPaneKey: {
'tab:leaf': { worktreeId: 'repo-agent::/agent' }
}
} as unknown as WorkspaceSessionState
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual([
'repo-active',
'repo-agent',
'repo-browser',
'repo-editor'
])
})
})
@@ -1,26 +1,48 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import { parseExecutionHostId } from '../../../shared/execution-host'
import type { WorkspaceKey, WorkspaceSessionState } from '../../../shared/types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
export type WorkspaceSessionHydrationOptions = {
additionalValidWorkspaceKeys?: readonly WorkspaceKey[]
}
const WORKSPACE_KEYED_SESSION_FIELDS = [
// Worktree-keyed fields carrying restorable chrome — a repo appears here only if it has live
// session state (open tabs, editors, browser) to restore.
const WORKSPACE_CHROME_SESSION_FIELDS = [
'tabsByWorktree',
'openFilesByWorktree',
'activeFileIdByWorktree',
'activeBrowserTabIdByWorktree',
'activeTabTypeByWorktree',
'activeTabIdByWorktree',
'browserTabsByWorktree',
'unifiedTabs',
'tabGroups',
'tabGroupLayouts',
'activeGroupIdByWorktree',
'tabGroupLayouts'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
// Why: unbounded per-worktree history — one entry per worktree ever focused / given default tabs.
// Folder-key detection still scans them, but repo-enumeration for pre-hydration must NOT: they'd
// pull in ~every repo the user ever touched and defeat the selective fetch, and they hydrate
// unfiltered regardless (the post-scan re-prune reaps stale entries).
const WORKSPACE_HISTORY_SESSION_FIELDS = [
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
// Why: selection markers can outlive their content and cannot restore anything by themselves.
const WORKSPACE_SELECTION_SESSION_FIELDS = [
'activeFileIdByWorktree',
'activeBrowserTabIdByWorktree',
'activeTabIdByWorktree',
'activeGroupIdByWorktree',
'activeTabTypeByWorktree'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
const WORKSPACE_KEYED_SESSION_FIELDS = [
...WORKSPACE_CHROME_SESSION_FIELDS,
...WORKSPACE_SELECTION_SESSION_FIELDS,
...WORKSPACE_HISTORY_SESSION_FIELDS
] as const satisfies readonly (keyof WorkspaceSessionState)[]
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -35,42 +57,108 @@ function addFolderWorkspaceKey(keys: Set<WorkspaceKey>, value: unknown): void {
}
}
export function collectFolderWorkspaceKeysFromSession(
session: WorkspaceSessionState
): WorkspaceKey[] {
const keys = new Set<WorkspaceKey>()
function collectWorkspaceSessionKeys(
session: WorkspaceSessionState,
fields: readonly (keyof WorkspaceSessionState)[],
includeEntry: (value: unknown) => boolean = () => true
): string[] {
const keys = new Set<string>()
const addKey = (value: unknown): void => {
if (typeof value === 'string') {
keys.add(value)
}
}
addFolderWorkspaceKey(keys, session.activeWorkspaceKey)
addFolderWorkspaceKey(keys, session.activeWorktreeId)
for (const field of WORKSPACE_KEYED_SESSION_FIELDS) {
addKey(session.activeWorkspaceKey)
addKey(session.activeWorktreeId)
for (const field of fields) {
const value = session[field]
if (!isPlainRecord(value)) {
continue
}
for (const key of Object.keys(value)) {
addFolderWorkspaceKey(keys, key)
for (const [key, entry] of Object.entries(value)) {
if (includeEntry(entry)) {
addKey(key)
}
}
}
for (const worktreeId of session.activeWorktreeIdsOnShutdown ?? []) {
addFolderWorkspaceKey(keys, worktreeId)
addKey(worktreeId)
}
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
if (!Array.isArray(pages)) {
continue
}
for (const page of pages) {
addFolderWorkspaceKey(keys, page.worktreeId)
addKey(page.worktreeId)
}
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
addFolderWorkspaceKey(keys, record.worktreeId)
addKey(record.worktreeId)
}
return [...keys]
}
function hasRestorableWorkspaceChrome(value: unknown): boolean {
if (Array.isArray(value)) {
return value.length > 0
}
if (isPlainRecord(value)) {
return Object.keys(value).length > 0
}
return value !== null && value !== undefined && value !== ''
}
export function collectFolderWorkspaceKeysFromSession(
session: WorkspaceSessionState
): WorkspaceKey[] {
const keys = new Set<WorkspaceKey>()
for (const key of collectWorkspaceSessionKeys(session, WORKSPACE_KEYED_SESSION_FIELDS)) {
addFolderWorkspaceKey(keys, key)
}
return [...keys]
}
export function collectWorktreeHydrationRepoIdsFromSession(
session: WorkspaceSessionState,
runtimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
): string[] {
const repoIds = new Set<string>()
const addWorktreeRepoId = (value: unknown): void => {
if (typeof value !== 'string') {
return
}
const scope = parseWorkspaceKey(value)
if (scope?.type === 'folder') {
return
}
const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : value
const isRuntimeOwned = [value, rawWorktreeId].some(
(key) => parseExecutionHostId(runtimeHostIdByWorkspaceSessionKey?.[key])?.kind === 'runtime'
)
if (!isRuntimeOwned) {
repoIds.add(getRepoIdFromWorktreeId(rawWorktreeId))
}
}
// Why: only chrome-bearing fields — enumerating the unbounded history maps would pull in ~every
// repo ever touched and defeat the selective pre-hydration fetch (they hydrate unfiltered).
for (const key of collectWorkspaceSessionKeys(
session,
WORKSPACE_CHROME_SESSION_FIELDS,
hasRestorableWorkspaceChrome
)) {
addWorktreeRepoId(key)
}
// Why: a repo referenced only by activeRepoId (no active worktree, no tabs) still needs
// enumeration so hydrateWorkspaceSession can restore its main worktree from worktreesByRepo.
addWorktreeRepoId(session.activeRepoId)
return [...repoIds].filter(Boolean).sort()
}
export function addAdditionalValidWorkspaceKeys(
validWorkspaceIds: Set<string>,
options?: WorkspaceSessionHydrationOptions
+7 -8
View File
@@ -52,6 +52,7 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
type CreateBrowserTabOptions = {
activate?: boolean
@@ -1582,10 +1583,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
hydrateBrowserSession: (session, options) => {
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
const currentState = get()
const validWorktreeIdsForCleanup = new Set(
Object.values(currentState.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration(
currentState,
Object.keys(persistedTabsByWorktree)
)
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of currentState.folderWorkspaces) {
@@ -1610,10 +1610,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {}
const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {}
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
s,
Object.keys(persistedTabsByWorktree)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
@@ -0,0 +1,211 @@
import { expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { BrowserTab, WorkspaceSessionState } from '../../../../shared/types'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
// @ts-expect-error -- mocked browser preload API
globalThis.window = { api: {} }
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from '@/lib/workspace-session'
import { createTestStore, makeTab } from './store-test-helpers'
const WORKTREE_ID = 'repo1::/path/degraded'
const TERMINAL_ID = 'terminal-degraded'
const EDITOR_FILE_ID = '/path/degraded/src/App.tsx'
const BROWSER_ID = 'browser-degraded'
const GROUP_ID = 'group-degraded'
function makeBrowserTab(): BrowserTab {
return {
id: BROWSER_ID,
worktreeId: WORKTREE_ID,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 3
}
}
function makeDegradedRepoSession(): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
activeRepoId: 'repo1',
activeWorktreeId: WORKTREE_ID,
activeTabId: TERMINAL_ID,
tabsByWorktree: {
[WORKTREE_ID]: [makeTab({ id: TERMINAL_ID, worktreeId: WORKTREE_ID })]
},
openFilesByWorktree: {
[WORKTREE_ID]: [
{
filePath: EDITOR_FILE_ID,
relativePath: 'src/App.tsx',
worktreeId: WORKTREE_ID,
language: 'typescript'
}
]
},
activeFileIdByWorktree: { [WORKTREE_ID]: EDITOR_FILE_ID },
browserTabsByWorktree: { [WORKTREE_ID]: [makeBrowserTab()] },
activeBrowserTabIdByWorktree: { [WORKTREE_ID]: BROWSER_ID },
activeTabTypeByWorktree: { [WORKTREE_ID]: 'browser' },
unifiedTabs: {
[WORKTREE_ID]: [
{
id: TERMINAL_ID,
entityId: TERMINAL_ID,
groupId: GROUP_ID,
worktreeId: WORKTREE_ID,
contentType: 'terminal',
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: EDITOR_FILE_ID,
entityId: EDITOR_FILE_ID,
groupId: GROUP_ID,
worktreeId: WORKTREE_ID,
contentType: 'editor',
label: 'App.tsx',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
},
{
id: BROWSER_ID,
entityId: BROWSER_ID,
groupId: GROUP_ID,
worktreeId: WORKTREE_ID,
contentType: 'browser',
label: 'Example',
customLabel: null,
color: null,
sortOrder: 2,
createdAt: 3
}
]
},
tabGroups: {
[WORKTREE_ID]: [
{
id: GROUP_ID,
worktreeId: WORKTREE_ID,
activeTabId: BROWSER_ID,
tabOrder: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID],
recentTabIds: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID]
}
]
},
activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }
}
}
function makeTerminalFreeDegradedRepoSession(): WorkspaceSessionState {
const session = makeDegradedRepoSession()
session.activeTabId = null
session.tabsByWorktree = {}
session.unifiedTabs![WORKTREE_ID] = session.unifiedTabs![WORKTREE_ID].filter(
(tab) => tab.contentType !== 'terminal'
)
session.tabGroups![WORKTREE_ID] = session.tabGroups![WORKTREE_ID].map((group) => ({
...group,
tabOrder: [EDITOR_FILE_ID, BROWSER_ID],
recentTabIds: [EDITOR_FILE_ID, BROWSER_ID]
}))
return session
}
function hydrateWithRepoScan(
store: ReturnType<typeof createTestStore>,
session: WorkspaceSessionState,
authoritative = false
): void {
store.setState({
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
worktreesByRepo: { repo1: [] },
detectedWorktreesByRepo: {
repo1: {
repoId: 'repo1',
authoritative,
source: authoritative ? 'git' : 'metadata-fallback',
worktrees: []
}
}
})
store.getState().hydrateWorkspaceSession(session)
store.getState().hydrateTabsSession(session)
store.getState().hydrateEditorSession(session)
store.getState().hydrateBrowserSession(session)
}
it('keeps tab, editor, and browser chrome through degraded hydration and persistence', () => {
const firstStore = createTestStore()
hydrateWithRepoScan(firstStore, makeDegradedRepoSession())
firstStore.setState({ workspaceSessionReady: true })
firstStore.getState().setHydrationSucceeded(true)
expect(shouldPersistWorkspaceSession(firstStore.getState())).toBe(true)
const persisted = buildWorkspaceSessionPayload(firstStore.getState())
const restoredStore = createTestStore()
hydrateWithRepoScan(restoredStore, persisted)
const restored = restoredStore.getState()
expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([
TERMINAL_ID,
EDITOR_FILE_ID,
BROWSER_ID
])
expect(restored.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID])
expect(restored.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID])
})
it('keeps a terminal-free degraded workspace selected through hydration and persistence', () => {
const firstStore = createTestStore()
hydrateWithRepoScan(firstStore, makeTerminalFreeDegradedRepoSession())
const first = firstStore.getState()
expect(first.activeWorktreeId).toBe(WORKTREE_ID)
expect(first.activeTabType).toBe('browser')
firstStore.setState({ workspaceSessionReady: true })
firstStore.getState().setHydrationSucceeded(true)
const persisted = buildWorkspaceSessionPayload(firstStore.getState())
const restoredStore = createTestStore()
hydrateWithRepoScan(restoredStore, persisted)
const restored = restoredStore.getState()
expect(restored.activeWorktreeId).toBe(WORKTREE_ID)
expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([
EDITOR_FILE_ID,
BROWSER_ID
])
expect(restored.activeTabType).toBe('browser')
})
it('drops terminal-free chrome when an authoritative scan proves deletion', () => {
const store = createTestStore()
hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true)
const state = store.getState()
expect(state.activeWorktreeId).toBeNull()
expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toBeUndefined()
expect(state.openFiles).toEqual([])
expect(state.browserTabsByWorktree[WORKTREE_ID]).toBeUndefined()
})
@@ -0,0 +1,75 @@
import type {
DetectedWorktreeListResult,
Repo,
WorkspaceSessionState,
Worktree
} from '../../../../shared/types'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
type WorktreeValidityCatalog = {
repos: readonly Pick<Repo, 'id'>[]
worktreesByRepo: Readonly<Record<string, readonly Pick<Worktree, 'id'>[]>>
detectedWorktreesByRepo?: Readonly<
Record<string, Pick<DetectedWorktreeListResult, 'authoritative'> | undefined>
>
}
export function collectPersistedWorktreeIdsForSessionHydration(
session: WorkspaceSessionState
): Set<string> {
const persistedWorktreeIds = new Set<string>()
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
persistedWorktreeIds.add(worktreeId)
}
for (const worktreeId of Object.keys(session.unifiedTabs ?? {})) {
persistedWorktreeIds.add(worktreeId)
}
for (const worktreeId of Object.keys(session.openFilesByWorktree ?? {})) {
persistedWorktreeIds.add(worktreeId)
}
for (const worktreeId of Object.keys(session.browserTabsByWorktree ?? {})) {
persistedWorktreeIds.add(worktreeId)
}
return persistedWorktreeIds
}
export function buildValidWorktreeIdsForSessionHydration(
catalog: WorktreeValidityCatalog,
persistedWorktreeIds: Iterable<string>
): Set<string> {
const worktreesByRepo = catalog.worktreesByRepo
const validWorktreeIds = new Set(
Object.values(worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id))
const repoIdsWithLoadedWorktrees = new Set(
Object.entries(worktreesByRepo)
.filter(([, worktrees]) => worktrees.length > 0)
.map(([repoId]) => repoId)
)
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
Object.entries(catalog.detectedWorktreesByRepo ?? {})
.filter(([, detected]) => detected?.authoritative)
.map(([repoId]) => repoId)
)
for (const worktreeId of persistedWorktreeIds) {
if (validWorktreeIds.has(worktreeId) || parseWorkspaceKey(worktreeId)?.type === 'folder') {
continue
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
// Why (#1158): a failed scan cannot prove deletion, while loaded worktrees or an authoritative scan can.
if (
knownRepoIds.has(repoId) &&
!repoIdsWithLoadedWorktrees.has(repoId) &&
!repoIdsWithAuthoritativeDetectedWorktrees.has(repoId)
) {
validWorktreeIds.add(worktreeId)
}
}
return validWorktreeIds
}
+4 -5
View File
@@ -69,6 +69,7 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { translate } from '@/i18n/i18n'
@@ -4407,11 +4408,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
const persistedMarkdownFrontmatterVisible = session.markdownFrontmatterVisible ?? {}
// Why: worktrees may have been deleted between sessions; drop files for worktrees that no longer exist.
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((w) => w.id)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
s,
Object.keys(openFilesByWorktree)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
+6 -5
View File
@@ -40,6 +40,10 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from './degraded-repo-worktree-validity'
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
@@ -1971,11 +1975,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
hydrateTabsSession: (session, options) => {
const state = get()
const validWorktreeIds = new Set(
Object.values(state.worktreesByRepo)
.flat()
.map((w) => w.id)
)
const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of state.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
+11 -31
View File
@@ -104,6 +104,10 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from './degraded-repo-worktree-validity'
import {
collectHibernatedCompletionEvidenceForWorktree,
collectSleepingAgentSessionRecordsForWorktree,
@@ -3074,45 +3078,21 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},
worktreesByRepo: s.worktreesByRepo
})
const validWorktreeIds = new Set(
Object.values(runtimeSessionPlaceholders.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
{
repos: runtimeSessionPlaceholders.repos,
worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo,
detectedWorktreesByRepo: s.detectedWorktreesByRepo
},
collectPersistedWorktreeIdsForSessionHydration(session)
)
const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id))
const repoIdsWithLoadedWorktrees = new Set(
Object.entries(runtimeSessionPlaceholders.worktreesByRepo)
.filter(([, worktrees]) => worktrees.length > 0)
.map(([repoId]) => repoId)
)
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
Object.entries(s.detectedWorktreesByRepo)
.filter(([, detected]) => detected.authoritative)
.map(([repoId]) => repoId)
)
// Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart.
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
continue
}
if (!validWorktreeIds.has(worktreeId)) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
// Why (#1158): an empty/missing list can mean degraded hydration; a non-empty repo list is authoritative for deleted-worktree cleanup.
if (
knownRepoIds.has(repoId) &&
!repoIdsWithLoadedWorktrees.has(repoId) &&
!repoIdsWithAuthoritativeDetectedWorktrees.has(repoId)
) {
validWorktreeIds.add(worktreeId)
}
}
}
// Why pendingActivationSpawn: a restored worktree's first mount calls updateTabPtyId, which would bump lastActivityAt and bounce it to the top of Recent; the tag (consumed on the first pty update) suppresses that so only real activity bumps.
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
Object.entries(session.tabsByWorktree)
@@ -22,6 +22,7 @@ import type {
} from '../../../../shared/types'
import type { WorktreeForceDeleteReason } from '../../../../shared/worktree-removal'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type {
PendingWorktreeCreation,
WorktreeCreationPhase
@@ -116,10 +117,16 @@ export type WorktreeSlice = {
* sessions (design §4.4). Session-only; never persisted.
*/
hasHydratedWorktreePurge: boolean
/** Startup owns the initial all-host refresh; sidebar repo-change refreshes stay gated until it finishes. */
startupWorktreeRefreshCompleted: boolean
fetchDetectedWorktrees: (repoId: string) => Promise<DetectedWorktreeListResult | null>
fetchWorktrees: (
repoId: string,
options?: { requireAuthoritative?: boolean; forceLocalOwner?: boolean }
options?: {
requireAuthoritative?: boolean
executionHostId?: ExecutionHostId
forceLocalOwner?: boolean
}
) => Promise<boolean>
fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise<void>
fetchWorktreeLineage: (options?: { forceLocalOwner?: boolean }) => Promise<void>
@@ -1350,6 +1350,45 @@ describe('fetchWorktrees', () => {
])
})
it('fetches the requested host when duplicate repo ids exist', async () => {
const store = createTestStore()
const localWorktree = makeWorktree({
id: 'same-repo::/local/wt',
repoId: 'same-repo',
path: '/local/wt'
})
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
repos: [
{
id: 'same-repo',
path: '/local/repo',
displayName: 'Local',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'local'
},
{
id: 'same-repo',
path: '/remote/repo',
displayName: 'Runtime',
badgeColor: '#111',
addedAt: 1,
executionHostId: 'runtime:env-1'
}
]
} as Partial<AppState>)
mockApi.worktrees.listDetected.mockResolvedValueOnce(
makeDetectedResult('same-repo', [localWorktree])
)
await store.getState().fetchWorktrees('same-repo', { executionHostId: 'local' })
expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree])
})
it('stamps remote runtime worktrees with the owning repo runtime host', async () => {
const store = createTestStore()
// Why: a remote runtime returns worktrees from its own perspective, so their hostId arrives as the default "local".
+4 -1
View File
@@ -2287,6 +2287,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
everActivatedWorktreeIds: new Set<string>(),
lastVisitedAtByWorktreeId: {},
hasHydratedWorktreePurge: false,
startupWorktreeRefreshCompleted: false,
fetchDetectedWorktrees: async (repoId) => {
try {
@@ -2337,7 +2338,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// the local owner without redirecting runtime/SSH-only repos.
const useLocalOwner =
options?.forceLocalOwner === true && (hasLocalOwner || repoOwners.length === 0)
const hostId = useLocalOwner ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId)
const hostId = useLocalOwner
? LOCAL_EXECUTION_HOST_ID
: repoHostId(ownerState, repoId, options?.executionHostId)
const ownerWasMissingAtStart = repoOwners.length === 0
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId)
+1
View File
@@ -3012,6 +3012,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
onReplay: () => noopUnsubscribe,
onModelRestoreNeeded: () => noopUnsubscribe,
onExit: () => noopUnsubscribe,
onSpawned: () => noopUnsubscribe,
onSerializeBufferRequest: () => noopUnsubscribe,
onClearBufferRequest: () => noopUnsubscribe,
sendSerializedBuffer: () => {},
+1
View File
@@ -237,6 +237,7 @@ export async function waitForTerminalReady(page, timeoutMs = 60_000, terminalTab
* workspace (which would mask a broken restore).
*/
export async function ensureTerminal(page, { allowCreate = true, timeoutMs = 60_000 } = {}) {
await dismissOverlays(page, 1)
const visibleTerminal = page.locator(TERMINAL_SURFACE_VISIBLE).first()
if (await visibleTerminal.isVisible().catch(() => false)) {
await waitForTerminalReady(page, timeoutMs)