mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
* fix(native-chat): kill hung WSL operations via child process
Stalled UNC file operations hold libuv permits even after the gate
timeout expires, blocking Chat tab recovery. Two stalled operations
fill both permits and freeze all WSL access until restart.
Fork file I/O for UNC paths into a separate child process. On deadline
expiry, kill the process to force the hung syscall to exit. This frees
the permit for the affected tab's next read. Temporarily quarantine the
stalled route to avoid retry storms.
* chore: drop internal review artifact from the repo root
* fix(native-chat): harden the WSL transcript fs sidecar
Review follow-ups on the sidecar isolation change:
- Only the deadline may abort running gate work. The sole waiter's
same-duration timeout fired first, killed healthy children on caller
abandonment, and settled the task before the deadline could quarantine
a stalled route - leaving the back-off dead for every dedupe:false op.
- Resolve the fork entry from out/main/chunks too: the resolver compiles
into a shared chunk, and the scanner service child has no
process.resourcesPath, so packaged WSL vault scans threw entry-not-found
(masked as an empty tree).
- Allowlist the fork env instead of spreading process.env; ambient
NODE_OPTIONS would halt or --require code into every child.
- Wrap transport faults (spawn failure, child death) in
WslTranscriptFsError('unavailable') so discovery reports them as scan
issues instead of misreading them as missing paths or empty trees.
- Gate the vitest in-process fallback on the vitest worker global so a
leaked VITEST=true cannot revert production to in-process UNC syscalls.
- Reap idle sidecar processes after 60s instead of holding them for the
app session.
- Split 'open' into its own protocol union member so the reusable-call
Exclude actually strips it from the pooled-process API.
- Guard kill('SIGKILL') against the teardown race where an exiting child
emits an unlistened 'error', and dispatch reads by handle kind before
path spelling.
* fix(native-chat): probe stalled WSL routes instead of a fixed quarantine
Remaining review follow-ups:
- Escalating route quarantine: first strike lifts after 5s so a distro
that was cold-booting when its op hit the deadline recovers on the
next poll (~35s total instead of ~90s); repeat stalls double the
back-off toward the prior 2x-timeout cap, and any settle the deadline
did not force clears the strikes. Queued same-route tasks fail fast
at quarantine instead of stranding one waiter deadline per file in
sequential scans.
- Single request implementation: the vitest in-process fallback now runs
the child's own dispatcher (WslTranscriptFsProcessOperations + decode),
so unit suites exercise exactly what the forked process executes and
the per-call-site fallback closures are gone. Dirent fixtures gained
the full kind-flag set the serializer reads.
- Dropped the production-dead per-route close queue; UNC FileHandles
(test fallback only) mirror the process-handle close contract.
- Error class, messages, and factories move to wsl-transcript-fs-error
(re-exported from the gate) to keep the gate under the lines budget.
* fix(native-chat): harden WSL transcript fs with route quarantine strike
Extract quarantine logic into a dedicated module with strike decay: stalls older
than 5 minutes restart from base back-off, and concurrent-lane timeouts count as
one incident. Allow joining live in-flight tasks on quarantined routes (they cost
no new I/O). Preserve quarantine across transport faults (child death). Handle
file shrinking during tail reads by detecting short reads and returning empty.
Defer file closes that arrive mid-read instead of refusing, preventing slot
leaks. Separate process slot and boundary-finding concerns into focused modules.
* fix(native-chat): enforce route quarantine windows and isolate lanes per
A late result arriving after the deadline was incorrectly lifting the route
quarantine, allowing subsequent work to start before the back-off period
expired. Now late results are correctly recognized as stale and never cut
the quarantine short.
Process work is now isolated per (route, priority) lane so a scan stall
cannot block exact reads on the same distro. Each lane gets its own client
and process pool; late results and handle faults stay scoped to their lane.
Tests now fake performance.now() alongside timers (the quarantine clock
depends on it) and wait for the full back-off window to expire rather than
advancing by 0. Gate state is reset between test cases since late releases
never lift the quarantine.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
283 lines
8.8 KiB
TypeScript
283 lines
8.8 KiB
TypeScript
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 {
|
|
findLastCompleteLineEnd,
|
|
readTranscriptByteAt,
|
|
TAIL_CHUNK_BYTES
|
|
} from './transcript-tail-boundary'
|
|
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
|
|
|
|
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 = await readTranscriptByteAt(handle, filePath, consumedTo - 1, signal)
|
|
if (finalByte === null) {
|
|
// File shrank between stat and probe: report empty, the next poll re-stats.
|
|
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
|
|
}
|
|
ignoreNextMalformedRecord = finalByte !== 0x0a
|
|
let cursor = consumedTo - (finalByte === 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()
|
|
// A short read means the file shrank mid-walk: stop paging back rather
|
|
// than stitch non-adjacent bytes into records.
|
|
if (bytesRead < buffer.length) {
|
|
break
|
|
}
|
|
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 })
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|