Skip to content

Commit ee85448

Browse files
authored
fix(webapp): dashboard agent maintenance moves into the agent project (#4599)
## What & why The dashboard agent's upkeep — retention deletes and the investigation sweep — ran as cron jobs on the webapp's common worker, even though it only touches the agent's own datastore. This moves that upkeep into the agent's Trigger project as scheduled tasks (TRI-13182). ## What's inside **Retention** — `internal-packages/dashboard-agent/src/maintenance.ts`, a daily task (03:00 UTC). Deletes turn evals older than 30 days, hard-deletes chats soft-deleted more than 30 days ago, and purges terminal watches and submission rows older than 7 days. It used to run every 5 minutes; nothing needs a hard delete that fast, so it is daily now, draining in bounded batches and warning if it hits the cap. It retries (3 attempts) because the next run is a day away. It connects with `DASHBOARD_AGENT_DATABASE_URL`, falling back to `DATABASE_URL` like every other task in the package (the deletes are confined to the agent's own Postgres schema), and skips when neither is set. **Investigation sweep** — `src/investigation-sweep.ts`, every 5 minutes, same as before: settles investigation cards stuck `in_progress` (30-minute window, attempt cap, force-abandon note). It keeps the fast cadence because it fixes live state the UI is showing. **What stays in the webapp.** The watch finalize/deliver sweep and batch rearm: they cover a dead agent-side tick chain — a backstop can't live inside the thing it backstops — and they need the main database and the alerts worker. The org-deletion chat purge also stays: deletion must not depend on the agent project being deployed. The removed cron job keeps a cron-less tombstone entry so already-queued items drain cleanly; remove it in a follow-up. **Test plumbing** — the drizzle migration replayer that webapp tests hand-rolled is now exported once from `@internal/dashboard-agent-db/testing`; the moved tests live in the agent package as `src/*.test.ts` against real Postgres. ## Testing Agent package: retention passes (backlog drain, batch cap, no-op guard, chat-delete cascade) and the sweep, on testcontainers Postgres. Webapp: the watch/chat suites, plus a test that a settlement card stops the dashboard spinner. Full typecheck on both.
1 parent 45444a7 commit ee85448

47 files changed

Lines changed: 1310 additions & 1719 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Routine cleanup of old dashboard agent data now runs on its own schedule.

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

Lines changed: 5 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,14 @@
11
/**
2-
* Retention for soft-deleted chats. A deleted chat is kept for a grace window and then
3-
* hard-deleted with all its child rows; one bounded statement per run, oldest first.
4-
* Also the eventual purge behind organization deletion, which soft-deletes the org's
5-
* chats so this same sweep removes them.
2+
* The purge behind organization deletion: it soft-deletes the org's chats, and retention
3+
* hard-deletes them once the window passes.
64
*/
75

8-
import {
9-
hardDeleteChatsSoftDeletedBefore,
10-
softDeleteChatsForOrganization,
11-
} from "@internal/dashboard-agent-db";
6+
import { softDeleteChatsForOrganization } from "@internal/dashboard-agent-db";
127
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
13-
import { logger } from "~/services/logger.server";
148

159
/**
16-
* How long a soft-deleted chat is kept before it and its children are hard-deleted.
17-
* Long enough that an accidental delete can still be investigated; org deletion soft-
18-
* deletes the org's chats, so those are removed the same way once the window passes.
19-
*/
20-
export const CHAT_SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
21-
22-
/** Per-run cap. Retention is one bounded statement, not a row-at-a-time loop. */
23-
const RETENTION_BATCH_LIMIT = 500;
24-
25-
export type ChatRetentionResult = {
26-
/** Soft-deleted chats past the retention window dropped this run. */
27-
purged: number;
28-
failed: number;
29-
};
30-
31-
export type ChatRetentionDeps = {
32-
now?: () => Date;
33-
limit?: number;
34-
/** Hard-delete chats soft-deleted before `before`. Returns how many went. */
35-
purge?: (params: { before: Date; limit: number }) => Promise<number>;
36-
};
37-
38-
export async function sweepDashboardAgentSoftDeletedChats(
39-
deps: ChatRetentionDeps = {}
40-
): Promise<ChatRetentionResult> {
41-
const now = deps.now?.() ?? new Date();
42-
const limit = deps.limit ?? RETENTION_BATCH_LIMIT;
43-
const purge =
44-
deps.purge ?? ((params) => hardDeleteChatsSoftDeletedBefore(dashboardAgentDb, params));
45-
46-
const result: ChatRetentionResult = { purged: 0, failed: 0 };
47-
48-
try {
49-
result.purged = await purge({
50-
before: new Date(now.getTime() - CHAT_SOFT_DELETE_RETENTION_MS),
51-
limit,
52-
});
53-
} catch (error) {
54-
result.failed++;
55-
logger.error("Dashboard agent chat retention failed", { error });
56-
}
57-
58-
if (result.failed > 0) {
59-
throw new Error("The dashboard agent chat retention pass failed");
60-
}
61-
62-
return result;
63-
}
64-
65-
/**
66-
* Soft-delete every chat belonging to a deleted organization. The retention sweep above
67-
* hard-deletes them once the window passes, so the org-deletion request never runs a
68-
* cross-database hard delete.
10+
* Soft-delete every chat belonging to a deleted organization. Retention hard-deletes them
11+
* once the window passes, so the org-deletion request never runs a cross-database hard delete.
6912
*/
7013
export async function purgeDashboardAgentChatsForOrganization(params: {
7114
organizationId: string;

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

Lines changed: 0 additions & 58 deletions
This file was deleted.

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

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import {
77
cancelWatch,
88
claimWatchAlertDispatch,
9-
deleteTerminalWatchesOlderThan,
10-
deleteWatchSubmissionsOlderThan,
119
listExpiredActiveWatches,
1210
listWatchBatchGroupsToArm,
1311
listWatchesAwaitingDelivery,
@@ -57,12 +55,6 @@ export const WATCH_DELIVERY_GRACE_MS = 5 * 60 * 1000;
5755
/** Per-run cap for each half of the sweep. Oldest first, so the rest land next run. */
5856
const SWEEP_BATCH_LIMIT = 100;
5957

60-
/** How long a terminal watch is kept. Its outcome also lives in the chat transcript. */
61-
export const WATCH_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
62-
63-
/** Higher than the other caps: retention is one statement, not a row-at-a-time loop. */
64-
const RETENTION_BATCH_LIMIT = 500;
65-
6658
/**
6759
* How many rows one sweep handles at once. An incident expires a whole group together, and a
6860
* bound is what stops one slow tenant spending the entire visibility window.
@@ -91,10 +83,6 @@ export type WatchSweepResult = {
9183
redelivered: number;
9284
/** Decided but not handed over, with no agent project. They stay owed. */
9385
deliveryDeferred: number;
94-
/** Long-terminal rows dropped by retention. */
95-
purged: number;
96-
/** Ledger rows dropped by retention. */
97-
purgedSubmissions: number;
9886
failed: number;
9987
};
10088

@@ -113,10 +101,6 @@ export type WatchSweepDeps = {
113101
deliver?: (watch: Watch) => Promise<void>;
114102
/** Gates the delivery half only. Finalization never depends on it. */
115103
configured?: () => boolean;
116-
/** Drop terminal rows older than `before`. Returns how many went. */
117-
purgeTerminal?: (params: { before: Date; limit: number }) => Promise<number>;
118-
/** Drop submission-ledger rows older than `before`. */
119-
purgeSubmissions?: (params: { before: Date; limit: number }) => Promise<number>;
120104
/** How many rows are handled at once. */
121105
concurrency?: number;
122106
};
@@ -332,11 +316,6 @@ export async function sweepDashboardAgentWatches(
332316
const listAwaitingDelivery =
333317
deps.listAwaitingDelivery ??
334318
((params) => listWatchesAwaitingDelivery(dashboardAgentDb, params));
335-
const purgeTerminal =
336-
deps.purgeTerminal ?? ((params) => deleteTerminalWatchesOlderThan(dashboardAgentDb, params));
337-
const purgeSubmissions =
338-
deps.purgeSubmissions ??
339-
((params) => deleteWatchSubmissionsOlderThan(dashboardAgentDb, params));
340319

341320
const result: WatchSweepResult = {
342321
overdue: 0,
@@ -347,8 +326,6 @@ export async function sweepDashboardAgentWatches(
347326
undelivered: 0,
348327
redelivered: 0,
349328
deliveryDeferred: 0,
350-
purged: 0,
351-
purgedSubmissions: 0,
352329
failed: 0,
353330
};
354331

@@ -435,18 +412,6 @@ export async function sweepDashboardAgentWatches(
435412
}
436413
}
437414

438-
// Retention runs last, over rows both halves are finished with. Its own try/catch so a
439-
// lost retention pass can't mask the other failures.
440-
try {
441-
const before = new Date(now.getTime() - WATCH_RETENTION_MS);
442-
result.purged = await purgeTerminal({ before, limit: RETENTION_BATCH_LIMIT });
443-
// The ledger's rows age out on the same window: past it no client is still retrying.
444-
result.purgedSubmissions = await purgeSubmissions({ before, limit: RETENTION_BATCH_LIMIT });
445-
} catch (error) {
446-
result.failed++;
447-
logger.error("Dashboard agent watch sweep: failed to purge terminal watches", { error });
448-
}
449-
450415
if (result.failed > 0) {
451416
throw new Error(`The dashboard agent watch sweep failed on ${result.failed} watches`);
452417
}

apps/webapp/app/v3/commonWorker.server.ts

Lines changed: 9 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,7 @@ import {
1111
runAttioUserSync,
1212
runAttioWorkspaceSync,
1313
} from "~/services/attio.server";
14-
import {
15-
purgeDashboardAgentChatsForOrganization,
16-
sweepDashboardAgentSoftDeletedChats,
17-
} from "~/services/dashboardAgentChatRetention.server";
18-
import { sweepDashboardAgentTurnEvals } from "~/services/dashboardAgentEvalRetention.server";
19-
import { sweepDashboardAgentInvestigations } from "~/services/dashboardAgentInvestigationSweep.server";
14+
import { purgeDashboardAgentChatsForOrganization } from "~/services/dashboardAgentChatRetention.server";
2015
import {
2116
rearmDashboardAgentWatchBatches,
2217
sweepDashboardAgentWatches,
@@ -47,7 +42,7 @@ function initializeWorker() {
4742

4843
logger.debug(`👨‍🏭 Initializing common worker at host ${env.COMMON_WORKER_REDIS_HOST}`);
4944

50-
// Only schedule the agent maintenance cron where the agent is actually set up. Otherwise
45+
// Only schedule the agent watch cron where the agent is actually set up. Otherwise
5146
// its sweeps hit a missing schema and drip a dead-letter entry every run.
5247
const dashboardAgentConfigured =
5348
env.DASHBOARD_AGENT_ENABLED === "1" || Boolean(env.DASHBOARD_AGENT_DATABASE_URL);
@@ -161,16 +156,15 @@ function initializeWorker() {
161156
maxAttempts: 5,
162157
},
163158
},
164-
// Stuck investigation cards and turn-eval retention.
159+
// @deprecated, moved to the dashboard agent project; remove once the queue drains.
165160
"dashboardAgent.maintenance": {
166161
schema: CronSchema,
167-
visibilityTimeoutMs: 60_000 * 5,
168-
...(dashboardAgentConfigured ? { cron: "*/5 * * * *", jitterInMs: 30_000 } : {}),
162+
visibilityTimeoutMs: 60_000,
169163
retry: {
170164
maxAttempts: 1,
171165
},
172166
},
173-
// The watch backstops: expiry, wake redelivery, retention and dead batch chains.
167+
// The watch backstops: expiry, wake redelivery and dead batch chains.
174168
"dashboardAgent.watchMaintenance": {
175169
schema: CronSchema,
176170
visibilityTimeoutMs: 60_000 * 5,
@@ -179,7 +173,7 @@ function initializeWorker() {
179173
maxAttempts: 1,
180174
},
181175
},
182-
// Soft-deletes a deleted organization's chats; the maintenance sweep purges them.
176+
// Soft-deletes a deleted organization's chats; retention hard-deletes them later.
183177
"dashboardAgent.purgeOrganization": {
184178
schema: z.object({
185179
organizationId: z.string(),
@@ -247,48 +241,15 @@ function initializeWorker() {
247241
const service = new BulkActionService();
248242
await service.process(payload.bulkActionId);
249243
},
250-
"dashboardAgent.maintenance": async () => {
251-
// Each backstop runs independently; the first failure is rethrown at the end.
252-
let failure: unknown;
253-
254-
try {
255-
const investigations = await sweepDashboardAgentInvestigations();
256-
if (investigations.stale > 0) {
257-
logger.debug("Dashboard agent investigation sweep", investigations);
258-
}
259-
} catch (error) {
260-
failure ??= error;
261-
}
262-
263-
// Retention on the judged-turn rows. Independent of the agent being configured.
264-
try {
265-
const evals = await sweepDashboardAgentTurnEvals();
266-
if (evals.purged > 0) {
267-
logger.debug("Dashboard agent turn-eval retention", evals);
268-
}
269-
} catch (error) {
270-
failure ??= error;
271-
}
272-
273-
// Hard-delete chats soft-deleted past the retention window, with their children.
274-
try {
275-
const chats = await sweepDashboardAgentSoftDeletedChats();
276-
if (chats.purged > 0) {
277-
logger.debug("Dashboard agent chat retention", chats);
278-
}
279-
} catch (error) {
280-
failure ??= error;
281-
}
282-
283-
if (failure) throw failure;
284-
},
244+
// @deprecated, moved to the dashboard agent project; remove once the queue drains.
245+
"dashboardAgent.maintenance": async () => {},
285246
"dashboardAgent.watchMaintenance": async () => {
286247
// Each backstop runs independently; the first failure is rethrown at the end.
287248
let failure: unknown;
288249

289250
try {
290251
const watches = await sweepDashboardAgentWatches();
291-
if (watches.overdue > 0 || watches.undelivered > 0 || watches.purged > 0) {
252+
if (watches.overdue > 0 || watches.undelivered > 0) {
292253
logger.debug("Dashboard agent watch sweep", watches);
293254
}
294255
} catch (error) {

0 commit comments

Comments
 (0)