From 68c699687eec407c6ce3ee8b5f328859edb3fe19 Mon Sep 17 00:00:00 2001 From: Constantine Date: Thu, 13 Aug 2026 02:43:31 +0300 Subject: [PATCH] fix(ui): keep selected run lifecycle and logs current (#10169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip's agent-run detail view combines live lifecycle events, detail queries, and persisted shell logs. > - Queued runs do not have a log reference yet, so polling the log endpoint returns expected-but-noisy 404s. > - Simply disabling queued log polling can strand the selected detail cache at `queued` or leave terminal lifecycle fields stale. > - The detail cache therefore needs both live lifecycle patches and an HTTP fallback while the run is active. > - Both the selected-run query and the shared transcript hook must avoid reading persisted logs before execution starts. > - This pull request disables queued log polling in both paths, patches lifecycle fields, invalidates authoritative detail data, and polls run state only while active. > - The benefit is quiet queued runs, reliable queued-to-running transitions, and complete final log handoff. ## Linked Issues or Issue Description No exact duplicate found. I searched open PRs for `queued run`, `log polling`, and `run lifecycle`; related results addressed stale queued execution or dashboards rather than selected-run log/detail synchronization. **What happened?** Opening or rendering a queued run caused repeated `GET /api/heartbeat-runs/:id/log` 404s from both the selected detail and shared transcript hydration paths. A queued-only detail guard could also leave the selected run stuck at `queued` when live delivery was missed, or stop before fetching final `logRef` and terminal fields. **Expected behavior** Queued runs should not request unavailable logs. The selected detail should transition through running to terminal via live events or bounded HTTP fallback, refresh authoritative detail data, fetch final logs, and then stop polling. **Steps to reproduce** 1. Open the detail page for a run waiting behind the agent concurrency limit. 2. Observe repeated log-endpoint 404s while status is queued. 3. Let the run start with WebSocket delivery unavailable or delayed. 4. Observe stale detail state without a run-detail fallback. **Environment** - Paperclip base: `14f20be92b86a49ff2c35495e5b0fa4d719998ef` - Deployment: self-hosted, built from source - Adapter scope: visible with Hermes-backed agents but not adapter-specific - [x] I searched open PRs for queued-run log polling and selected-run lifecycle synchronization; no exact duplicate was found. ## What Changed - Poll shell logs only while a run is `running`, never while `queued`. - Defer shared persisted-transcript hydration and its live WebSocket until a queued run becomes `running`; terminal runs still receive one persisted-log hydration attempt. - Patch selected run status, invocation metadata, errors, and start/finish times from lifecycle events. - Invalidate selected run detail on lifecycle events to hydrate authoritative `logRef`, result, usage, and excerpts. - Poll run detail every 5 seconds while queued and every 15 seconds while running, then stop at terminal status. - Add regression coverage for queued/running/terminal polling and lifecycle cache handoff. ## Verification - `pnpm exec vitest run ui/src/context/LiveUpdatesProvider.test.ts ui/src/pages/AgentDetail.progress.test.ts` — 42 passed. - `pnpm exec vitest run ui/src/components/transcript/useLiveRunTranscripts.test.tsx` — 11 passed. - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/ui build` - Exact final diff independently reviewed: **APPROVE**, no blocking findings. - UI-only local cutover completed with the Paperclip server PID unchanged. ## Risks - The selected run performs one lightweight detail GET every 5 seconds while queued and every 15 seconds while running if it remains open. Polling stops at terminal status. - Lifecycle events still patch immediately; polling is only the fallback and authoritative hydration path. - No API, schema, or migration changes. > This is a bug fix, not roadmap feature work. ## Model Used OpenAI Codex `gpt-5.6-sol`, with tool use, code execution, repository inspection, and independent read-only review agents. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked/described the search above - [x] I have described the issue in-PR following the bug template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have considered documentation; no user-facing documentation change is required - [x] I have considered and documented risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: cucurigoo --- .../transcript/useLiveRunTranscripts.test.tsx | 36 +++++++++++ .../transcript/useLiveRunTranscripts.ts | 15 +++-- ui/src/context/LiveUpdatesProvider.test.ts | 64 ++++++++++++++++++- ui/src/context/LiveUpdatesProvider.tsx | 27 +++++++- ui/src/pages/AgentDetail.progress.test.ts | 18 ++++++ ui/src/pages/AgentDetail.tsx | 26 ++++++-- 6 files changed, 171 insertions(+), 15 deletions(-) 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(() => {