Files
orca/src/main/native-chat/transcript-incremental-reader.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

118 lines
3.5 KiB
TypeScript

import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
import {
MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES,
type NativeChatLineDecoder
} from './transcript-tail-reader'
import { openTranscriptReadStream, wslGatedStat } from './wsl-transcript-fs-access'
const APPEND_BATCH_MESSAGE_LIMIT = 40
export type IncrementalTranscriptState = {
offset: number
pendingChunks: Buffer[]
pendingStart: number
pendingBytes: number
droppingOversizedRecord: boolean
}
export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void {
state.offset = 0
state.pendingChunks.length = 0
state.pendingStart = 0
state.pendingBytes = 0
state.droppingOversizedRecord = false
}
export async function readIncrementalTranscriptMessages(
filePath: string,
state: IncrementalTranscriptState,
decode: NativeChatLineDecoder,
onBatch?: (messages: NativeChatMessage[]) => void,
decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null,
onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void,
signal?: AbortSignal
): Promise<NativeChatMessage[]> {
const end = (await wslGatedStat(filePath, 'exact', signal)).size
if (end <= state.offset) {
return []
}
const messages: NativeChatMessage[] = []
const stream = openTranscriptReadStream(
filePath,
{ start: state.offset, end: end - 1 },
'exact',
signal
)
try {
let absoluteOffset = state.offset
for await (const rawChunk of stream) {
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk)
let segmentStart = 0
let newline = chunk.indexOf(0x0a)
while (newline >= 0) {
retainPart(chunk.subarray(segmentStart, newline))
if (!state.droppingOversizedRecord) {
decodeLine()
}
resetPendingLine(absoluteOffset + newline + 1)
segmentStart = newline + 1
newline = chunk.indexOf(0x0a, segmentStart)
}
if (segmentStart < chunk.length) {
retainPart(chunk.subarray(segmentStart))
}
absoluteOffset += chunk.length
state.offset = absoluteOffset
}
return messages
} finally {
// Early exits (throw/oversized-record bail) must not leak the fd or, on
// UNC, the gated handle the generator's finally closes.
stream.destroy()
}
function retainPart(part: Buffer): void {
if (state.droppingOversizedRecord) {
return
}
state.pendingBytes += part.length
if (state.pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) {
state.pendingChunks.length = 0
state.droppingOversizedRecord = true
return
}
state.pendingChunks.push(part)
}
function resetPendingLine(nextStart: number): void {
state.pendingChunks.length = 0
state.pendingBytes = 0
state.droppingOversizedRecord = false
state.pendingStart = nextStart
}
function decodeLine(): void {
let line = Buffer.concat(state.pendingChunks).toString('utf8')
if (line.endsWith('\r')) {
line = line.slice(0, -1)
}
if (!line) {
return
}
const fallbackId = transcriptFallbackId(filePath, state.pendingStart)
const lifecycle = decodeLifecycle?.(line, fallbackId)
if (lifecycle) {
onLifecycle?.(lifecycle)
}
const message = decode(line, fallbackId)
if (!message) {
return
}
messages.push(message)
if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) {
onBatch(messages.splice(0))
}
}
}