Files
orca/src/main/native-chat/transcript-read-cache.ts
T
Kaynan Sampaio de Camargoandkaynan dc4fb2aa03 fix(native-chat): retry not-yet-flushed transcripts instead of settling into a permanent error (#8418)
* fix(native-chat): retry not-yet-flushed transcripts instead of settling into a permanent error

A freshly-created session's transcript .jsonl lands on disk seconds to
minutes after the process starts. Native chat's one-shot read raced that
first flush: a miss became a permanent "No transcript found" error and
the live-tail subscription silently degraded to a no-op, so the pane
never recovered even after the file appeared.

- transcript-reader/read-cache: mark the miss with notFound so callers
  can tell "not flushed yet" from a real parse/IO error (never cached).
- transcript-watch: poll resolve+install (500ms backoff, 5s cap) for the
  subscription's lifetime instead of returning a dead no-op watcher.
- use-native-chat-live-session: retry a notFound read with backoff for
  up to 60s while staying in the loading state, and let live appends
  render over a stale initial-read error.

Fixes #8401

Claude-Session: https://claude.ai/code/session_01HA5g3X7wCakBttBpDru9Fp

* fix(native-chat): address CodeRabbit review — ENOENT retryable, content over spinner, blank-id guard, unref poll timer

- transcript-reader: an ENOENT after a successful resolve is the same
  first-flush/rotation race as an unresolved path — mark it notFound.
- use-native-chat-live-session: live appends landing mid-retry render
  instead of the loading state (mirrors the stale-error gate).
- transcript-watch: bail out for a blank session id with no explicit
  file (nothing to resolve-poll), and unref the poll timer so headless
  serve shutdown is never held open by an unresolvable session.

Claude-Session: https://claude.ai/code/session_01HA5g3X7wCakBttBpDru9Fp

---------

Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br>
2026-07-13 12:45:46 -07:00

124 lines
5.4 KiB
TypeScript

import { stat } from 'node:fs/promises'
import type { AgentType } from '../../shared/native-chat-types'
import { resolveSessionFilePath } from './session-file-resolver'
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
// Why: both the desktop IPC handler and the runtime RPC handler read the same
// host-filesystem transcript, so a single process-global cache keyed by the
// RESOLVED transcript file path maximizes the hit rate across desktop + every
// paired web/mobile client (all clients of one session resolve the same path
// against this runtime's home). Keying by connection instead would defeat the
// multi-client case this feature targets and multiply memory by the connection
// count. The key is the resolved file path, NOT `agent:sessionId`: two panes can
// share one sessionId yet resolve to DIFFERENT files (the same session resumed
// into a second worktree, which writes a new transcript file), and a
// sessionId-only key let one worktree's cached parse be served to another when
// their file mtimes momentarily coincided (#7326). The cache stores ONE
// canonical, unwindowed parse; windowing and per-surface truncation stay in the
// callers so the same parse is reused across all `limit` values and every client kind.
type CachedTranscript = {
result: ReadTranscriptResult
/** mtime of the resolved file when cached; a newer mtime invalidates it. */
mtimeMs: number
/** On-disk byte size of the resolved file — a cheap, monotonic proxy for this
* entry's parsed memory footprint, used to bound the cache by total bytes. */
bytes: number
}
const cache = new Map<string, CachedTranscript>()
// Why: cap the cache so a long-lived process browsing many sessions can't grow
// it unbounded. Map preserves insertion order, so evicting the first key drops
// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
const MAX_CACHE_ENTRIES = 50
// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool
// results embed whole file contents, command output, and diffs), and each cached
// entry is the full unwindowed parse. The count cap alone let 50 such entries
// retain multiple GB in the one process that now serves desktop + every paired
// web/mobile client. Bound total cached file bytes too; we always keep the most-
// recent entry (see setCached) so an active transcript is never re-parsed on
// every read, which caps the regression to extra re-parses only past this budget.
const MAX_CACHE_BYTES = 128 * 1024 * 1024
// Overridable only from tests so the byte-eviction path can be exercised without
// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES.
let maxCacheBytes = MAX_CACHE_BYTES
function setCached(key: string, value: CachedTranscript): void {
// Re-insert moves the key to the most-recent position for LRU eviction.
cache.delete(key)
cache.set(key, value)
let totalBytes = 0
for (const entry of cache.values()) {
totalBytes += entry.bytes
}
// Evict oldest until within BOTH caps, but never drop the most-recent entry
// (cache.size > 1): a single active transcript larger than the whole budget
// must stay cached or every read would re-parse the full file.
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 stat(filePath)
return { mtimeMs: stats.mtimeMs, bytes: stats.size }
} catch {
return { mtimeMs: Number.NaN, bytes: 0 }
}
}
/**
* Read the full transcript for an agent + session, returning the cached parse on
* an mtime hit and re-reading (and re-caching) when the file changed. Returns the
* canonical, unwindowed result; callers apply their own windowing/truncation.
*/
export async function readNativeChatTranscriptCached(
agent: AgentType,
sessionId: string,
/** Hook-reported authoritative transcript path, preferred over the id glob. */
transcriptPath?: string
): Promise<ReadTranscriptResult> {
const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
if (!filePath) {
// Not cached (see below): a not-yet-flushed transcript should be re-checked
// on the next call, not pinned as a settled miss (#8401).
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
}
const key = cacheKey(agent, filePath)
const { mtimeMs, bytes } = await fileStat(filePath)
const cached = cache.get(key)
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
// Bump recency so a frequently-read session survives eviction.
setCached(key, cached)
return cached.result
}
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
if (Number.isFinite(mtimeMs)) {
setCached(key, { result, mtimeMs, bytes })
}
return result
}
/** Test-only: drop the transcript parse cache between runs. */
export function clearNativeChatTranscriptCache(): void {
cache.clear()
}
/** Test-only: override the byte budget (pass no arg to restore the default). */
export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void {
maxCacheBytes = bytes ?? MAX_CACHE_BYTES
}