mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(native-chat): say when a transcript is unreadable on this host instead of spinning
An agent running in an SSH worktree writes its transcript on the remote machine, so the desktop resolves nothing and subscribeNativeChatTranscript re-polls forever with no frame. The client's loading state only clears on a stream frame, so the chat view spins indefinitely with no error (#13663). Emit one advisory snapshot once an unresolved transcript passes a deadline. It is deliberately non-terminal: the resolve poll keeps running and a later real snapshot replaces it, so a session that is merely slow to flush its first JSONL line still loads (#8401). The deadline is generous for the same reason -- a late message costs less than a false one on a healthy session. This lands in the shared subscribe path, so desktop, mobile, web and headless all get it without a wire change; clients already render an error carried on the snapshot frame.
This commit is contained in:
@@ -28,6 +28,9 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
|
||||
debounceMs?: number
|
||||
/** Test-only override for the production resolve-poll backoff. */
|
||||
resolvePollIntervalMs?: number
|
||||
/** Test-only override for how long an unresolved transcript stays silent
|
||||
* before the first "unavailable" snapshot. */
|
||||
unresolvedNoticeMs?: number
|
||||
/** Test-only override for the host-side watcher reconciliation interval. */
|
||||
reconciliationIntervalMs?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
async function pendingFilePath(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unresolved-'))
|
||||
tempRoots.push(root)
|
||||
return join(root, 'rollout.jsonl')
|
||||
}
|
||||
|
||||
function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string {
|
||||
return `${JSON.stringify({
|
||||
type: role,
|
||||
uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role, content: [{ type: 'text', text }] }
|
||||
})}\n`
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('waitFor timed out')
|
||||
}
|
||||
|
||||
type Snapshot = { messages: NativeChatMessage[]; error?: string }
|
||||
|
||||
// #13663: an agent in an SSH worktree writes its transcript on the remote host,
|
||||
// so this host resolves nothing and the poll spins forever with no frame — the
|
||||
// client sits on a spinner indefinitely. The poll is still correct for a
|
||||
// not-yet-flushed local session (#8401), so the notice must be advisory.
|
||||
describe('subscribeNativeChatTranscript unresolved-transcript notice (#13663)', () => {
|
||||
it('reports the transcript as unavailable once the deadline passes', async () => {
|
||||
const snapshots: Snapshot[] = []
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath: await pendingFilePath(),
|
||||
onAppend: () => {},
|
||||
onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) =>
|
||||
snapshots.push({ messages, error }),
|
||||
resolvePollIntervalMs: 10,
|
||||
unresolvedNoticeMs: 30
|
||||
})
|
||||
|
||||
await waitFor(() => snapshots.length > 0)
|
||||
expect(snapshots[0]?.messages).toEqual([])
|
||||
expect(snapshots[0]?.error).toMatch(/unavailable on this host/i)
|
||||
|
||||
sub.unsubscribe()
|
||||
})
|
||||
|
||||
it('stays silent before the deadline so a slow first flush is not called an error', async () => {
|
||||
const snapshots: Snapshot[] = []
|
||||
const filePath = await pendingFilePath()
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: () => {},
|
||||
onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) =>
|
||||
snapshots.push({ messages, error }),
|
||||
resolvePollIntervalMs: 10,
|
||||
unresolvedNoticeMs: 10_000
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
expect(snapshots).toHaveLength(0)
|
||||
|
||||
sub.unsubscribe()
|
||||
})
|
||||
|
||||
// The notice must not be a terminal state: the poll keeps running, so a
|
||||
// transcript that shows up late still loads.
|
||||
it('keeps polling after the notice and still tails the file once it appears', async () => {
|
||||
const snapshots: Snapshot[] = []
|
||||
const appended: NativeChatMessage[] = []
|
||||
const filePath = await pendingFilePath()
|
||||
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onAppend: (messages) => appended.push(...messages),
|
||||
onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) =>
|
||||
snapshots.push({ messages, error }),
|
||||
debounceMs: 5,
|
||||
resolvePollIntervalMs: 10,
|
||||
unresolvedNoticeMs: 30
|
||||
})
|
||||
|
||||
await waitFor(() => snapshots.some((s) => s.error))
|
||||
|
||||
await writeFile(filePath, claudeLine('u-1', 'user', 'late but real'))
|
||||
await waitFor(() => appended.some((m) => m.id === 'u-1') || snapshots.length > 1)
|
||||
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(1)
|
||||
// Exactly one advisory frame — it must not repeat on every poll tick.
|
||||
expect(snapshots.filter((s) => s.error)).toHaveLength(1)
|
||||
|
||||
sub.unsubscribe()
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,16 @@ async function attemptInstall(
|
||||
const INITIAL_RESOLVE_POLL_MS = 500
|
||||
const MAX_RESOLVE_POLL_MS = 5_000
|
||||
const FALLBACK_RESOLVE_POLL_MS = 5_000
|
||||
// Why: a transcript this host can never open (the agent runs on an SSH remote,
|
||||
// so the file is on that machine — #13663) is indistinguishable here from one
|
||||
// that simply has not flushed yet, and both poll forever. Past this deadline
|
||||
// say so instead of spinning silently. Deliberately longer than the observed
|
||||
// worst-case first flush (#8401): the notice is non-terminal, so paying a
|
||||
// generous wait costs a late message, while being too eager costs a false one
|
||||
// on a healthy session.
|
||||
const UNRESOLVED_NOTICE_MS = 60_000
|
||||
const UNRESOLVED_TRANSCRIPT_MESSAGE =
|
||||
'Transcript unavailable on this host. If this agent runs on a remote machine, its transcript lives there.'
|
||||
|
||||
function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null {
|
||||
const path = args.transcriptPath?.trim()
|
||||
@@ -81,8 +91,23 @@ function subscribeViaResolvePoll(
|
||||
// Latches only once a frame was actually emitted, so a subscriber without the
|
||||
// callback can't suppress it for a later one.
|
||||
let gateErrorEmitted = false
|
||||
const startedAt = Date.now()
|
||||
const unresolvedNoticeMs = args.unresolvedNoticeMs ?? UNRESOLVED_NOTICE_MS
|
||||
const resolveController = new AbortController()
|
||||
|
||||
/** Non-terminal: the poll keeps running and a later real snapshot replaces
|
||||
* this, so a slow-to-flush session still recovers (#8401). */
|
||||
function emitUnresolvedNotice(): void {
|
||||
if (gateErrorEmitted || !args.onInitialSnapshot) {
|
||||
return
|
||||
}
|
||||
if (Date.now() - startedAt < unresolvedNoticeMs) {
|
||||
return
|
||||
}
|
||||
gateErrorEmitted = true
|
||||
args.onInitialSnapshot([], false, 0, UNRESOLVED_TRANSCRIPT_MESSAGE)
|
||||
}
|
||||
|
||||
function scheduleAttempt(): void {
|
||||
if (closed) {
|
||||
return
|
||||
@@ -167,6 +192,7 @@ function subscribeViaResolvePoll(
|
||||
installed = result
|
||||
return
|
||||
}
|
||||
emitUnresolvedNotice()
|
||||
scheduleAttempt()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user