mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* [comment audit] Tighten watcher lifetime and file-write constraints [skip ci] * [comment audit] Clarify IPC routing and notification comments [skip ci] * [comment audit] Shorten watcher, preflight and PTY comments [skip ci] * [comment audit] Clarify PTY delivery and restore ownership [skip ci] * [comment audit] Tighten PTY environment and flow-control rationale [skip ci] * [comment audit] Clarify IPC spawn and stable-pane guarantees [skip ci] * [comment audit] Tighten PTY provider and filesystem ownership rationale [skip ci] * [comment audit] Clarify clone ownership and remote runtime recovery [skip ci] * [comment audit] Condense upload, settings and SSH lifecycle comments [skip ci] * [comment audit] Shorten shutdown, preview and worktree watcher rationale [skip ci] * [comment audit] Clarify worktree identity, remote ownership and copy limits [skip ci] * [comment audit] Shorten worktree catalog, removal and WSL routing rationale [skip ci] * [comment audit] Tighten Linear credential, pagination and export comments [skip ci] * [comment audit] Condense managed-server activation, snapshot and launch rationale [skip ci] * [comment audit] Trim runtime installation and managed-server lifecycle comments [skip ci] * [comment audit] Simplify managed tunnel, memory and menu comments [skip ci] * [comment audit] Condense integration, model catalog and attachment rationale [skip ci] * [comment audit] Shorten journal persistence, replay and queue rationale [skip ci] * [comment audit] Condense journal ordering and timeline lifecycle comments [skip ci] * [comment audit] Trim timeline and subscriber delivery explanations [skip ci] * [comment audit] Shorten Stop, child-exit and recovery contracts [skip ci] * [comment audit] Simplify handover and host recovery comments [skip ci] * [comment audit] Trim queue delivery and provider startup commentary [skip ci] * [comment audit] Clarify restart and rewind safety comments [skip ci] * [comment audit] Trim status, Stop and completion commentary [skip ci] * [comment audit] Simplify transcript lookup and visual containment comments [skip ci] * [comment audit] Trim transcript reader, watcher and WSL transport commentary [skip ci] --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
|
import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode'
|
|
import type { AgentType } from '../../shared/native-chat-types'
|
|
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
|
import { resolveSessionFilePath } from './session-file-resolver'
|
|
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
|
|
import { wslGatedStat } from './wsl-transcript-fs-access'
|
|
import {
|
|
isWslTranscriptFsRefusalMessage,
|
|
WslTranscriptFsError,
|
|
wslTranscriptFsRefusal
|
|
} from './wsl-transcript-fs-error'
|
|
|
|
// Share one unwindowed parse per resolved path across desktop and paired clients.
|
|
// Session IDs can resolve to different worktree files; connection keys would duplicate memory.
|
|
|
|
type CachedTranscript = {
|
|
result: ReadTranscriptResult
|
|
mtimeMs: number
|
|
/** Disk bytes approximate parsed memory for eviction. */
|
|
bytes: number
|
|
readOrder: number
|
|
}
|
|
|
|
const cache = new Map<string, CachedTranscript>()
|
|
const inFlightReads = new InFlightPromiseDedupe<ReadTranscriptResult>()
|
|
let cacheEpoch = 0
|
|
let nextReadOrder = 0
|
|
|
|
const MAX_CACHE_ENTRIES = 50
|
|
// Count alone permits gigabytes of full transcripts; also cap bytes while retaining the active entry.
|
|
const MAX_CACHE_BYTES = 128 * 1024 * 1024
|
|
let maxCacheBytes = MAX_CACHE_BYTES
|
|
|
|
function setCached(key: string, value: CachedTranscript): void {
|
|
cache.delete(key)
|
|
cache.set(key, value)
|
|
let totalBytes = 0
|
|
for (const entry of cache.values()) {
|
|
totalBytes += entry.bytes
|
|
}
|
|
// Keep the newest even over budget to avoid reparsing a single large active transcript on every read.
|
|
while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) {
|
|
const oldest = cache.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
totalBytes -= cache.get(oldest)?.bytes ?? 0
|
|
cache.delete(oldest)
|
|
}
|
|
}
|
|
|
|
function cacheKey(agent: AgentType, filePath: string): string {
|
|
return `${agent}:${filePath}`
|
|
}
|
|
|
|
async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> {
|
|
try {
|
|
const stats = await wslGatedStat(filePath, 'exact')
|
|
return { mtimeMs: stats.mtimeMs, bytes: stats.size }
|
|
} catch (err) {
|
|
// Preserve gate refusal; falling through would spend another deadline on a full read.
|
|
if (err instanceof WslTranscriptFsError) {
|
|
throw err
|
|
}
|
|
return { mtimeMs: Number.NaN, bytes: 0 }
|
|
}
|
|
}
|
|
|
|
/** Cache by mtime/size; callers apply their own windows to the canonical full parse. */
|
|
export async function readNativeChatTranscriptCached(
|
|
agent: AgentType,
|
|
sessionId: string,
|
|
transcriptPath?: string
|
|
): Promise<ReadTranscriptResult> {
|
|
if (resolveNativeChatTranscriptAgent(agent) === 'opencode') {
|
|
return readOpenCodeNativeChatTranscriptFull(
|
|
sessionId,
|
|
{},
|
|
undefined,
|
|
agent === 'zcode' ? 'zcode' : undefined
|
|
)
|
|
}
|
|
const epoch = cacheEpoch
|
|
let filePath: string | null
|
|
try {
|
|
filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
|
|
} catch (err) {
|
|
// Unavailability is neither cached nor marked missing; the next call must resolve again.
|
|
return { error: wslTranscriptFsRefusal(err).message }
|
|
}
|
|
if (!filePath) {
|
|
// First flush may not have happened; never cache a missing transcript.
|
|
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
|
|
}
|
|
|
|
const key = cacheKey(agent, filePath)
|
|
let cached = epoch === cacheEpoch ? cache.get(key) : undefined
|
|
let mtimeMs: number
|
|
let bytes: number
|
|
try {
|
|
;({ mtimeMs, bytes } = await fileStat(filePath))
|
|
} catch (err) {
|
|
// Failed stat cannot prove the cached parse stale; keep complete content visible through a distro stall.
|
|
cached = epoch === cacheEpoch ? (cache.get(key) ?? cached) : undefined
|
|
if (cached) {
|
|
setCached(key, cached)
|
|
return cached.result
|
|
}
|
|
return { error: wslTranscriptFsRefusal(err).message }
|
|
}
|
|
cached = epoch === cacheEpoch ? cache.get(key) : undefined
|
|
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs && cached.bytes === bytes) {
|
|
setCached(key, cached)
|
|
return cached.result
|
|
}
|
|
|
|
if (!Number.isFinite(mtimeMs) || epoch !== cacheEpoch) {
|
|
return readNativeChatTranscript(agent, sessionId, { filePath })
|
|
}
|
|
|
|
return inFlightReads.run(stableInFlightKey([key, mtimeMs, bytes, epoch]), async () => {
|
|
const readOrder = ++nextReadOrder
|
|
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
|
|
// Late parses must not overwrite a newer completed read or repopulate a cleared cache.
|
|
if (
|
|
epoch === cacheEpoch &&
|
|
(cache.get(key)?.readOrder ?? 0) < readOrder &&
|
|
!isRetryableReadError(result)
|
|
) {
|
|
setCached(key, { result, mtimeMs, bytes, readOrder })
|
|
}
|
|
return result
|
|
})
|
|
}
|
|
|
|
function isRetryableReadError(result: ReadTranscriptResult): boolean {
|
|
return (
|
|
'error' in result && (result.notFound === true || isWslTranscriptFsRefusalMessage(result.error))
|
|
)
|
|
}
|
|
|
|
export function clearNativeChatTranscriptCache(): void {
|
|
cacheEpoch += 1
|
|
inFlightReads.clear()
|
|
cache.clear()
|
|
}
|
|
|
|
export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void {
|
|
maxCacheBytes = bytes ?? MAX_CACHE_BYTES
|
|
}
|