Skip to content

Commit 26f93da

Browse files
committed
fix(webapp): have the stale-investigation sweep close the card it settles
The sweep settled the row and appended nothing, so it visibly fixed nothing: the chat kept rendering the last card it had, which was still "Working…". The settle now returns the state and revision it wrote, and the sweep appends that as the closing card revision on the chat — id-deduped on `investigation-settlement:{id}:{revision}`, so a retried run can neither stack a second card nor open a second investigation. The append is scoped by chat id: a sweep runs off any session and has no user in context, unlike the turn lane.
1 parent f7b8374 commit 26f93da

4 files changed

Lines changed: 270 additions & 10 deletions

File tree

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

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

66
import {
7+
appendChatMessageOnceByChatId,
8+
investigationSettlementMessage,
79
listStaleOpenInvestigations,
810
settleInvestigationAsInconclusive,
911
type Investigation,
12+
type InvestigationCardMessage,
13+
type SettledInvestigation,
1014
} from "@internal/dashboard-agent-db";
1115
import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts";
1216
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
@@ -25,6 +29,8 @@ export type InvestigationSweepResult = {
2529
/** Stale `in_progress` rows seen. */
2630
stale: number;
2731
settled: number;
32+
/** Settled rows whose closing card reached the chat. */
33+
closed: number;
2834
/** A turn (or another sweep) settled it first. */
2935
alreadySettled: number;
3036
failed: number;
@@ -34,8 +40,10 @@ export type InvestigationSweepDeps = {
3440
now?: () => Date;
3541
limit?: number;
3642
listStale?: (params: { olderThan: Date; limit: number }) => Promise<Investigation[]>;
37-
/** Settle one row. False when it was no longer `in_progress`. */
38-
settle?: (params: { id: string; note: string }) => Promise<boolean>;
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>;
3947
};
4048

4149
/**
@@ -51,10 +59,13 @@ export async function sweepDashboardAgentInvestigations(
5159
deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params));
5260
const settle =
5361
deps.settle ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params));
62+
const closeCard =
63+
deps.closeCard ?? ((params) => appendChatMessageOnceByChatId(dashboardAgentDb, params));
5464

5565
const result: InvestigationSweepResult = {
5666
stale: 0,
5767
settled: 0,
68+
closed: 0,
5869
alreadySettled: 0,
5970
failed: 0,
6071
};
@@ -68,8 +79,28 @@ export async function sweepDashboardAgentInvestigations(
6879
for (const investigation of stale) {
6980
try {
7081
const settled = await settle({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE });
71-
if (settled) result.settled++;
72-
else result.alreadySettled++;
82+
if (!settled) {
83+
result.alreadySettled++;
84+
continue;
85+
}
86+
result.settled++;
87+
88+
// 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,
94+
});
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+
});
101+
continue;
102+
}
103+
if (await closeCard({ chatId: investigation.chatId, message })) result.closed++;
73104
} catch (error) {
74105
result.failed++;
75106
logger.error("Dashboard agent investigation sweep: failed to settle an investigation", {

apps/webapp/test/dashboardAgentInvestigationSweep.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
22
createChat,
33
createDashboardAgentDb,
4+
getChatMessages,
45
getInvestigation,
6+
investigationSettlementMessageId,
57
listStaleOpenInvestigations,
68
settleInvestigationAsInconclusive,
79
upsertInvestigationRevision,
@@ -155,7 +157,38 @@ describe("the dashboard agent investigation sweep", () => {
155157
expect(state.hypotheses).toHaveLength(1);
156158
expect(state.title).toBe("send-order-receipt keeps failing");
157159
expect(row?.revision).toBe(1);
158-
}
160+
161+
// The settled row is invisible on its own: the panel resolves the card from the
162+
// transcript, so the closing revision has to be in the chat too.
163+
const messages = (await getChatMessages(ctx.agentDb, {
164+
chatId: "chat_stale",
165+
userId: "user_sweep",
166+
organizationId: "org_sweep",
167+
})) as { id: string; parts: Record<string, any>[] }[] | null;
168+
expect(messages?.map((message) => message.id)).toEqual([
169+
investigationSettlementMessageId(id, 1),
170+
]);
171+
const block = messages![0]!.parts[0]!.output.blocks[0];
172+
expect(block).toMatchObject({ type: "investigation", id, revision: 1 });
173+
expect(block.investigation.outcome).toBe("inconclusive");
174+
expect(result.closed).toBe(1);
175+
176+
// A second run can't stack a second card: the settle is a no-op and the append
177+
// is deduped on the same message id.
178+
expect(await sweepDashboardAgentInvestigations()).toMatchObject({ stale: 0, settled: 0 });
179+
expect(
180+
(
181+
(await getChatMessages(ctx.agentDb, {
182+
chatId: "chat_stale",
183+
userId: "user_sweep",
184+
organizationId: "org_sweep",
185+
})) as unknown[]
186+
).length
187+
).toBe(1);
188+
},
189+
// This one pays the container boot and the schema replay, and now asserts the
190+
// transcript on top.
191+
30_000
159192
);
160193

161194
postgresTest(
@@ -260,7 +293,13 @@ describe("the dashboard agent investigation sweep", () => {
260293
expect(concluded.ok).toBe(true);
261294

262295
const result = await sweepDashboardAgentInvestigations({ listStale: async () => stale });
263-
expect(result).toMatchObject({ stale: 1, settled: 0, alreadySettled: 1, failed: 0 });
296+
expect(result).toMatchObject({
297+
stale: 1,
298+
settled: 0,
299+
closed: 0,
300+
alreadySettled: 1,
301+
failed: 0,
302+
});
264303

265304
const row = await getInvestigation(ctx.agentDb, { id });
266305
const state = investigationStateSchema.parse(row?.state);
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import {
2+
investigationSettlementMessageId,
3+
type Investigation,
4+
type InvestigationCardMessage,
5+
} from "@internal/dashboard-agent-db";
6+
import {
7+
forceSettledInvestigationState,
8+
investigationStateSchema,
9+
UNSETTLED_INVESTIGATION_NOTE,
10+
VIEW_BLOCK_VERSION,
11+
type InvestigationState,
12+
} from "@internal/dashboard-agent-contracts";
13+
import { describe, expect, it, vi } from "vitest";
14+
import { liveInvestigation } from "~/components/dashboard-agent/progress-line";
15+
16+
// The sweep's own datastore is never reached here: every write is injected. The
17+
// connection is only stubbed so importing the service doesn't open a pool.
18+
vi.mock("~/services/dashboardAgentDb.server", () => ({ dashboardAgentDb: undefined }));
19+
20+
const { sweepDashboardAgentInvestigations } =
21+
await import("~/services/dashboardAgentInvestigationSweep.server");
22+
23+
/**
24+
* The transcript half of the sweep, without a container: settling the row is invisible
25+
* to the panel, which resolves a card from the chat's own `render_view` parts.
26+
*
27+
* The database half — that the closing card actually lands in `chats.messages` — is
28+
* covered by `dashboardAgentInvestigationSweep.test.ts`, which needs Postgres.
29+
*/
30+
31+
const CHAT_ID = "chat_sweep_card";
32+
const INVESTIGATION_ID = "inv_sweep_card";
33+
34+
function openState(): InvestigationState {
35+
return investigationStateSchema.parse({
36+
outcome: "in_progress",
37+
severity: "warn",
38+
confidence: "medium",
39+
title: "send-order-receipt keeps failing",
40+
headline: "Checking whether the failures share a payload.",
41+
progress: "Reading the run's spans",
42+
hypotheses: [
43+
{
44+
id: "h1",
45+
statement: "The new payload dropped a field the task reads.",
46+
verdict: "testing",
47+
evidence: [],
48+
},
49+
],
50+
evidence: [],
51+
});
52+
}
53+
54+
/** The card the agent left behind, as the transcript holds it. */
55+
function openCardMessage(state: InvestigationState) {
56+
return {
57+
id: "msg_open",
58+
role: "assistant",
59+
parts: [
60+
{
61+
type: "tool-render_view",
62+
toolCallId: "tc_open",
63+
state: "output-available",
64+
input: { blocks: [{ type: "investigation", investigation: state }] },
65+
output: {
66+
blocks: [
67+
{
68+
type: "investigation",
69+
investigation: state,
70+
id: INVESTIGATION_ID,
71+
revision: 0,
72+
version: VIEW_BLOCK_VERSION,
73+
},
74+
],
75+
},
76+
},
77+
],
78+
};
79+
}
80+
81+
function staleRow(state: InvestigationState): Investigation {
82+
return {
83+
id: INVESTIGATION_ID,
84+
chatId: CHAT_ID,
85+
projectRef: "proj_sweep",
86+
environmentRef: "env_sweep",
87+
revision: 0,
88+
state,
89+
createdAt: new Date(),
90+
updatedAt: new Date(),
91+
} as Investigation;
92+
}
93+
94+
/**
95+
* Stands in for the datastore: the conditional settle (`in_progress` only, mirroring
96+
* `forceSettledInvestigationState`) and the id-deduped append.
97+
*/
98+
function fakeStore(initial: InvestigationState) {
99+
const row = { revision: 0, state: initial };
100+
const appended: InvestigationCardMessage[] = [];
101+
return {
102+
row,
103+
appended,
104+
settle: async (params: { id: string; note: string }) => {
105+
if (investigationStateSchema.parse(row.state).outcome !== "in_progress") return null;
106+
row.state = forceSettledInvestigationState(investigationStateSchema.parse(row.state));
107+
row.revision += 1;
108+
return { id: params.id, revision: row.revision, state: row.state };
109+
},
110+
closeCard: async (params: { chatId: string; message: InvestigationCardMessage }) => {
111+
if (appended.some((message) => message.id === params.message.id)) return false;
112+
appended.push(params.message);
113+
return true;
114+
},
115+
};
116+
}
117+
118+
describe("the dashboard agent investigation sweep's closing card", () => {
119+
it("appends the terminal card to the chat, so the panel stops spinning", async () => {
120+
const open = openState();
121+
const store = fakeStore(open);
122+
123+
const result = await sweepDashboardAgentInvestigations({
124+
listStale: async () => [staleRow(open)],
125+
settle: store.settle,
126+
closeCard: store.closeCard,
127+
});
128+
129+
expect(store.appended).toHaveLength(1);
130+
expect(result).toMatchObject({ stale: 1, settled: 1, closed: 1, alreadySettled: 0, failed: 0 });
131+
132+
const message = store.appended[0]!;
133+
expect(message.id).toBe(investigationSettlementMessageId(INVESTIGATION_ID, 1));
134+
expect(message.role).toBe("assistant");
135+
136+
const part = message.parts[0] as {
137+
type: string;
138+
state: string;
139+
output: { blocks: Record<string, any>[] };
140+
};
141+
expect(part.type).toBe("tool-render_view");
142+
expect(part.state).toBe("output-available");
143+
expect(part.output.blocks[0]).toMatchObject({
144+
type: "investigation",
145+
id: INVESTIGATION_ID,
146+
revision: 1,
147+
version: VIEW_BLOCK_VERSION,
148+
});
149+
const settled = part.output.blocks[0]!.investigation;
150+
expect(settled.outcome).toBe("inconclusive");
151+
expect(settled.confidence).toBe("low");
152+
expect(settled.progress).toBeUndefined();
153+
expect(settled.headline).toContain(UNSETTLED_INVESTIGATION_NOTE);
154+
// What was checked survives: the card closes honestly, it doesn't get blanked.
155+
expect(settled.hypotheses).toHaveLength(1);
156+
157+
// The panel, over the transcript a refresh loads: the spinner was there, and the
158+
// appended revision is what ends it.
159+
const transcript = [openCardMessage(open)];
160+
expect(liveInvestigation(transcript)).toEqual({ progress: "Reading the run's spans" });
161+
expect(liveInvestigation([...transcript, message as never])).toBeNull();
162+
});
163+
164+
it("a retried run neither duplicates the card nor opens a second investigation", async () => {
165+
const open = openState();
166+
const store = fakeStore(open);
167+
const deps = {
168+
listStale: async () => [staleRow(open)],
169+
settle: store.settle,
170+
closeCard: store.closeCard,
171+
};
172+
173+
await sweepDashboardAgentInvestigations(deps);
174+
const second = await sweepDashboardAgentInvestigations(deps);
175+
176+
expect(store.appended.map((message) => message.id)).toEqual([
177+
investigationSettlementMessageId(INVESTIGATION_ID, 1),
178+
]);
179+
expect(second).toMatchObject({ stale: 1, settled: 0, closed: 0, alreadySettled: 1, failed: 0 });
180+
expect(store.row.revision).toBe(1);
181+
});
182+
});

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -679,14 +679,18 @@ export async function listStaleOpenInvestigations(
679679
return rows.map((row) => row.investigation);
680680
}
681681

682+
/** What the settle wrote, which is what the closing card has to render. */
683+
export type SettledInvestigation = { id: string; revision: number; state: unknown };
684+
682685
/**
683686
* Backstop for {@link listStaleOpenInvestigations}. One statement, so a turn that
684-
* concludes the card first wins. The merge mirrors `forceSettledInvestigationState`.
687+
* concludes the card first wins — null means it was no longer `in_progress`. The merge
688+
* mirrors `forceSettledInvestigationState`.
685689
*/
686690
export async function settleInvestigationAsInconclusive(
687691
db: DashboardAgentDb,
688692
params: { id: string; note: string }
689-
): Promise<boolean> {
693+
): Promise<SettledInvestigation | null> {
690694
const rows = await db
691695
.update(investigations)
692696
.set({
@@ -704,7 +708,11 @@ export async function settleInvestigationAsInconclusive(
704708
sql`${investigations.state}->>'outcome' = 'in_progress'`
705709
)
706710
)
707-
.returning({ id: investigations.id });
711+
.returning({
712+
id: investigations.id,
713+
revision: investigations.revision,
714+
state: investigations.state,
715+
});
708716

709-
return rows.length > 0;
717+
return rows[0] ?? null;
710718
}

0 commit comments

Comments
 (0)