Skip to content

Commit 2dd410d

Browse files
committed
fix(webapp,dashboard-agent-db): stop a stuck investigation pinning the sweep
1 parent 21f5461 commit 2dd410d

8 files changed

Lines changed: 1653 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
A single stuck assistant investigation can no longer hold up others from being tidied away.

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
import {
77
listStaleOpenInvestigations,
8+
recordInvestigationSweepAttempt,
89
settleInvestigationAndCloseCard,
10+
settleInvestigationAsInconclusive,
911
type Investigation,
12+
type SettledInvestigation,
1013
type SettledInvestigationCard,
1114
} from "@internal/dashboard-agent-db";
1215
import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts";
@@ -22,6 +25,13 @@ export const INVESTIGATION_STALE_MS = 30 * 60 * 1000;
2225
/** Per-run cap. Oldest first, so the rest land next run. */
2326
const SWEEP_BATCH_LIMIT = 100;
2427

28+
/**
29+
* After this many failed settle attempts a row is force-abandoned: settled `inconclusive`
30+
* WITHOUT the closing card, so a card that never renders leaves the queue instead of
31+
* looping forever. The rare stuck spinner is the price of not starving every other row.
32+
*/
33+
export const MAX_SWEEP_ATTEMPTS = 5;
34+
2535
export type InvestigationSweepResult = {
2636
/** Stale `in_progress` rows seen. */
2737
stale: number;
@@ -30,6 +40,8 @@ export type InvestigationSweepResult = {
3040
closed: number;
3141
/** A turn (or another sweep) settled it first. */
3242
alreadySettled: number;
43+
/** Rows past the attempt cap, force-settled without a card so they leave the queue. */
44+
abandoned: number;
3345
failed: number;
3446
};
3547

@@ -46,6 +58,10 @@ export type InvestigationSweepDeps = {
4658
chatId: string;
4759
note: string;
4860
}) => Promise<SettledInvestigationCard | null>;
61+
/** Record a failed settle out-of-band; returns the new attempt count, or null if gone. */
62+
recordAttempt?: (params: { id: string }) => Promise<number | null>;
63+
/** Force a poison row terminal without the failing render path. */
64+
forceAbandon?: (params: { id: string; note: string }) => Promise<SettledInvestigation | null>;
4965
};
5066

5167
/**
@@ -61,12 +77,17 @@ export async function sweepDashboardAgentInvestigations(
6177
deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params));
6278
const settleAndClose =
6379
deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params));
80+
const recordAttempt =
81+
deps.recordAttempt ?? ((params) => recordInvestigationSweepAttempt(dashboardAgentDb, params));
82+
const forceAbandon =
83+
deps.forceAbandon ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params));
6484

6585
const result: InvestigationSweepResult = {
6686
stale: 0,
6787
settled: 0,
6888
closed: 0,
6989
alreadySettled: 0,
90+
abandoned: 0,
7091
failed: 0,
7192
};
7293

@@ -93,10 +114,49 @@ export async function sweepDashboardAgentInvestigations(
93114
result.settled++;
94115
if (outcome.closed) result.closed++;
95116
} catch (error) {
117+
// The settle rolled back, so the row is still `in_progress`. Record the attempt in
118+
// its own write — this rotates the row to the back of the sweep order (see
119+
// `listStaleOpenInvestigations`) so it can't pin the head and starve newer rows.
120+
let attempts: number | null = null;
121+
try {
122+
attempts = await recordAttempt({ id: investigation.id });
123+
} catch (recordError) {
124+
logger.error("Dashboard agent investigation sweep: failed to record a sweep attempt", {
125+
investigationId: investigation.id,
126+
chatId: investigation.chatId,
127+
error: recordError,
128+
});
129+
}
130+
131+
// Past the cap the card will never render; force it terminal without the render
132+
// path so it leaves the queue instead of looping forever.
133+
if (attempts !== null && attempts >= MAX_SWEEP_ATTEMPTS) {
134+
try {
135+
await forceAbandon({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE });
136+
result.abandoned++;
137+
logger.warn(
138+
"Dashboard agent investigation sweep: abandoned a card past the attempt cap",
139+
{
140+
investigationId: investigation.id,
141+
chatId: investigation.chatId,
142+
attempts,
143+
}
144+
);
145+
continue;
146+
} catch (abandonError) {
147+
logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", {
148+
investigationId: investigation.id,
149+
chatId: investigation.chatId,
150+
error: abandonError,
151+
});
152+
}
153+
}
154+
96155
result.failed++;
97156
logger.error("Dashboard agent investigation sweep: failed to settle an investigation", {
98157
investigationId: investigation.id,
99158
chatId: investigation.chatId,
159+
attempts,
100160
error,
101161
});
102162
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import {
2+
createChat,
3+
createDashboardAgentDb,
4+
getInvestigation,
5+
settleInvestigationAndCloseCard,
6+
upsertInvestigationRevision,
7+
type DashboardAgentDb,
8+
type DashboardAgentDbClient,
9+
} from "@internal/dashboard-agent-db";
10+
import {
11+
investigationStateSchema,
12+
type InvestigationState,
13+
} from "@internal/dashboard-agent-contracts";
14+
import { postgresTest } from "@internal/testcontainers";
15+
import type { PrismaClient } from "@trigger.dev/database";
16+
import { readdirSync, readFileSync } from "node:fs";
17+
import path from "node:path";
18+
import { afterEach, describe, expect, vi } from "vitest";
19+
20+
const ctx = vi.hoisted(() => ({
21+
agentDb: undefined as unknown as DashboardAgentDb,
22+
}));
23+
24+
vi.mock("~/services/dashboardAgentDb.server", () => ({
25+
get dashboardAgentDb() {
26+
return ctx.agentDb;
27+
},
28+
}));
29+
30+
const { sweepDashboardAgentInvestigations, INVESTIGATION_STALE_MS, MAX_SWEEP_ATTEMPTS } =
31+
await import("~/services/dashboardAgentInvestigationSweep.server");
32+
33+
async function applyAgentSchema(prisma: PrismaClient) {
34+
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
35+
const migrations = readdirSync(folder)
36+
.filter((file) => file.endsWith(".sql"))
37+
.sort();
38+
for (const name of migrations) {
39+
const sql = readFileSync(path.join(folder, name), "utf8");
40+
for (const statement of sql.split("--> statement-breakpoint")) {
41+
const trimmed = statement.trim();
42+
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
43+
}
44+
}
45+
}
46+
47+
let agentDbClient: DashboardAgentDbClient | undefined;
48+
let prismaForRaw: PrismaClient | undefined;
49+
50+
async function boot(prisma: PrismaClient, connectionUri: string) {
51+
await applyAgentSchema(prisma);
52+
agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
53+
ctx.agentDb = agentDbClient.db;
54+
prismaForRaw = prisma;
55+
}
56+
57+
afterEach(async () => {
58+
await agentDbClient?.close();
59+
agentDbClient = undefined;
60+
});
61+
62+
const ORG = "org_poison";
63+
const USER = "user_poison";
64+
65+
function openState(): InvestigationState {
66+
return investigationStateSchema.parse({
67+
outcome: "in_progress",
68+
severity: "warn",
69+
confidence: "medium",
70+
title: "a stuck card",
71+
headline: "Still checking.",
72+
progress: "Reading spans",
73+
checkNext: [],
74+
hypotheses: [],
75+
evidence: [],
76+
});
77+
}
78+
79+
async function seedInvestigation(chatId: string, ageMs: number): Promise<string> {
80+
await createChat(ctx.agentDb, { id: chatId, organizationId: ORG, userId: USER });
81+
const created = await upsertInvestigationRevision(ctx.agentDb, {
82+
chatId,
83+
projectRef: "proj",
84+
environmentRef: "env",
85+
state: openState(),
86+
});
87+
if (!created.ok) throw new Error("fixture investigation not created");
88+
await prismaForRaw!.$executeRawUnsafe(
89+
`update trigger_dashboard_agent.investigations
90+
set updated_at = now() - ($2 || ' milliseconds')::interval where id = $1`,
91+
created.id,
92+
String(ageMs)
93+
);
94+
return created.id;
95+
}
96+
97+
async function outcomeOf(id: string): Promise<string | undefined> {
98+
const row = await getInvestigation(ctx.agentDb, { id });
99+
return row ? (row.state as { outcome?: string }).outcome : undefined;
100+
}
101+
102+
const STALE_AGE_MS = INVESTIGATION_STALE_MS + 60_000;
103+
const OLDER_AGE_MS = STALE_AGE_MS + 60_000;
104+
105+
describe("the investigation sweep with a poison row", () => {
106+
postgresTest(
107+
"a row that always fails to settle cannot pin the head and starve a newer row",
108+
async ({ prisma, postgresContainer }) => {
109+
await boot(prisma, postgresContainer.getConnectionUri());
110+
111+
// Poison sorts first (older `updated_at`); renderable is newer.
112+
const poisonId = await seedInvestigation("chat_poison", OLDER_AGE_MS);
113+
const renderableId = await seedInvestigation("chat_ok", STALE_AGE_MS);
114+
115+
// Only the poison row's settle throws; the renderable one goes through the real path.
116+
const settleAndClose = (params: { id: string; chatId: string; note: string }) => {
117+
if (params.id === poisonId) throw new Error("state isn't renderable");
118+
return settleInvestigationAndCloseCard(ctx.agentDb, params);
119+
};
120+
121+
// limit 1 forces head contention: without backoff the poison row would win every run.
122+
// A failed run throws so the job retries, but the attempt is recorded before it does.
123+
await expect(
124+
sweepDashboardAgentInvestigations({ limit: 1, settleAndClose })
125+
).rejects.toThrow();
126+
expect(await outcomeOf(poisonId)).toBe("in_progress");
127+
expect(await outcomeOf(renderableId)).toBe("in_progress");
128+
129+
// Next run: the poison row now sorts behind the never-attempted renderable one,
130+
// so the newer row is picked and settled despite the poison row still being stale.
131+
const second = await sweepDashboardAgentInvestigations({ limit: 1, settleAndClose });
132+
expect(second).toMatchObject({ stale: 1, settled: 1, failed: 0 });
133+
expect(await outcomeOf(renderableId)).toBe("inconclusive");
134+
expect(await outcomeOf(poisonId)).toBe("in_progress");
135+
},
136+
30_000
137+
);
138+
139+
postgresTest(
140+
"after the attempt cap the poison row is abandoned and leaves the queue",
141+
async ({ prisma, postgresContainer }) => {
142+
await boot(prisma, postgresContainer.getConnectionUri());
143+
const poisonId = await seedInvestigation("chat_poison", STALE_AGE_MS);
144+
145+
const settleAndClose = () => {
146+
throw new Error("state isn't renderable");
147+
};
148+
149+
// The first MAX_SWEEP_ATTEMPTS-1 runs record a failed attempt and throw; the row stays stale.
150+
for (let i = 1; i < MAX_SWEEP_ATTEMPTS; i++) {
151+
await expect(sweepDashboardAgentInvestigations({ settleAndClose })).rejects.toThrow();
152+
expect(await outcomeOf(poisonId)).toBe("in_progress");
153+
}
154+
155+
// The capped run force-settles the row without the render path, so it leaves the queue.
156+
const capped = await sweepDashboardAgentInvestigations({ settleAndClose });
157+
expect(capped).toMatchObject({ stale: 1, abandoned: 1, failed: 0 });
158+
expect(await outcomeOf(poisonId)).toBe("inconclusive");
159+
160+
// Nothing stale remains, so the poison row is no longer swept.
161+
const after = await sweepDashboardAgentInvestigations({ settleAndClose });
162+
expect(after).toMatchObject({ stale: 0 });
163+
},
164+
30_000
165+
);
166+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "sweep_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2+
ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "last_sweep_attempt_at" timestamp with time zone;

0 commit comments

Comments
 (0)