mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
|
import {
|
|
boundaryFingerprint,
|
|
readTranscriptFileVersion,
|
|
transcriptFileVersionChanged,
|
|
type TranscriptFileVersion
|
|
} from './transcript-file-version'
|
|
import {
|
|
createIncrementalTranscriptState,
|
|
readIncrementalTranscriptMessages,
|
|
resetIncrementalTranscriptState
|
|
} from './transcript-incremental-reader'
|
|
import { readNativeChatTranscriptTailFile } from './transcript-tail-reader'
|
|
import { emitTranscriptUnavailableSnapshot } from './transcript-unavailable-snapshot'
|
|
import { transcriptWatcherPathIsInstallable } from './transcript-watcher-install-probe'
|
|
import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle'
|
|
import type {
|
|
NativeChatTranscriptSubscription,
|
|
SubscribeNativeChatTranscriptArgs
|
|
} from './transcript-watch-contract'
|
|
import { createTranscriptWatchScheduler } from './transcript-watch-scheduler'
|
|
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
|
import {
|
|
createRunningGuardedTranscriptNativeWatcher,
|
|
isWslTranscriptWatcherPath,
|
|
transcriptWatcherPathIsRunning
|
|
} from './wsl-transcript-watcher-running-guard'
|
|
import { observeWslTranscriptRunningState } from './wsl-transcript-running-observer'
|
|
import { trackActiveNativeChatWatcher } from './transcript-watcher-count'
|
|
|
|
/** Install a live tail, or return null when the resolved file is not readable yet. */
|
|
export async function installTranscriptWatcher(
|
|
filePath: string,
|
|
decode: (line: string, fallbackId: string) => NativeChatMessage | null,
|
|
args: SubscribeNativeChatTranscriptArgs,
|
|
signal?: AbortSignal
|
|
): Promise<NativeChatTranscriptSubscription | null> {
|
|
const isWslPath = isWslTranscriptWatcherPath(filePath)
|
|
if (!(await transcriptWatcherPathIsInstallable(filePath, signal))) {
|
|
return null
|
|
}
|
|
const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args
|
|
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
|
|
|
|
const state = createIncrementalTranscriptState()
|
|
let watchedVersion: TranscriptFileVersion | null = null
|
|
let initialDrain = true,
|
|
initialErrorEmitted = false
|
|
let closed = false
|
|
// Why: every gated call on the drain path must detach the moment we
|
|
// unsubscribe, instead of holding a waiter until its 30s deadline, and an
|
|
// aborted signal also makes the gate refuse admission for anything the
|
|
// in-flight drain would start after teardown.
|
|
const gateAbort = new AbortController()
|
|
let reading = false
|
|
let pendingReadRequested = false
|
|
let rotationRetryCount = 0
|
|
|
|
function scheduleRotationRetry(): void {
|
|
if (closed) {
|
|
return
|
|
}
|
|
const retryDelay = Math.min(25 * 2 ** Math.min(rotationRetryCount, 7), 2_000)
|
|
if (scheduler.scheduleRetry(retryDelay)) {
|
|
rotationRetryCount += 1
|
|
}
|
|
}
|
|
|
|
async function readAndEmitAppends(): Promise<void> {
|
|
let lifecycle: NativeChatTurnLifecycle | undefined
|
|
const remaining = await readIncrementalTranscriptMessages(
|
|
filePath,
|
|
state,
|
|
decode,
|
|
(messages) => {
|
|
if (!closed) {
|
|
onAppend(messages)
|
|
}
|
|
},
|
|
decodeLifecycle ?? undefined,
|
|
(nextLifecycle) => {
|
|
lifecycle = nextLifecycle
|
|
},
|
|
gateAbort.signal
|
|
)
|
|
if (!closed && (remaining.length > 0 || lifecycle)) {
|
|
onAppend(remaining, lifecycle)
|
|
}
|
|
}
|
|
|
|
async function readSnapshot(limit: number) {
|
|
return readNativeChatTranscriptTailFile(
|
|
filePath,
|
|
limit,
|
|
decode,
|
|
false,
|
|
undefined,
|
|
decodeLifecycle,
|
|
gateAbort.signal,
|
|
(boundary) => {
|
|
state.boundary = boundary
|
|
}
|
|
)
|
|
}
|
|
|
|
async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise<void> {
|
|
const completedVersion = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
|
if (transcriptFileVersionChanged(completedVersion, startVersion)) {
|
|
// Why: a write racing this drain needs another pass even when the reader
|
|
// happened to reach its new EOF; timestamp-only rewrites may need replace.
|
|
watchedVersion = startVersion
|
|
pendingReadRequested = true
|
|
} else {
|
|
watchedVersion = completedVersion
|
|
}
|
|
pendingReadRequested ||= completedVersion.size !== state.offset
|
|
if (closed) {
|
|
return
|
|
}
|
|
if (!nativeWatcher.needsRebind() || nativeWatcher.bind()) {
|
|
rotationRetryCount = 0
|
|
return
|
|
}
|
|
if (!isWslPath) {
|
|
scheduleRotationRetry()
|
|
}
|
|
}
|
|
|
|
async function drainOnce(): Promise<void> {
|
|
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
|
const currentBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal)
|
|
if (closed) {
|
|
return
|
|
}
|
|
const identityChanged = watchedVersion !== null && current.identity !== watchedVersion.identity
|
|
const sameSizeVersionChanged =
|
|
watchedVersion !== null &&
|
|
current.identity === watchedVersion.identity &&
|
|
current.size === watchedVersion.size &&
|
|
transcriptFileVersionChanged(current, watchedVersion)
|
|
const contentReplaced =
|
|
identityChanged ||
|
|
sameSizeVersionChanged ||
|
|
current.size < state.offset ||
|
|
(state.offset > 0 && state.boundary.toString('base64') !== currentBoundary)
|
|
if (identityChanged) {
|
|
nativeWatcher.invalidate()
|
|
}
|
|
if (contentReplaced) {
|
|
resetIncrementalTranscriptState(state)
|
|
}
|
|
// Why: subscriber callbacks may replace the path before the drain can finish.
|
|
watchedVersion ??= current
|
|
|
|
const replacementSnapshot =
|
|
// Why: 0 is a valid window — an explicit undefined check keeps an empty
|
|
// snapshot empty instead of falling back to an unbounded incremental read.
|
|
contentReplaced && !initialDrain && onReplace && initialLimit !== undefined
|
|
? await readSnapshot(initialLimit)
|
|
: null
|
|
if (closed) {
|
|
return
|
|
}
|
|
if (replacementSnapshot && onReplace) {
|
|
state.offset = replacementSnapshot.consumedTo
|
|
state.pendingStart = state.offset
|
|
onReplace(
|
|
replacementSnapshot.messages,
|
|
replacementSnapshot.hasMore,
|
|
replacementSnapshot.beforeOffset,
|
|
replacementSnapshot.lifecycle
|
|
)
|
|
await finishSuccessfulDrain(current)
|
|
return
|
|
}
|
|
|
|
const initialSnapshot =
|
|
initialDrain && onInitialSnapshot && initialLimit !== undefined
|
|
? await readSnapshot(initialLimit)
|
|
: null
|
|
if (closed) {
|
|
return
|
|
}
|
|
if (initialDrain && onInitialSnapshot) {
|
|
initialDrain = false
|
|
if (initialSnapshot) {
|
|
state.offset = initialSnapshot.consumedTo
|
|
state.pendingStart = state.offset
|
|
onInitialSnapshot(
|
|
initialSnapshot.messages,
|
|
initialSnapshot.hasMore,
|
|
initialSnapshot.beforeOffset,
|
|
undefined,
|
|
initialSnapshot.lifecycle
|
|
)
|
|
} else {
|
|
let lifecycle: NativeChatTurnLifecycle | undefined
|
|
const messages = await readIncrementalTranscriptMessages(
|
|
filePath,
|
|
state,
|
|
decode,
|
|
undefined,
|
|
decodeLifecycle ?? undefined,
|
|
(nextLifecycle) => {
|
|
lifecycle = nextLifecycle
|
|
},
|
|
gateAbort.signal
|
|
)
|
|
if (closed) {
|
|
return
|
|
}
|
|
onInitialSnapshot(messages, false, 0, undefined, lifecycle)
|
|
}
|
|
} else {
|
|
initialDrain = false
|
|
await readAndEmitAppends()
|
|
}
|
|
await finishSuccessfulDrain(current)
|
|
}
|
|
|
|
async function drain(runningChecked = false): Promise<void> {
|
|
if (closed) {
|
|
return
|
|
}
|
|
if (isWslPath && !runningChecked && !(await transcriptWatcherPathIsRunning(filePath))) {
|
|
nativeWatcher.invalidate()
|
|
initialErrorEmitted ||=
|
|
!closed && initialDrain && emitTranscriptUnavailableSnapshot(onInitialSnapshot)
|
|
return
|
|
}
|
|
if (reading) {
|
|
pendingReadRequested = true
|
|
return
|
|
}
|
|
reading = true
|
|
try {
|
|
do {
|
|
pendingReadRequested = false
|
|
try {
|
|
await drainOnce()
|
|
} catch (error) {
|
|
// Why: unlink/recreate can detach fs.watch from the pathname. Keep one
|
|
// capped-backoff retry alive until a successor appears or we unsubscribe.
|
|
// A still-pending initial drain also surfaces one error snapshot so a
|
|
// watching client isn't stranded at 'loading' when the read keeps
|
|
// throwing; initialDrain stays true so a recovered read can still win.
|
|
initialErrorEmitted ||=
|
|
!closed &&
|
|
initialDrain &&
|
|
emitTranscriptUnavailableSnapshot(
|
|
onInitialSnapshot,
|
|
error instanceof WslTranscriptFsError ? error.message : 'Transcript unavailable'
|
|
)
|
|
if (!isWslPath) {
|
|
scheduleRotationRetry()
|
|
}
|
|
break
|
|
}
|
|
} while (pendingReadRequested && !closed)
|
|
} finally {
|
|
reading = false
|
|
}
|
|
}
|
|
|
|
async function reconcileKnownRunning(): Promise<void> {
|
|
if (closed) {
|
|
return
|
|
}
|
|
try {
|
|
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
|
if (closed) {
|
|
return
|
|
}
|
|
const versionChanged =
|
|
watchedVersion === null || transcriptFileVersionChanged(current, watchedVersion)
|
|
if (versionChanged || current.size !== state.offset || nativeWatcher.needsRebind()) {
|
|
await drain(true)
|
|
}
|
|
} catch {
|
|
// WSL retries wait for the next shared running-state observation.
|
|
await (isWslPath ? undefined : drain())
|
|
}
|
|
}
|
|
|
|
const scheduler = createTranscriptWatchScheduler({
|
|
debounceMs: args.debounceMs,
|
|
reconciliationIntervalMs: args.reconciliationIntervalMs,
|
|
drain: () => void drain(),
|
|
reconcile: reconcileKnownRunning
|
|
})
|
|
const nativeWatcher = createRunningGuardedTranscriptNativeWatcher(
|
|
filePath,
|
|
() => scheduler.scheduleEventDrain(),
|
|
scheduleRotationRetry
|
|
)
|
|
|
|
nativeWatcher.bind()
|
|
const stopWslObservation = isWslPath
|
|
? observeWslTranscriptRunningState(
|
|
filePath,
|
|
() => reconcileKnownRunning(),
|
|
() => nativeWatcher.invalidate()
|
|
)
|
|
: () => {}
|
|
trackActiveNativeChatWatcher(1)
|
|
if (!isWslPath) {
|
|
scheduler.startReconciliation()
|
|
}
|
|
scheduler.scheduleEventDrain()
|
|
|
|
return {
|
|
watching: true,
|
|
unsubscribe: () => {
|
|
if (closed) {
|
|
return
|
|
}
|
|
closed = true
|
|
gateAbort.abort(new Error('Native Chat transcript watcher unsubscribed'))
|
|
scheduler.dispose()
|
|
stopWslObservation()
|
|
nativeWatcher.dispose()
|
|
trackActiveNativeChatWatcher(-1)
|
|
}
|
|
}
|
|
}
|