diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index 29d3364770..eac7a5a01b 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -234,6 +234,42 @@ describe("useLiveRunTranscripts", () => { container.remove(); }); + it("does not request persisted logs until a queued run starts", async () => { + function Harness({ status }: { status: "queued" | "running" }) { + useLiveRunTranscripts({ + companyId: "company-1", + runs: [{ id: "run-queued", status, adapterType: "codex_local" }], + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(logMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(logMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith("run-queued", 0, 256_000); + expect(FakeWebSocket.instances).toHaveLength(1); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("can hydrate active runs without opening the live event socket", async () => { function Harness() { useLiveRunTranscripts({ diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 8cd2d1e46d..4a5e9afa79 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -72,6 +72,10 @@ function isTerminalStatus(status: string): boolean { return status === "failed" || status === "timed_out" || status === "cancelled" || status === "interrupted" || status === "succeeded"; } +function canReadPersistedLog(run: RunTranscriptSource): boolean { + return run.status === "running" || isTerminalStatus(run.status); +} + function runKnownLogBytes(run: RunTranscriptSource): number | null { const bytes = run.status === "queued" ? run.logBytes @@ -157,7 +161,7 @@ export function useLiveRunTranscripts({ const runById = useMemo(() => new Map(normalizedRuns.map((run) => [run.id, run])), [normalizedRuns]); const activeRunIds = useMemo( - () => new Set(normalizedRuns.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)), + () => new Set(normalizedRuns.filter((run) => run.status === "running").map((run) => run.id)), [normalizedRuns], ); const runIdsKey = useMemo( @@ -276,7 +280,8 @@ export function useLiveRunTranscripts({ }, [normalizedRuns, pruneTick]); useEffect(() => { - if (normalizedRuns.length === 0) return; + const readableRuns = normalizedRuns.filter(canReadPersistedLog); + if (readableRuns.length === 0) return; let cancelled = false; @@ -315,11 +320,11 @@ export function useLiveRunTranscripts({ }; const readAll = async () => { - await Promise.all(normalizedRuns.map((run) => readRunLog(run))); + await Promise.all(readableRuns.map((run) => readRunLog(run))); }; void readAll(); - const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status)); + const activeRuns = readableRuns.filter((run) => run.status === "running"); // The realtime websocket is the primary live source when enabled, so the // recurring poll only needs to run as a slow fallback rather than doubling // the live update work every couple of seconds. @@ -513,7 +518,7 @@ export function useLiveRunTranscripts({ return { transcriptByRun, - isInitialHydrating: normalizedRuns.some((run) => !hydratedRunIds.has(run.id)), + isInitialHydrating: normalizedRuns.some((run) => canReadPersistedLog(run) && !hydratedRunIds.has(run.id)), hasOutputForRun(runId: string) { return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true; }, diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index f0ab8cd67b..7f25ccc7e7 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -143,6 +143,25 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("refreshes the selected run detail after lifecycle events", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + }; + + __liveUpdatesTestUtils.invalidateHeartbeatQueries( + queryClient as never, + "company-1", + { runId: "run-1", agentId: "agent-1" }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.runDetail("run-1"), + }); + }); + it("applies heartbeat progress payloads directly to cached visible issue runs", () => { const cache = new Map([ [JSON.stringify(queryKeys.liveRuns("company-1")), [{ id: "run-1", currentStatusMessage: null }]], @@ -1088,8 +1107,10 @@ describe("LiveUpdatesProvider run lifecycle toasts", () => { describe("applyRunLifecycleToCompanyLiveRuns", () => { function makeClient(initial: Array<{ id: string; status: string }>) { + const initialDetail = initial.find((run) => run.id === "run-1"); const cache = new Map([ [JSON.stringify(queryKeys.liveRuns("company-1")), initial], + [JSON.stringify(queryKeys.runDetail("run-1")), initialDetail], ]); const client = { getQueryData: (key: unknown) => cache.get(JSON.stringify(key)), @@ -1100,18 +1121,31 @@ describe("applyRunLifecycleToCompanyLiveRuns", () => { }, }; const read = () => cache.get(JSON.stringify(queryKeys.liveRuns("company-1"))); - return { client, read }; + const readDetail = () => cache.get(JSON.stringify(queryKeys.runDetail("run-1"))); + return { client, read, readDetail }; } it("removes a run on a terminal status (patched, no refetch needed)", () => { - const { client, read } = makeClient([{ id: "run-1", status: "running" }, { id: "run-2", status: "running" }]); + const { client, read, readDetail } = makeClient([{ id: "run-1", status: "running" }, { id: "run-2", status: "running" }]); const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( client as never, "company-1", - { runId: "run-1", status: "succeeded" }, + { + runId: "run-1", + status: "succeeded", + finishedAt: "2026-07-24T10:00:00.000Z", + error: null, + errorCode: null, + }, ); expect(patched).toBe(true); expect(read()).toEqual([{ id: "run-2", status: "running" }]); + expect(readDetail()).toEqual(expect.objectContaining({ + status: "succeeded", + finishedAt: "2026-07-24T10:00:00.000Z", + error: null, + errorCode: null, + })); }); it("patches status in place for a run already in the list", () => { @@ -1125,6 +1159,30 @@ describe("applyRunLifecycleToCompanyLiveRuns", () => { expect(read()).toEqual([{ id: "run-1", status: "running" }]); }); + it("patches the selected run detail when a queued run starts", () => { + const { client, readDetail } = makeClient([{ id: "run-1", status: "queued" }]); + + __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( + client as never, + "company-1", + { + runId: "run-1", + status: "running", + startedAt: "2026-07-24T09:59:00.000Z", + error: null, + errorCode: null, + }, + ); + + expect(readDetail()).toEqual(expect.objectContaining({ + id: "run-1", + status: "running", + startedAt: "2026-07-24T09:59:00.000Z", + error: null, + errorCode: null, + })); + }); + it("reports not-patched for a genuinely new run so the caller refetches", () => { const { client, read } = makeClient([{ id: "run-1", status: "running" }]); const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index b7ffeec361..586c1272c7 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -10,7 +10,7 @@ import { import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; import { createCoalescingQueryClient, createInvalidationBatcher } from "../lib/query-invalidation-batcher"; import { patchRunStatusInList, removeRunFromList } from "../lib/live-runs-cache"; -import type { Agent, Issue, IssueComment, LiveEvent } from "@paperclipai/shared"; +import type { Agent, HeartbeatRun, Issue, IssueComment, LiveEvent } from "@paperclipai/shared"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import type { CompanyUserDirectoryResponse } from "../api/access"; @@ -868,6 +868,26 @@ function applyRunLifecycleToCompanyLiveRuns( const status = readString(payload.status); if (!runId || !status) return false; + queryClient.setQueryData( + queryKeys.runDetail(runId), + (current: HeartbeatRun | undefined) => { + if (!current) return current; + const has = (key: string) => Object.prototype.hasOwnProperty.call(payload, key); + return { + ...current, + status: status as HeartbeatRun["status"], + ...(has("invocationSource") + ? { invocationSource: readString(payload.invocationSource) ?? current.invocationSource } + : {}), + ...(has("triggerDetail") ? { triggerDetail: readString(payload.triggerDetail) } : {}), + ...(has("error") ? { error: readString(payload.error) } : {}), + ...(has("errorCode") ? { errorCode: readString(payload.errorCode) } : {}), + ...(has("startedAt") ? { startedAt: readString(payload.startedAt) } : {}), + ...(has("finishedAt") ? { finishedAt: readString(payload.finishedAt) } : {}), + }; + }, + ); + if (TERMINAL_RUN_STATUSES.has(status)) { queryClient.setQueryData( queryKeys.liveRuns(companyId), @@ -909,6 +929,10 @@ function invalidateHeartbeatQueries( queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) }); } + const runId = readString(payload.runId); + if (runId) { + queryClient.invalidateQueries({ queryKey: queryKeys.runDetail(runId) }); + } } function invalidateHeartbeatProgressQueries( @@ -1287,6 +1311,7 @@ export const __liveUpdatesTestUtils = { applyRunLiveStatusPatchToCaches, hydrateVisibleIssueComment, invalidateActivityQueries, + invalidateHeartbeatQueries, invalidateHeartbeatProgressQueries, invalidateVisibleIssueRunQueries, readRunLiveStatusPatchFromPayload, diff --git a/ui/src/pages/AgentDetail.progress.test.ts b/ui/src/pages/AgentDetail.progress.test.ts index 83c2623556..e7d017d26d 100644 --- a/ui/src/pages/AgentDetail.progress.test.ts +++ b/ui/src/pages/AgentDetail.progress.test.ts @@ -5,6 +5,8 @@ import { queryKeys } from "../lib/queryKeys"; import { buildHeartbeatProgressLogLine, heartbeatProgressLogLineKey, + runDetailRefetchIntervalMs, + shouldPollRunShellLog, syncAgentRouteAfterRename, } from "./AgentDetail"; @@ -63,6 +65,22 @@ describe("heartbeatProgressLogLineKey", () => { }); }); +describe("shouldPollRunShellLog", () => { + it("polls only after a queued run has started running", () => { + expect(shouldPollRunShellLog("queued")).toBe(false); + expect(shouldPollRunShellLog("running")).toBe(true); + expect(shouldPollRunShellLog("succeeded")).toBe(false); + }); +}); + +describe("runDetailRefetchIntervalMs", () => { + it("polls run state only while the selected run remains queued", () => { + expect(runDetailRefetchIntervalMs("queued")).toBe(5_000); + expect(runDetailRefetchIntervalMs("running")).toBe(15_000); + expect(runDetailRefetchIntervalMs("succeeded")).toBe(false); + }); +}); + describe("syncAgentRouteAfterRename", () => { it("replaces stale agent routes after a rename changes the URL key", () => { const queryClient = new QueryClient(); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index b76eb60975..5b87a80505 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -364,6 +364,16 @@ export function heartbeatProgressLogLineKey(line: RunLogChunk): string { return `${line.ts}\u0000${line.stream}\u0000${line.chunk}`; } +export function shouldPollRunShellLog(status: HeartbeatRun["status"]): boolean { + return status === "running"; +} + +export function runDetailRefetchIntervalMs(status: HeartbeatRun["status"]): 5000 | 15000 | false { + if (status === "queued") return 5000; + if (status === "running") return 15000; + return false; +} + export function RunInvocationCard({ payload, censorUsernameInLogs, @@ -3079,6 +3089,9 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } queryKey: queryKeys.runDetail(initialRun.id), queryFn: () => heartbeatsApi.get(initialRun.id), enabled: Boolean(initialRun.id), + refetchInterval: (query) => runDetailRefetchIntervalMs( + (query.state.data ?? initialRun).status, + ), }); const run = hydratedRun ?? initialRun; const metrics = runMetrics(run); @@ -3594,6 +3607,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin distanceFromBottom: Number.POSITIVE_INFINITY, }); const isLive = run.status === "running" || run.status === "queued"; + const shouldPollShellLog = shouldPollRunShellLog(run.status); const { data: workspaceOperations = [] } = useQuery({ queryKey: queryKeys.runWorkspaceOperations(run.id), queryFn: () => heartbeatsApi.workspaceOperations(run.id), @@ -3745,7 +3759,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin setLoadingMoreLog(false); setLogError(null); - if (!run.logRef && !isLive) { + if (!run.logRef && !shouldPollShellLog) { setLogLoading(false); return () => { cancelled = true; @@ -3760,10 +3774,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin appendLogContent(result.content, result.nextOffset === undefined); const next = result.nextOffset ?? result.content.length; setLogOffset(next); - setHasMoreLog(!isLive && result.nextOffset !== undefined); + setHasMoreLog(!shouldPollShellLog && result.nextOffset !== undefined); } catch (err) { if (!cancelled) { - if (isLive && isRunLogUnavailable(err)) { + if (shouldPollShellLog && isRunLogUnavailable(err)) { setLogLoading(false); return; } @@ -3778,7 +3792,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin return () => { cancelled = true; }; - }, [run.id, run.logRef, run.logBytes, isLive]); + }, [run.id, run.logRef, run.logBytes, shouldPollShellLog]); async function loadMorePersistedLog() { if (loadingMoreLog || !hasMoreLog) return; @@ -3816,7 +3830,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin // Poll shell log for running runs useEffect(() => { - if (!isLive || isStreamingConnected) return; + if (!shouldPollShellLog || isStreamingConnected) return; const interval = setInterval(async () => { try { const result = await heartbeatsApi.log(run.id, logOffset, 256_000); @@ -3834,7 +3848,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin } }, 2000); return () => clearInterval(interval); - }, [run.id, isLive, isStreamingConnected, logOffset]); + }, [run.id, shouldPollShellLog, isStreamingConnected, logOffset]); // Stream live updates from websocket (primary path for running runs). useEffect(() => {