mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* 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>
118 lines
3.5 KiB
TypeScript
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))
|
|
}
|
|
}
|
|
}
|