Files
orca/src/main/ai-vault/session-scanner-codex-parser.ts
T
JinjingandOrca 2c28b6c92c Gate WSL transcript filesystem I/O to prevent stalls (STA-4049) (#14203)
* fix(ai-vault): gate post-resolution WSL transcript I/O (STA-4049)

PR #14090 admitted only path *resolution* through the WSL transcript
filesystem gate. Every byte read afterwards from the resulting
\\wsl.localhost\... UNC path ran raw, so a distro that answers the first
access() and then stalls hung Native Chat at "loading" and AI Vault at
"scanning" with no timeout and no error.

Route that I/O through a new wsl-transcript-fs-access accessor, which is
a verbatim node:fs passthrough off UNC and an admitted, deadlined task on
it. open/positional-read opt out of coalescing (dedupe: false): joiners
would share one FileHandle or one caller's buffer.

Refusals now surface as the existing retryable message rather than
notFound, per-root scan failures are contained to an AiVaultScanIssue,
and the memoized Codex/Kimi indexes evict on refusal so a stall cannot
pin "no titles"/"no cwd" until the index changes.

* fix(ai-vault): stop caching WSL gate refusals as results (STA-4049)

Code review 1 P1 fixes on top of the transcript gate:

- transcript-read-cache: never store a gate refusal. The refusal leaves the
  file's mtime untouched, so the cached error would have been served to every
  later call until the transcript itself changed.
- kimi/grok/opencode parsers: rethrow WslTranscriptFsError instead of folding it
  into "no session"/"no transcript", so the session parse cache cannot store a
  null or partial answer under an unchanged mtime. Ordinary missing/half-written
  files stay contained.
- opencode-usage scanner: gate the data-directory readdir and the absolute
  OPENCODE_DB stat. The AI Vault's primary OpenCode source reaches them
  transitively, which is why the direct-import guard never saw them.
- gated stat/lstat: accept an AbortSignal, matching gated open/read, so a
  cancelled watch install or title probe detaches immediately instead of holding
  a waiter to its deadline.
- gated open: close a FileHandle whose syscall lands after the last waiter gave
  up, and close handles off UNC verbatim (awaited, failures surfaced).

Co-authored-by: Orca <help@stably.ai>

* fix(native-chat): decode gated chunks incrementally and cancel drain I/O (STA-4049)

Addresses the CR2 blockers.

UTF-8 chunk-boundary corruption: the UNC branch yielded raw 1 MiB Buffer
slices that `decodeTranscriptStream` decoded independently, so any multibyte
codepoint straddling a boundary became U+FFFD on both sides — corrupting the
JSONL line and shifting `consumedBytes` (which seeds fallback message ids).
`gatedChunks` now holds a StringDecoder when `encoding` is set, and
`decodeTranscriptStream` holds one for the Buffer path, matching what
`createReadStream`'s decoder already did off UNC.

Watcher teardown: `installTranscriptWatcher` owns an AbortController that
`unsubscribe()` aborts, threaded through every gated call on the drain path.
Waiters now detach at teardown instead of holding to the 30s deadline, and
the gate's aborted-signal pre-check stops an in-flight drain from admitting
new tasks after close.

Rovo `session_context.json`: `readJsonObjectIfExists` rethrows
WslTranscriptFsError so `parseSessionCandidate` records a scan issue, instead
of caching an un-enriched session under an unchanged mtime that never re-reads.

Primary OpenCode source: `listOpenCodeDatabases` takes an optional refusal
reporter so a refused `OPENCODE_DB`/`XDG_DATA_HOME` surfaces an
AiVaultScanIssue, matching `listOpenCodeDatabasesInDirectory`.

`boundaryFingerprint` moved to its own module to keep the watcher engine
under the max-lines cap.

* refactor(native-chat): consolidate transcript I/O and remove fallback te

- Move boundaryFingerprint from its own module to transcript-file-version.ts
- Extract runPathOperation helper to eliminate duplicate UNC path routing
- Remove tests for fallback behaviors when transcripts are unavailable or incomplete
- Clean up implementation comments and verbose test documentation

* consolidate scan issues and gate session scanner I/O (STA-4049)

Both local and remote session scans hit stalled WSL distros identically:
one failed probe per discovered path. Unifying issue recording and gate
refusal handling prevents duplication and ensures consistent behavior.

- Gate all file operations (stat, readdir, read, open) through WSL
  stall detection instead of scattered or missing gates
- Serve cached transcripts when stat stalls; distinguish gate refusals
  from missing files
- Serialize UNC close operations to prevent thread pool exhaustion
- Incremental chunk decoding in streams handles codepoint boundaries
  correctly

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-13 00:54:19 -07:00

329 lines
9.9 KiB
TypeScript

import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
cloneSessionAccumulator,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
consumeCodexCompletedMessage,
consumeCodexLegacyEventMessage,
consumeCodexResponseMessage
} from './session-scanner-codex-message-records'
import type {
CodexUsageSnapshot,
FileWithMtime,
ResumableParseFinalizeOptions,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
addCodexUsage,
asRecord,
extractGitBranch,
extractModel,
extractString,
normalizeCodexUsage,
normalizeTitleText,
parseJsonObject,
subtractCodexUsage
} from './session-scanner-values'
import { remoteSessionContentLines } from './remote-session-content-lines'
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null,
executionHostId?: ExecutionHostId
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCodexSessionLines({
file,
lines,
platform,
codexHome,
executionHostId,
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
})
}
export async function parseCodexSessionContent(args: {
file: FileWithMtime
content: string
platform?: NodeJS.Platform
codexHome?: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
readIndexedTitle?: (sessionId: string) => Promise<string | null>
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
return parseCodexSessionLines({
file: args.file,
lines: remoteSessionContentLines(args.content, args.signal),
platform: args.platform ?? process.platform,
codexHome: args.codexHome ?? null,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform,
titleReader: args.readIndexedTitle
})
}
type CodexSessionParseState = {
accumulator: SessionAccumulator
previousTotals: CodexUsageSnapshot | null
rejectedWorkerSession: boolean
sawSessionMeta: boolean
historyMode: string | null
// Which source set the current title; an index-file title outranks the raw
// first user prompt, so finalize must know whether 'meta' already won.
titleSource: 'meta' | 'user' | null
}
function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
return {
accumulator: createAccumulator({
agent: 'codex',
file,
sessionId: sessionIdFromFileName(file.path)
}),
previousTotals: null,
rejectedWorkerSession: false,
sawSessionMeta: false,
historyMode: null,
titleSource: null
}
}
function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseState {
return {
// previousTotals snapshots are replaced, never mutated, so sharing is safe.
...state,
accumulator: cloneSessionAccumulator(state.accumulator)
}
}
function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void {
if (state.rejectedWorkerSession) {
return
}
const record = parseJsonObject(line)
if (!record) {
return
}
const { accumulator } = state
updateTimeline(accumulator, extractString(record.timestamp))
const payload = asRecord(record.payload)
if (record.type === 'session_meta' && payload) {
if (isCodexWorkerSession(payload)) {
// Why: Codex writes internal worker/sub-agent transcripts into the same
// history tree; AI Vault should show user-started sessions only.
state.rejectedWorkerSession = true
return
}
state.sawSessionMeta = true
state.historyMode = extractString(payload.history_mode)
const sessionId = extractString(payload.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
const metadataTitle = extractCodexSessionMetadataTitle(payload)
if (metadataTitle) {
accumulator.title = metadataTitle
state.titleSource = 'meta'
}
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
return
}
if (record.type === 'turn_context' && payload) {
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
return
}
if (!payload) {
return
}
if (record.type === 'response_item' && payload.type === 'message') {
if (state.historyMode === 'paginated') {
return
}
if (consumeCodexResponseMessage(accumulator, payload, record.timestamp)) {
state.titleSource = 'user'
}
return
}
if (record.type !== 'event_msg') {
return
}
if (state.historyMode === 'paginated' && payload.type === 'item_completed') {
if (consumeCodexCompletedMessage(accumulator, payload, record.timestamp)) {
state.titleSource = 'user'
}
return
}
if (payload.type === 'user_message' || payload.type === 'agent_message') {
if (consumeCodexLegacyEventMessage(accumulator, payload, record.timestamp)) {
state.titleSource = 'user'
}
return
}
if (payload.type !== 'token_count') {
return
}
const info = asRecord(payload.info)
if (!info) {
return
}
const totalUsage = normalizeCodexUsage(info.total_token_usage)
const lastUsage = normalizeCodexUsage(info.last_token_usage)
let delta: CodexUsageSnapshot | null = null
if (totalUsage) {
delta = subtractCodexUsage(totalUsage, state.previousTotals)
state.previousTotals = totalUsage
} else if (lastUsage) {
delta = lastUsage
state.previousTotals = state.previousTotals
? addCodexUsage(state.previousTotals, lastUsage)
: lastUsage
}
if (delta) {
accumulator.totalTokens += delta.totalTokens
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
}
async function finalizeCodexParseState(
state: CodexSessionParseState,
platform: NodeJS.Platform,
args: {
codexHome: string | null
titleReader?: (sessionId: string) => Promise<string | null>
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
): Promise<AiVaultSession | null> {
if (state.rejectedWorkerSession) {
return null
}
// Finalize a snapshot: the live state keeps accumulating appended lines.
const snapshot = cloneCodexParseState(state)
// Why: Codex names threads lazily in session_index.jsonl, so the lookup runs
// per finalize (the index read is signature-cached) — a title that appears
// after the transcript was first parsed must still replace the raw prompt.
if (snapshot.sawSessionMeta && snapshot.titleSource !== 'meta') {
const indexedTitle = await args.titleReader?.(snapshot.accumulator.sessionId)
if (indexedTitle) {
snapshot.accumulator.title = indexedTitle
}
}
return finalizeSession(snapshot.accumulator, platform, {
codexHome: args.codexHome,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform
})
}
export function createCodexSessionResumeState(
file: FileWithMtime,
codexHome: string | null
): ResumableSessionParseState {
return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) =>
readCodexSessionIndexTitle(file.path, codexHome, sessionId)
)
}
function codexResumeStateFromParseState(
state: CodexSessionParseState,
codexHome: string | null,
titleReader: (sessionId: string) => Promise<string | null>
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeCodexRecordLine(state, line),
clone: () =>
codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader),
touchFile: (file) => {
state.accumulator.modifiedAt = file.modifiedAt
},
finalize: (platform, options?: ResumableParseFinalizeOptions) =>
finalizeCodexParseState(state, platform, { codexHome, titleReader, ...options })
}
}
async function parseCodexSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
codexHome: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise<string | null>
}): Promise<AiVaultSession | null> {
const state = createCodexParseState(args.file)
for await (const line of args.lines) {
consumeCodexRecordLine(state, line)
if (state.rejectedWorkerSession) {
// Worker transcripts are excluded outright; stop reading early.
return null
}
}
return finalizeCodexParseState(state, args.platform, {
codexHome: args.codexHome,
titleReader: args.titleReader,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform
})
}
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
const threadSource = extractCodexThreadSource(payload)
if (threadSource) {
return threadSource.toLowerCase() !== 'user'
}
const source = asRecord(payload.source)
return Boolean(asRecord(source?.subagent))
}
function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
return (
normalizeTitleText(extractString(payload.title) ?? '') ??
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
normalizeTitleText(extractString(payload.threadName) ?? '')
)
}