Files
orca/src/main/ai-vault/session-scanner-opencode-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

137 lines
5.1 KiB
TypeScript

import { wslGatedReaddir, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { join } from 'node:path'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewMessage,
createAccumulator,
finalizeSession,
seedFullFirstUserPrompt,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt'
import {
asRecord,
extractPreviewContentText,
extractString,
findOpenCodeStorageRoot,
normalizeTitleText,
timeObjectValue,
timestampMs,
tokenTotal
} from './session-scanner-values'
export async function parseOpenCodeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
return null
}
const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path)
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId })
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.directory)
updateTimeline(accumulator, timeObjectValue(record.time, 'created'))
updateTimeline(accumulator, timeObjectValue(record.time, 'updated'))
await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId)
return finalizeSession(accumulator, platform)
}
// Why: OpenCode stores one file per message and `readdir` order is arbitrary,
// so the transcript has to be rebuilt by creation time before anything reads
// "the first user prompt". Undated messages sort last, by file name, so a
// missing timestamp can never displace a real first turn.
async function readOpenCodeMessagesInOrder(messageDir: string): Promise<Record<string, unknown>[]> {
let entries
try {
entries = await wslGatedReaddir(messageDir, 'scan')
} catch (error) {
// A session with no message dir yet is empty; a gate refusal is not — an
// empty transcript would otherwise be cached under the session's mtime.
if (error instanceof WslTranscriptFsError) {
throw error
}
return []
}
const messages: { name: string; createdMs: number; message: Record<string, unknown> }[] = []
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) {
continue
}
let message: Record<string, unknown> | null = null
try {
message = asRecord(
JSON.parse(await wslGatedReadFile(join(messageDir, entry.name), 'utf-8', 'scan')) as unknown
)
} catch (error) {
// A live OpenCode process can leave a half-written file; skip it rather
// than discard the whole session. A refused read is not a bad file — the
// partial transcript it would produce must not become the cached answer.
if (error instanceof WslTranscriptFsError) {
throw error
}
continue
}
if (!message) {
continue
}
const createdMs = timestampMs(timeObjectValue(message.time, 'created'))
messages.push({
name: entry.name,
createdMs: Number.isFinite(createdMs) ? createdMs : Number.POSITIVE_INFINITY,
message
})
}
return messages
.sort((a, b) => a.createdMs - b.createdMs || a.name.localeCompare(b.name))
.map((entry) => entry.message)
}
export async function consumeOpenCodeMessages(
accumulator: SessionAccumulator,
storageRoot: string | null,
sessionId: string
): Promise<void> {
if (!storageRoot) {
return
}
for (const message of await readOpenCodeMessagesInOrder(
join(storageRoot, 'message', sessionId)
)) {
const role = extractString(message.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
updateTimeline(accumulator, timeObjectValue(message.time, 'created'))
const summary = asRecord(message.summary)
if (role === 'user') {
accumulator.title ??= extractString(summary?.title) ?? extractString(summary?.body)
}
// Why: the summary fallbacks are AI-generated. Seed the copyable first
// prompt from real typed content only, never from a summary of a
// harness-injected turn. Extraction stays inside the thunk so list scans
// (capture mode `none`) never pay for it.
seedFullFirstUserPrompt(accumulator, role, () =>
extractFullFirstUserPromptText(message.content)
)
addPreviewMessage(accumulator, {
role,
text:
extractPreviewContentText(message.content) ??
extractString(summary?.body) ??
extractString(summary?.title),
timestamp: timeObjectValue(message.time, 'created'),
seedFirstUserPrompt: false
})
accumulator.model =
extractString(asRecord(message.model)?.modelID) ||
extractString(message.modelID) ||
accumulator.model
accumulator.totalTokens += tokenTotal(message.tokens)
}
}
}