mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +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>
329 lines
9.9 KiB
TypeScript
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) ?? '')
|
|
)
|
|
}
|