refactor(x): finish turn-spine adoption for headless surfaces

Background-task and live-note transcript views move from fetch-once to
the shared useAgentRunTranscript hook (useTurn over the turns:events
spine, with a one-shot legacy runs:fetch fallback for pre-migration run
ids), so an in-flight run's transcript now streams live. The headless
runner drains its execution stream — delivery rides the turn event bus,
and an unconsumed HotStream buffered every durable event until settle.
Deletes the caller-less runHeadlessTurn duplicate (HeadlessAgentRunner
is the implementation; session-design.md now says so) and the orphaned
web-search skill file that was never registered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-09 14:59:22 +05:30
parent 248bb0d1a1
commit f916cb1d70
12 changed files with 132 additions and 180 deletions

View file

@ -15,7 +15,8 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
import { formatRelativeTime } from '@/lib/relative-time'
import { toast } from '@/lib/toast'
import type { ConversationItem } from '@/lib/chat-conversation'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { fetchAgentRunTranscript } from '@/lib/agent-transcript'
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
import { CompactConversation } from '@/components/compact-conversation'
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
import { HtmlFileViewer } from '@/components/html-file-viewer'
@ -1116,29 +1117,9 @@ function RunTranscriptView({
isInFlight: boolean
onBack: () => void
}) {
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
void (async () => {
try {
const t = await fetchAgentRunTranscript(runId)
if (cancelled) return
setTranscript(t)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
setTranscript(null)
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [runId])
// Live via the turns:events spine: an in-flight run's transcript streams
// in as the agent works; settled runs render from one snapshot fetch.
const { transcript, loading, error } = useAgentRunTranscript(runId)
const summary = transcript ?? undefined
const items: ConversationItem[] = transcript?.items ?? []

View file

@ -13,7 +13,7 @@ import {
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
import { formatRelativeTime } from '@/lib/relative-time'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
import { CompactConversation } from '@/components/compact-conversation'
export type OpenLiveNotePanelDetail = {
@ -659,37 +659,14 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
}
function LastRunTab({ live }: { live: LiveNote }) {
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loadingRun, setLoadingRun] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const runId = live.lastRunId ?? null
useEffect(() => {
if (!runId) {
setTranscript(null)
setFetchError(null)
setLoadingRun(false)
return
}
let cancelled = false
setLoadingRun(true)
setFetchError(null)
void (async () => {
try {
const t = await fetchAgentRunTranscript(runId)
if (cancelled) return
setTranscript(t)
} catch (err) {
if (cancelled) return
setFetchError(err instanceof Error ? err.message : String(err))
setTranscript(null)
} finally {
if (!cancelled) setLoadingRun(false)
}
})()
return () => { cancelled = true }
}, [runId])
// Live via the turns:events spine: an in-flight run's transcript streams
// in as the agent works; settled runs render from one snapshot fetch.
const {
transcript,
loading: loadingRun,
error: fetchError,
} = useAgentRunTranscript(runId)
if (!runId) {
return (

View file

@ -0,0 +1,61 @@
import { useEffect, useMemo, useState } from 'react'
import {
runToTranscript,
turnStateToTranscript,
type AgentRunTranscript,
} from '@/lib/agent-transcript'
import { useTurn } from '@/hooks/use-turn'
export interface UseAgentRunTranscriptResult {
transcript: AgentRunTranscript | null
loading: boolean
error: string | null
}
// Live transcript of a headless agent run (background task, live note) by
// run id. Run ids are turn ids since the runs→turns migration, so useTurn
// keeps the transcript live via the turns:events spine; ids whose turn
// snapshot definitively fails (pre-migration legacy files) fall back to one
// runs:fetch. Callers open this for runs that already exist, so a failed
// snapshot means "not a turn", not "not created yet" — maxRetries 0 makes
// the legacy fallback immediate.
export function useAgentRunTranscript(
runId: string | undefined | null,
): UseAgentRunTranscriptResult {
const id = runId ?? undefined
const { state, error: turnError, snapshotFailed } = useTurn(id, { maxRetries: 0 })
const [legacy, setLegacy] = useState<AgentRunTranscript | null>(null)
const [legacyError, setLegacyError] = useState<string | null>(null)
useEffect(() => {
setLegacy(null)
setLegacyError(null)
if (!id || !snapshotFailed) {
return
}
let alive = true
window.ipc
.invoke('runs:fetch', { runId: id })
.then((run) => {
if (alive) setLegacy(runToTranscript(run))
})
.catch((err: unknown) => {
if (alive) setLegacyError(err instanceof Error ? err.message : String(err))
})
return () => {
alive = false
}
}, [id, snapshotFailed])
const transcript = useMemo(() => {
if (id && state) {
return turnStateToTranscript(id, state)
}
return legacy
}, [id, state, legacy])
const error = transcript ? null : (turnError ?? legacyError)
const loading = !!id && !transcript && !error
return { transcript, loading, error }
}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { TurnState } from '@x/shared/src/turns.js'
import { subscribeTurnFeed } from '@/lib/turn-feed'
import { followTurn } from '@/lib/turn-follower'
@ -6,6 +6,10 @@ import { followTurn } from '@/lib/turn-follower'
export interface UseTurnResult {
state: TurnState | null
error: string | null
// Snapshot retries exhausted with no state — the id may not be a turn at
// all (e.g. a pre-migration legacy run id). Clears if a later feed event
// recovers the turn.
snapshotFailed: boolean
}
// Live view of one turn by id: snapshot via sessions:getTurn, then durable
@ -14,20 +18,25 @@ export interface UseTurnResult {
// sub-agents.
export function useTurn(
turnId: string | undefined,
opts?: { enabled?: boolean },
opts?: { enabled?: boolean; maxRetries?: number },
): UseTurnResult {
const enabled = opts?.enabled ?? true
const maxRetries = opts?.maxRetries
const [state, setState] = useState<TurnState | null>(null)
const [error, setError] = useState<string | null>(null)
const [snapshotFailed, setSnapshotFailed] = useState(false)
const lastTurnId = useRef<string | undefined>(undefined)
useEffect(() => {
if (!turnId) {
// A different turn means the previous turn's state is stale; a mere
// enabled toggle keeps the last rendered state while hidden.
if (turnId !== lastTurnId.current) {
lastTurnId.current = turnId
setState(null)
setError(null)
return
setSnapshotFailed(false)
}
if (!enabled) {
// Keep the last rendered state while hidden; re-enabling refetches.
if (!turnId || !enabled) {
return
}
return followTurn(turnId, {
@ -36,10 +45,13 @@ export function useTurn(
onState: (next) => {
setState(next)
setError(null)
setSnapshotFailed(false)
},
onError: (message) => setError(message),
onSnapshotFailed: () => setSnapshotFailed(true),
...(maxRetries === undefined ? {} : { maxRetries }),
})
}, [turnId, enabled])
}, [turnId, enabled, maxRetries])
return { state, error }
return { state, error, snapshotFailed }
}

View file

@ -42,6 +42,7 @@ function harness(fetches: Array<TEvent[] | Error>) {
let unsubscribed = false
const states: TurnState[] = []
const errors: string[] = []
const snapshotFailures: string[] = []
const deps: TurnFollowerDeps = {
fetchTurn,
subscribe: (fn) => {
@ -53,6 +54,7 @@ function harness(fetches: Array<TEvent[] | Error>) {
},
onState: (state) => states.push(state),
onError: (message) => errors.push(message),
onSnapshotFailed: (message) => snapshotFailures.push(message),
maxRetries: 0,
}
return {
@ -60,6 +62,7 @@ function harness(fetches: Array<TEvent[] | Error>) {
fetchTurn,
states,
errors,
snapshotFailures,
emit: (event: TurnBusEvent) => listener?.(event),
get unsubscribed() {
return unsubscribed
@ -155,6 +158,18 @@ describe('followTurn', () => {
detach()
})
it('reports snapshot failure once retries are exhausted', async () => {
const h = harness([new Error('turn not found: legacy run id')])
const detach = followTurn(TURN, h.deps)
await flush()
// maxRetries 0: the first failure is definitive (legacy-run fallback
// hinges on this signal firing).
expect(h.snapshotFailures).toEqual(['turn not found: legacy run id'])
expect(h.states).toHaveLength(0)
detach()
})
it('stops delivering after detach and unsubscribes from the feed', async () => {
const full = log()
const h = harness([full.slice(0, 2)])

View file

@ -23,6 +23,10 @@ export interface TurnFollowerDeps {
subscribe: (listener: (event: TurnBusEvent) => void) => () => void
onState: (state: TurnState) => void
onError: (message: string) => void
// Fired when snapshot retries are exhausted (the id may not be a turn at
// all — e.g. a pre-migration legacy run). A later feed event still retries
// and can recover.
onSnapshotFailed?: (message: string) => void
retryDelayMs?: number
maxRetries?: number
}
@ -89,7 +93,7 @@ export function followTurn(turnId: string, deps: TurnFollowerDeps): () => void {
}
}
publish()
} catch {
} catch (err) {
if (!alive) return
// Turn file may not exist yet (subscription raced creation) or the
// fetch failed transiently.
@ -97,6 +101,8 @@ export function followTurn(turnId: string, deps: TurnFollowerDeps): () => void {
if (retries < maxRetries) {
retries += 1
retryTimer = setTimeout(() => void fetchSnapshot(), retryDelayMs)
} else {
deps.onSnapshotFailed?.(err instanceof Error ? err.message : String(err))
}
} finally {
fetching = false