Files
orca/src/main/codex/codex-session-backfill-scan-dates.ts
T
Neil 015f904fca fix(codex): stop re-scanning all Codex session history on every launch (#16251) (#16593)
* fix(codex): stop re-scanning all Codex session history on every launch (#16251)

A launch deleted the backfill completion marker, and a marker could never
be written while a Codex pane was open, so every launch re-derived
"needs full scan" and walked the entire .codex/sessions tree — on Windows
with a large history that read as a hung window.

- v4 marker keeps a durable full-history baseline plus a bounded set of
  pending dates. v3 is read as a baseline, so upgrades pay no full scan.
- A launch now marks dates pending instead of deleting the marker, and a
  full pass certifies the baseline even while a pane is still running;
  the live pane's own date just stays pending.
- Pending dates are persisted, so an abnormal exit or a cross-midnight
  pane recovers a bounded window instead of a full walk.
- A date-limited pass can only extend an existing baseline, never create
  one, so it can no longer certify history it never looked at.
- Marker and index-heal target roots compare through
  normalizeRuntimePathForComparison, so Windows spellings of one
  directory stop invalidating each other.
- Both append-only ledgers stream instead of readFileSync + whole-file
  JSON.parse, keeping the main thread responsive on large histories.

* fix(codex): keep the backfill marker's full-scan demand durable

Review follow-ups on the v4 backfill marker:

- markCodexSessionBackfillMarkerPending no longer erases a persisted
  needsFullScan; the demand survives until a generation-current full walk
  retires it, and the function now reports it so the launch path folds it
  into its own in-memory flag (as @rumoii's #16252 does).
- A full pass settles the whole pending set instead of subtracting the
  empty set, so a date a full walk provably covered stops forcing an extra
  bounded pass on every startup.
- isCodexSessionBackfillDate does a real calendar check, so a corrupted
  marker cannot carry 2026/99/99. No age or future bound: the same guard
  gates rollout publication and a clock-skewed directory holds real
  sessions.
- 'scans only the current date once a baseline exists' now has a second
  date directory, so it fails on a full walk instead of passing either way.
2026-08-26 15:42:54 -07:00

113 lines
4.2 KiB
TypeScript

import type { CodexSessionBackfillDate } from './codex-session-backfill-types'
// Set algebra over the UTC date directories that make up a bounded backfill
// pass. Kept apart from the walk itself so the durable marker, the scheduler,
// and the pass all agree on identity, ordering, and range expansion.
export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate {
return [
String(date.getUTCFullYear()).padStart(4, '0'),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
]
}
export function isCodexSessionBackfillDate(value: unknown): value is CodexSessionBackfillDate {
if (!Array.isArray(value) || value.length !== 3) {
return false
}
const key = value.join('-')
// Why: shape alone accepts directories the calendar never produces (2026/99/99
// from a corrupted marker, 2025/02/29); a UTC round-trip rejects them for free.
// Deliberately no age or future bound — this also gates which managed rollouts
// get published, and a clock-skewed future directory holds real sessions.
return (
/^\d{4}-\d{2}-\d{2}$/.test(key) &&
toCodexSessionBackfillDateKey(getCodexSessionBackfillDate(toUtcDate(value))) === key
)
}
export function toCodexSessionBackfillDateKey(date: CodexSessionBackfillDate): string {
return date.join('-')
}
export function compareCodexSessionBackfillDates(
left: CodexSessionBackfillDate,
right: CodexSessionBackfillDate
): number {
return toCodexSessionBackfillDateKey(left).localeCompare(toCodexSessionBackfillDateKey(right))
}
/** Deduplicated ascending union; invalid entries are dropped. */
export function mergeCodexSessionBackfillDates(
...groups: readonly (readonly CodexSessionBackfillDate[] | undefined)[]
): CodexSessionBackfillDate[] {
const merged = new Map<string, CodexSessionBackfillDate>()
for (const group of groups) {
for (const date of group ?? []) {
if (isCodexSessionBackfillDate(date)) {
merged.set(toCodexSessionBackfillDateKey(date), date)
}
}
}
return [...merged.values()].sort(compareCodexSessionBackfillDates)
}
export function subtractCodexSessionBackfillDates(
dates: readonly CodexSessionBackfillDate[],
removed: readonly CodexSessionBackfillDate[]
): CodexSessionBackfillDate[] {
const removedKeys = new Set(removed.map(toCodexSessionBackfillDateKey))
return dates.filter((date) => !removedKeys.has(toCodexSessionBackfillDateKey(date)))
}
/** Reads persisted marker dates; anything unrecognized is discarded. */
export function parseCodexSessionBackfillDates(value: unknown): CodexSessionBackfillDate[] {
return Array.isArray(value)
? mergeCodexSessionBackfillDates(value.filter(isCodexSessionBackfillDate))
: []
}
export function getCodexSessionBackfillDatesBetween(
startedAt: Date,
finishedAt: Date
): CodexSessionBackfillDate[] {
const dates: CodexSessionBackfillDate[] = []
const cursor = toUtcMidnight(startedAt)
const last = toUtcMidnight(finishedAt)
while (cursor <= last) {
dates.push(getCodexSessionBackfillDate(cursor))
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return dates
}
/**
* Widens a pending set into the contiguous range that ends at `today`.
*
* Why: an abnormal exit or a pane held open across midnight leaves activity on
* dates nobody got to record. The gap between the oldest pending date and today
* is the smallest window that provably contains them. Returns null once that
* window outgrows `maxDates`, where a full walk is the cheaper certainty.
*/
export function expandCodexSessionBackfillDatesThroughToday(
dates: readonly CodexSessionBackfillDate[],
today: CodexSessionBackfillDate,
maxDates: number
): CodexSessionBackfillDate[] | null {
if (dates.length === 0) {
return []
}
const bounds = mergeCodexSessionBackfillDates(dates, [today])
const range = getCodexSessionBackfillDatesBetween(toUtcDate(bounds[0]), toUtcDate(bounds.at(-1)!))
return range.length > maxDates ? null : range
}
function toUtcDate([year, month, day]: readonly string[]): Date {
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
}
function toUtcMidnight(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}