diff --git a/server/src/__tests__/heartbeat-run-log.test.ts b/server/src/__tests__/heartbeat-run-log.test.ts
index 9eb6eda9c79..df0f9f3cfbb 100644
--- a/server/src/__tests__/heartbeat-run-log.test.ts
+++ b/server/src/__tests__/heartbeat-run-log.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
-import { compactRunLogChunk } from "../services/heartbeat.js";
+import type { Db } from "@paperclipai/db";
+import { compactRunLogChunk, heartbeatService } from "../services/heartbeat.js";
describe("compactRunLogChunk", () => {
it("redacts inline base64 image data from structured log chunks", () => {
@@ -39,3 +40,24 @@ describe("compactRunLogChunk", () => {
expect(compacted).not.toContain("paperclip-flag-secret");
});
});
+
+describe("heartbeat run log reads", () => {
+ it("returns a pending empty payload when an existing run has no initialized log yet", async () => {
+ const service = heartbeatService({} as Db);
+
+ await expect(
+ service.readLog({
+ id: "run-pending-log",
+ companyId: "company-1",
+ logStore: null,
+ logRef: null,
+ }),
+ ).resolves.toEqual({
+ runId: "run-pending-log",
+ store: null,
+ logRef: null,
+ content: "",
+ pending: true,
+ });
+ });
+});
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 09a1e5fe7cb..b18f8b9f9f4 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -10116,7 +10116,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const run = typeof runOrLookup === "string" ? await getRunLogAccess(runOrLookup) : runOrLookup;
const runId = typeof runOrLookup === "string" ? runOrLookup : runOrLookup.id;
if (!run) throw notFound("Heartbeat run not found");
- if (!run.logStore || !run.logRef) throw notFound("Run log not found");
+ if (!run.logStore || !run.logRef) {
+ return {
+ runId,
+ store: run.logStore,
+ logRef: run.logRef,
+ content: "",
+ pending: true,
+ };
+ }
const result = await runLogStore.read(
{
diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts
index fbaad815d7e..0f4c4f0c361 100644
--- a/ui/src/api/heartbeats.ts
+++ b/ui/src/api/heartbeats.ts
@@ -84,7 +84,7 @@ export const heartbeatsApi = {
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
),
log: (runId: string, offset = 0, limitBytes = 256000) =>
- api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
+ api.get<{ runId: string; store: string | null; logRef: string | null; content: string; nextOffset?: number; pending?: boolean }>(
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
workspaceOperations: (runId: string) =>
diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
index 025d2f0ed0c..2422b1586e7 100644
--- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
+++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
@@ -81,6 +81,7 @@ describe("useLiveRunTranscripts", () => {
});
afterEach(() => {
+ vi.useRealTimers();
globalThis.WebSocket = OriginalWebSocket;
});
@@ -229,6 +230,44 @@ describe("useLiveRunTranscripts", () => {
container.remove();
});
+ it("stops polling active runs after a persisted-log 404", async () => {
+ vi.useFakeTimers();
+ logMock.mockReset();
+ logMock.mockRejectedValue(new ApiError("Run log not found", 404, { error: "Run log not found" }));
+
+ function Harness() {
+ useLiveRunTranscripts({
+ companyId: "company-1",
+ runs: [{ id: "run-stale", status: "running", adapterType: "codex_local" }],
+ logPollIntervalMs: 10,
+ });
+ 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).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(30);
+ await Promise.resolve();
+ });
+
+ expect(logMock).toHaveBeenCalledTimes(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 6c001e2fae4..a19f76a7099 100644
--- a/ui/src/components/transcript/useLiveRunTranscripts.ts
+++ b/ui/src/components/transcript/useLiveRunTranscripts.ts
@@ -113,7 +113,7 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set());
const pendingLogRowsByRunRef = useRef(new Map());
const logOffsetByRunRef = useRef(new Map());
- const missingTerminalLogRunIdsRef = useRef(new Set());
+ const missingLogRunIdsRef = useRef(new Set());
const transcriptCacheRef = useRef(new Map {
- if (missingTerminalLogRunIdsRef.current.has(run.id)) {
+ if (missingLogRunIdsRef.current.has(run.id)) {
return;
}
const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
@@ -232,8 +232,8 @@ export function useLiveRunTranscripts({
logOffsetByRunRef.current.set(run.id, offset + result.content.length);
}
} catch (error) {
- if (error instanceof ApiError && error.status === 404 && isTerminalStatus(run.status)) {
- missingTerminalLogRunIdsRef.current.add(run.id);
+ if (error instanceof ApiError && error.status === 404) {
+ missingLogRunIdsRef.current.add(run.id);
}
} finally {
if (!cancelled) {