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

311 lines
9.4 KiB
TypeScript

import type { FileHandle } from 'node:fs/promises'
import type {
AgentType,
NativeChatMessage,
NativeChatTurnLifecycle
} from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import {
decodeClaudeTranscriptLine,
decodeCodexTranscriptLine,
decodeGrokTranscriptLine,
decodeOmpTranscriptLine
} from './transcript-line-decoders'
import { transcriptFallbackId } from './transcript-fallback-id'
import {
nativeChatTurnLifecycleDecoderForAgent,
type NativeChatTurnLifecycleDecoder
} from './transcript-turn-lifecycle'
import {
closeTranscriptHandle,
wslGatedOpen,
wslGatedRead,
wslGatedStat
} from './wsl-transcript-fs-access'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
const TAIL_CHUNK_BYTES = 64 * 1024
export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null
export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null {
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
if (transcriptAgent === 'claude') {
return decodeClaudeTranscriptLine
}
if (transcriptAgent === 'codex') {
return decodeCodexTranscriptLine
}
if (transcriptAgent === 'grok') {
return decodeGrokTranscriptLine
}
if (transcriptAgent === 'omp') {
return decodeOmpTranscriptLine
}
return null
}
export async function readNativeChatTranscriptTailFile(
filePath: string,
limit: number,
decode: NativeChatLineDecoder,
includeTrailingLine = false,
endOffset?: number,
decodeLifecycle?: NativeChatTurnLifecycleDecoder | null,
signal?: AbortSignal
): Promise<{
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
consumedTo: number
hasMore: boolean
beforeOffset: number
malformedRecordCount?: number
oversizedRecordCount?: number
}> {
signal?.throwIfAborted()
const end = Math.min(
(await wslGatedStat(filePath, 'exact', signal)).size,
endOffset ?? Number.MAX_SAFE_INTEGER
)
signal?.throwIfAborted()
if (end === 0) {
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
}
const handle = await wslGatedOpen(filePath, 'exact', signal)
const lineParts: Buffer[] = []
let lineBytes = 0
let lineOversized = false
let lifecycle: NativeChatTurnLifecycle | undefined
let malformedRecordCount = 0
let oversizedRecordCount = 0
let ignoreNextMalformedRecord = false
try {
signal?.throwIfAborted()
const consumedTo = includeTrailingLine
? end
: await findLastCompleteLineEnd(handle, filePath, end, signal)
if (consumedTo === 0) {
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
}
const newestFirst: { message: NativeChatMessage; offset: number }[] = []
const finalByte = Buffer.allocUnsafe(1)
await wslGatedRead(handle, filePath, finalByte, 0, 1, consumedTo - 1, 'exact', signal)
signal?.throwIfAborted()
ignoreNextMalformedRecord = finalByte[0] !== 0x0a
let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0)
while (cursor > 0 && newestFirst.length <= limit) {
signal?.throwIfAborted()
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
const buffer = Buffer.allocUnsafe(cursor - start)
const { bytesRead } = await wslGatedRead(
handle,
filePath,
buffer,
0,
buffer.length,
start,
'exact',
signal
)
signal?.throwIfAborted()
let segmentEnd = bytesRead
for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) {
if (buffer[index] !== 0x0a) {
continue
}
retainPart(buffer.subarray(index + 1, segmentEnd))
if (!lineOversized) {
decodeLine(start + index + 1, newestFirst)
}
resetLine()
segmentEnd = index
}
if (segmentEnd > 0) {
retainPart(buffer.subarray(0, segmentEnd))
}
cursor = start
}
if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) {
decodeLine(0, newestFirst)
}
const chronological = newestFirst.toReversed()
// Why: slice(-0) returns the whole array, so a non-positive limit must
// window to nothing explicitly rather than leak every buffered record.
const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : []
return {
messages: selected.map((entry) => entry.message),
...(lifecycle ? { lifecycle } : {}),
consumedTo,
hasMore: limit > 0 && chronological.length > limit,
beforeOffset: selected[0]?.offset ?? end,
...(malformedRecordCount > 0 ? { malformedRecordCount } : {}),
...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {})
}
} finally {
await closeTranscriptHandle(handle, filePath)
}
function retainPart(part: Buffer): void {
if (lineOversized) {
return
}
lineBytes += part.length
if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) {
lineParts.length = 0
lineOversized = true
oversizedRecordCount++
return
}
lineParts.push(part)
}
function resetLine(): void {
lineParts.length = 0
lineBytes = 0
lineOversized = false
}
function decodeLine(
lineOffset: number,
messages: { message: NativeChatMessage; offset: number }[]
): void {
let line = Buffer.concat([...lineParts].toReversed()).toString('utf8')
if (line.endsWith('\r')) {
line = line.slice(0, -1)
}
if (!line) {
return
}
try {
JSON.parse(line)
} catch {
if (ignoreNextMalformedRecord) {
ignoreNextMalformedRecord = false
return
}
malformedRecordCount++
return
}
ignoreNextMalformedRecord = false
const fallbackId = transcriptFallbackId(filePath, lineOffset)
// Why: scan the same bounded JSONL window for provider-authored lifecycle
// records so reconnect snapshots can replay completion without guessing
// from the last visible assistant message.
lifecycle ??= decodeLifecycle?.(line, fallbackId) ?? undefined
const message = decode(line, fallbackId)
if (message) {
messages.push({ message, offset: lineOffset })
}
}
}
async function findLastCompleteLineEnd(
handle: FileHandle,
filePath: string,
end: number,
signal?: AbortSignal
): Promise<number> {
signal?.throwIfAborted()
const lastByte = Buffer.allocUnsafe(1)
await wslGatedRead(handle, filePath, lastByte, 0, 1, end - 1, 'exact', signal)
signal?.throwIfAborted()
if (lastByte[0] === 0x0a) {
return end
}
let cursor = end
while (cursor > 0) {
signal?.throwIfAborted()
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
const buffer = Buffer.allocUnsafe(cursor - start)
const { bytesRead } = await wslGatedRead(
handle,
filePath,
buffer,
0,
buffer.length,
start,
'exact',
signal
)
signal?.throwIfAborted()
const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a)
if (newline !== -1) {
return start + newline + 1
}
cursor = start
}
return 0
}
export async function readNativeChatTranscriptTail(
args: ResolveSessionFileOptions & {
agent: AgentType
sessionId: string
transcriptPath?: string
filePath?: string
limit: number
beforeOffset?: number
},
signal?: AbortSignal
): Promise<
| {
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
hasMore: boolean
beforeOffset: number
}
| { error: string; notFound?: true }
> {
const decode = nativeChatLineDecoderForAgent(args.agent)
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
if (!decode) {
return { error: 'Transcript unavailable' }
}
let filePath: string | null
try {
filePath =
args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal))
} catch (error) {
signal?.throwIfAborted()
// Why: gate refusal is transient unavailability with retry guidance —
// `notFound` would settle callers into a false "missing" state.
return { error: wslTranscriptFsRefusal(error).message }
}
signal?.throwIfAborted()
// Why: a new agent session can report its id before the first JSONL flush;
// callers keep that miss in loading/retry rather than showing a false error.
if (!filePath) {
return { error: 'Transcript unavailable', notFound: true }
}
try {
const result = await readNativeChatTranscriptTailFile(
filePath,
args.limit,
decode,
true,
args.beforeOffset,
decodeLifecycle,
signal
)
signal?.throwIfAborted()
return {
messages: result.messages,
// Why: an older pagination page must not rewind the live lifecycle; only
// the current transcript tail can authoritatively describe turn state.
...(args.beforeOffset === undefined && result.lifecycle
? { lifecycle: result.lifecycle }
: {}),
hasMore: result.hasMore,
beforeOffset: result.beforeOffset
}
} catch (error) {
signal?.throwIfAborted()
const message = error instanceof Error ? error.message : String(error)
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
? { error: message, notFound: true }
: { error: message }
}
}