Skip to content

Commit 5bf3612

Browse files
committed
fix(webapp): settle an investigation and its closing card in one write
The row settle, the transcript and the session state were three separate operations. Once the row was terminal, a failed transcript write left a card that read `in_progress` forever: the stale sweep only selects `in_progress` rows, so nothing was left to repair it. Both lanes now commit the pair atomically. The live turn hands its pending settlements to `persistTurn`, which upserts the revisions, appends their cards and writes the session in one transaction; the process-local entry survives until that commits, so a retried `onTurnComplete` still settles. The sweep goes through `settleInvestigationAndCloseCard`, whose rollback restores the `in_progress` row the sweep already selects. That also removes the last reader of the per-run `chatOwners` map, which had no `delete` and grew for the life of the worker: the failure record now travels in the transcript write, which needs no userId. Separately, the consented watch investigation gets the rolling step cache. Its ten-step `streamText` re-sent every accumulated tool output uncached; the breakpoint helper and the per-step cache telemetry now live in `step-cache.ts` and both lanes use them, wrapping any `prepareStep` the resolved options carry.
1 parent 29de34f commit 5bf3612

13 files changed

Lines changed: 577 additions & 214 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import { createTranscriptOrder, orderTranscript } from "./message-order";
2121
import { appendRunFilters } from "./navigate-target";
2222
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
2323
import type { AgentPageContext } from "./page-context-types";
24+
import {
25+
fetchChatTranscript,
26+
hasOpenInvestigation,
27+
pollSettledTranscript,
28+
} from "./settled-transcript";
2429
import { useAgentMessageQuota } from "./useAgentMessageQuota";
2530
import { useTriggerUriResolver } from "./useTriggerUriResolver";
2631
import { WatchChips, type WatchChip } from "./WatchChips";
@@ -300,13 +305,27 @@ export function DashboardAgentChat({
300305
aiStop();
301306
}, [transport, chatId, aiStop]);
302307

308+
// Read by the settle effect, which must not re-run when the transcript changes.
309+
const messagesRef = useRef(messages);
310+
messagesRef.current = messages;
311+
303312
const prevStatus = useRef(status);
304313
useEffect(() => {
305314
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
306315
const nowSettled = status === "ready" || status === "error";
307-
if (wasInFlight && nowSettled) onTurnSettled();
308316
prevStatus.current = status;
309-
}, [status, onTurnSettled]);
317+
if (!wasInFlight || !nowSettled) return;
318+
319+
onTurnSettled();
320+
// The terminal card is written to the chat row after the stream closes, so this
321+
// mounted panel would otherwise keep showing the last `in_progress` revision.
322+
if (!hasOpenInvestigation(messagesRef.current)) return;
323+
void pollSettledTranscript<UIMessage>({
324+
fetchTranscript: () => fetchChatTranscript(actionPath, chatId),
325+
apply: (merge) => setMessages((current) => merge(current)),
326+
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
327+
});
328+
}, [status, onTurnSettled, actionPath, chatId, setMessages]);
310329

311330
// Not cleared on unmount: the turn carries on server-side and reports again on remount.
312331
useEffect(() => {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { liveInvestigation } from "./progress-line";
2+
3+
/**
4+
* Re-reading the stored transcript once a turn settles.
5+
*
6+
* The turn's terminal records — a force-settled investigation card, a failure
7+
* record — are written to the chat row, not pushed as a stream chunk. An open panel
8+
* has already closed its stream by then, so without this it keeps rendering the last
9+
* `in_progress` revision and spins until the user reloads.
10+
*/
11+
12+
type Identified = { id: string };
13+
14+
/**
15+
* Append-only, keyed on the message id. Ids are stable (a settlement card is
16+
* `investigation-settlement:{id}:{revision}`), so re-reading the same transcript any
17+
* number of times can never produce a second copy of a card, and nothing already
18+
* rendered is reordered or replaced.
19+
*/
20+
export function mergeSettledMessages<T extends Identified>(current: T[], fetched: T[]): T[] {
21+
const missing = fetched.filter(
22+
(message) => !current.some((existing) => existing.id === message.id)
23+
);
24+
return missing.length === 0 ? current : [...current, ...missing];
25+
}
26+
27+
/** Whether the transcript still resolves to a card mid-investigation. */
28+
export function hasOpenInvestigation(messages: ReadonlyArray<unknown>): boolean {
29+
return liveInvestigation(messages as never) !== null;
30+
}
31+
32+
/**
33+
* The settlement is written in `onTurnComplete`, which runs AFTER the client's stream
34+
* closes, so the first re-read can legitimately land before it. Retry a few times,
35+
* then leave it: a reload and the between-turns sweep are both still backstops.
36+
*/
37+
export const SETTLE_REFETCH_DELAYS_MS = [200, 800, 2_500];
38+
39+
export async function pollSettledTranscript<T extends Identified>(deps: {
40+
fetchTranscript: () => Promise<T[] | null>;
41+
apply: (merge: (current: T[]) => T[]) => void;
42+
wait: (ms: number) => Promise<void>;
43+
delays?: ReadonlyArray<number>;
44+
}): Promise<void> {
45+
for (const delay of deps.delays ?? SETTLE_REFETCH_DELAYS_MS) {
46+
await deps.wait(delay);
47+
const fetched = await deps.fetchTranscript();
48+
if (!fetched) return;
49+
deps.apply((current) => mergeSettledMessages(current, fetched));
50+
// The stored transcript is the authority on whether anything is still open.
51+
if (!hasOpenInvestigation(fetched)) return;
52+
}
53+
}
54+
55+
export async function fetchChatTranscript<T extends Identified>(
56+
actionPath: string,
57+
chatId: string
58+
): Promise<T[] | null> {
59+
try {
60+
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(chatId)}`);
61+
if (!res.ok) return null;
62+
const data = (await res.json()) as { messages?: T[] };
63+
return data.messages ?? null;
64+
} catch (error) {
65+
console.error("Dashboard agent: failed to re-read the settled transcript", error);
66+
return null;
67+
}
68+
}

apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@
44
*/
55

66
import {
7-
appendChatMessageOnceByChatId,
8-
investigationSettlementMessage,
97
listStaleOpenInvestigations,
10-
settleInvestigationAsInconclusive,
8+
settleInvestigationAndCloseCard,
119
type Investigation,
12-
type InvestigationCardMessage,
13-
type SettledInvestigation,
10+
type SettledInvestigationCard,
1411
} from "@internal/dashboard-agent-db";
1512
import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts";
1613
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
@@ -40,10 +37,15 @@ export type InvestigationSweepDeps = {
4037
now?: () => Date;
4138
limit?: number;
4239
listStale?: (params: { olderThan: Date; limit: number }) => Promise<Investigation[]>;
43-
/** Settle one row. Null when it was no longer `in_progress`. */
44-
settle?: (params: { id: string; note: string }) => Promise<SettledInvestigation | null>;
45-
/** Append the closing card to the chat. False when that message id is already there. */
46-
closeCard?: (params: { chatId: string; message: InvestigationCardMessage }) => Promise<boolean>;
40+
/**
41+
* Settle one row and deliver its closing card as a single operation. Null when the
42+
* row was no longer `in_progress`.
43+
*/
44+
settleAndClose?: (params: {
45+
id: string;
46+
chatId: string;
47+
note: string;
48+
}) => Promise<SettledInvestigationCard | null>;
4749
};
4850

4951
/**
@@ -57,10 +59,8 @@ export async function sweepDashboardAgentInvestigations(
5759
const limit = deps.limit ?? SWEEP_BATCH_LIMIT;
5860
const listStale =
5961
deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params));
60-
const settle =
61-
deps.settle ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params));
62-
const closeCard =
63-
deps.closeCard ?? ((params) => appendChatMessageOnceByChatId(dashboardAgentDb, params));
62+
const settleAndClose =
63+
deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params));
6464

6565
const result: InvestigationSweepResult = {
6666
stale: 0,
@@ -78,29 +78,20 @@ export async function sweepDashboardAgentInvestigations(
7878

7979
for (const investigation of stale) {
8080
try {
81-
const settled = await settle({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE });
82-
if (!settled) {
83-
result.alreadySettled++;
84-
continue;
85-
}
86-
result.settled++;
87-
8881
// Settling the row fixes nothing on its own: the chat renders the winning card
89-
// from its own transcript, so an unappended settle is still a stuck spinner.
90-
const message = investigationSettlementMessage({
91-
investigationId: settled.id,
92-
revision: settled.revision,
93-
state: settled.state,
82+
// from its own transcript, so an unappended settle is still a stuck spinner —
83+
// which is why both writes are one operation that rolls back together.
84+
const outcome = await settleAndClose({
85+
id: investigation.id,
86+
chatId: investigation.chatId,
87+
note: UNSETTLED_INVESTIGATION_NOTE,
9488
});
95-
if (!message) {
96-
result.failed++;
97-
logger.error("Dashboard agent investigation sweep: the closing card didn't validate", {
98-
investigationId: settled.id,
99-
chatId: investigation.chatId,
100-
});
89+
if (!outcome) {
90+
result.alreadySettled++;
10191
continue;
10292
}
103-
if (await closeCard({ chatId: investigation.chatId, message })) result.closed++;
93+
result.settled++;
94+
if (outcome.closed) result.closed++;
10495
} catch (error) {
10596
result.failed++;
10697
logger.error("Dashboard agent investigation sweep: failed to settle an investigation", {

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import { and, desc, eq, ne, sql, isNull } from "drizzle-orm";
77
import type { DashboardAgentDb } from "./client.js";
88
import { generateInvestigationId } from "./ids.js";
9-
import { lockChatForWatches } from "./internal.js";
9+
import { lockChatForWatches, type DashboardAgentDbOrTx } from "./internal.js";
1010
import {
1111
chats,
1212
chatSessions,
@@ -359,7 +359,7 @@ export async function appendChatMessageOnce(
359359
* the between-turns sweep runs on its own, off any session.
360360
*/
361361
export async function appendChatMessageOnceByChatId(
362-
db: DashboardAgentDb,
362+
db: DashboardAgentDbOrTx,
363363
params: { chatId: string; message: { id: string } }
364364
): Promise<boolean> {
365365
const rows = await db
@@ -381,9 +381,23 @@ export async function appendChatMessageOnceByChatId(
381381
return rows.length > 0;
382382
}
383383

384+
/** An investigation the turn left running, and the terminal state to close it with. */
385+
export type PendingInvestigationSettlement = {
386+
id: string;
387+
projectRef: string;
388+
environmentRef: string;
389+
state: unknown;
390+
};
391+
392+
export type PersistTurnResult = { settled: SettledInvestigation[] };
393+
384394
/**
385395
* One transaction: the frontend reads `messages` and `lastEventId` in parallel, so a
386396
* torn write resumes from a stale cursor and double-renders the last turn.
397+
*
398+
* `settlements` closes the cards the turn left running in that same transaction. It has
399+
* to be the same one: a settled row whose closing card didn't land is a terminal row the
400+
* stale sweep no longer selects, and the panel renders the spinner forever.
387401
*/
388402
export async function persistTurn(
389403
db: DashboardAgentDb,
@@ -395,12 +409,47 @@ export async function persistTurn(
395409
lastEventId?: string | null;
396410
runId?: string | null;
397411
};
412+
settlements?: PendingInvestigationSettlement[];
398413
}
399-
): Promise<void> {
400-
await db.transaction(async (tx) => {
414+
): Promise<PersistTurnResult> {
415+
return db.transaction(async (tx) => {
416+
const settled: SettledInvestigation[] = [];
417+
const cards: InvestigationCardMessage[] = [];
418+
for (const pending of params.settlements ?? []) {
419+
const result = await upsertInvestigationRevision(tx, {
420+
id: pending.id,
421+
chatId: params.chatId,
422+
projectRef: pending.projectRef,
423+
environmentRef: pending.environmentRef,
424+
state: pending.state,
425+
});
426+
// A row that no longer belongs to this chat/project/env has nothing to close.
427+
if (!result.ok) continue;
428+
429+
const message = investigationSettlementMessage({
430+
investigationId: result.id,
431+
revision: result.revision,
432+
state: pending.state,
433+
});
434+
if (!message) {
435+
throw new Error(`Investigation ${result.id} settled to a state that isn't renderable`);
436+
}
437+
settled.push({ id: result.id, revision: result.revision, state: pending.state });
438+
cards.push(message);
439+
}
440+
441+
// Revision-stable ids, so a replayed turn writes the same transcript, not a second card.
442+
const existing = new Set(
443+
params.messages.flatMap((message) => {
444+
const id = (message as { id?: unknown }).id;
445+
return typeof id === "string" ? [id] : [];
446+
})
447+
);
448+
const messages = [...params.messages, ...cards.filter((card) => !existing.has(card.id))];
449+
401450
await tx
402451
.update(chats)
403-
.set({ messages: params.messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
452+
.set({ messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
404453
.where(eq(chats.id, params.chatId));
405454

406455
await tx
@@ -420,6 +469,8 @@ export async function persistTurn(
420469
updatedAt: sql`now()`,
421470
},
422471
});
472+
473+
return { settled };
423474
});
424475
}
425476

@@ -461,7 +512,7 @@ export type UpsertInvestigationResult =
461512
* revisions. The chat/project/environment triple is in the `WHERE`: the tenancy check.
462513
*/
463514
export async function upsertInvestigationRevision(
464-
db: DashboardAgentDb,
515+
db: DashboardAgentDbOrTx,
465516
params: {
466517
id?: string;
467518
chatId: string;
@@ -688,7 +739,7 @@ export type SettledInvestigation = { id: string; revision: number; state: unknow
688739
* mirrors `forceSettledInvestigationState`.
689740
*/
690741
export async function settleInvestigationAsInconclusive(
691-
db: DashboardAgentDb,
742+
db: DashboardAgentDbOrTx,
692743
params: { id: string; note: string }
693744
): Promise<SettledInvestigation | null> {
694745
const rows = await db
@@ -716,3 +767,40 @@ export async function settleInvestigationAsInconclusive(
716767

717768
return rows[0] ?? null;
718769
}
770+
771+
export type SettledInvestigationCard = { settled: SettledInvestigation; closed: boolean };
772+
773+
/**
774+
* Settle a stale card and put its closing revision in the transcript, atomically.
775+
*
776+
* The two halves cannot be separate operations: a settle that commits without its
777+
* card leaves a terminal row the stale sweep no longer selects, and the panel — which
778+
* renders from the transcript — keeps the spinner forever. Rolling back restores the
779+
* `in_progress` row the sweep already looks for, so the next run retries it.
780+
*
781+
* Null when the row was no longer `in_progress`; `closed` is false when that message
782+
* id is already in the chat.
783+
*/
784+
export async function settleInvestigationAndCloseCard(
785+
db: DashboardAgentDb,
786+
params: { id: string; chatId: string; note: string }
787+
): Promise<SettledInvestigationCard | null> {
788+
return db.transaction(async (tx) => {
789+
const settled = await settleInvestigationAsInconclusive(tx, params);
790+
if (!settled) return null;
791+
792+
const message = investigationSettlementMessage({
793+
investigationId: settled.id,
794+
revision: settled.revision,
795+
state: settled.state,
796+
});
797+
// Nothing to deliver means the settle must not stand: a terminal row with no card
798+
// is the permanent spinner this transaction exists to prevent.
799+
if (!message) {
800+
throw new Error(`Investigation ${settled.id} settled to a state that isn't renderable`);
801+
}
802+
803+
const closed = await appendChatMessageOnceByChatId(tx, { chatId: params.chatId, message });
804+
return { settled, closed };
805+
});
806+
}

0 commit comments

Comments
 (0)