Skip to content

Commit 43deaed

Browse files
committed
fix(dashboard-agent): a failed batch check keeps the chain alive
1 parent 00c210b commit 43deaed

4 files changed

Lines changed: 74 additions & 32 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ const WATCH_BATCH_TOKEN_CLIENT = "dashboard-agent-watch-batch";
9797

9898
/**
9999
* How long a chain's token lives. A chain has no deadline to pin it to, but it must still
100-
* expire; an expired one is self-healing via the re-arm backstop.
100+
* expire. A chain whose token no longer verifies keeps ticking — a failed check is never a
101+
* verdict — and gets a fresh token when the re-arm backstop starts the next epoch.
101102
*/
102103
export const WATCH_BATCH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
103104

internal-packages/dashboard-agent/GUIDEBOOK.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,9 @@ falls within one cadence is always due, so the final evaluation is never missed.
371371
The group's chain stops only when nothing is active **and** nothing is owed.
372372

373373
A check that came back `unavailable` in a batch is recorded as an attempt rather
374-
than as a check, so the stall streak and the last-checked time survive it.
374+
than as a check, so the stall streak and the last-checked time survive it. If the
375+
batch check itself can't be read, nothing was looked at, so nothing is recorded —
376+
and the chain still reschedules, so the group keeps its cadence.
375377

376378
A sweep (`dashboardAgentWatchSweep.server.ts`) is the backstop:
377379

internal-packages/dashboard-agent/src/watch-batch.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ export type WatchBatchTickDeps = {
5959
};
6060

6161
export type WatchBatchTickResult = {
62-
outcome: "ticked" | "stale";
62+
// `unavailable`: the check phase itself couldn't run, so the group learned nothing.
63+
outcome: "ticked" | "stale" | "unavailable";
6364
results: Array<{ watchId: string; outcome?: WatchTickOutcome; error?: string }>;
6465
rescheduled: boolean;
6566
};
@@ -92,7 +93,22 @@ export async function runWatchBatchTick(
9293
payload: WatchBatchTickPayload,
9394
deps: WatchBatchTickDeps
9495
): Promise<WatchBatchTickResult> {
95-
const response = await deps.checkBatch(payload);
96+
let response: WatchBatchCheckResponse;
97+
try {
98+
response = await deps.checkBatch(payload);
99+
} catch (error) {
100+
// A check failure is never a verdict: the group learned nothing, so nothing is
101+
// recorded — no watch was looked at — and the chain ticks on to try again.
102+
logger.error("dashboard-agent watch batch check is unavailable; ticking on", {
103+
environmentId: payload.environmentId,
104+
cadenceMinutes: payload.cadenceMinutes,
105+
epoch: payload.epoch,
106+
tick: payload.tick,
107+
error: (error as Error).message,
108+
});
109+
await scheduleNextBatchTick(payload, deps);
110+
return { outcome: "unavailable", results: [], rescheduled: true };
111+
}
96112

97113
if (response.stale) {
98114
logger.info("dashboard-agent watch batch is stale; exiting", {
@@ -114,16 +130,7 @@ export async function runWatchBatchTick(
114130
// Before the rethrow below, so the chain survives a watch that keeps failing.
115131
let rescheduled = false;
116132
if (response.continues) {
117-
const next = payload.tick + 1;
118-
await deps.reschedule(
119-
{ ...payload, tick: next },
120-
{
121-
delay: `${payload.cadenceMinutes}m`,
122-
// Keyed on the epoch too, so a re-armed chain can't collide with its
123-
// predecessor's keys.
124-
idempotencyKey: `watch-batch:${payload.environmentId}:${payload.cadenceMinutes}:${payload.epoch}:tick:${next}`,
125-
}
126-
);
133+
await scheduleNextBatchTick(payload, deps);
127134
rescheduled = true;
128135
}
129136

@@ -141,6 +148,24 @@ export async function runWatchBatchTick(
141148
return { outcome: "ticked", results, rescheduled };
142149
}
143150

151+
// The successor's generation comes from this run's own, and rides in the idempotency
152+
// key, so a resumed or duplicated tick schedules the same successor once.
153+
async function scheduleNextBatchTick(
154+
payload: WatchBatchTickPayload,
155+
deps: WatchBatchTickDeps
156+
): Promise<void> {
157+
const next = payload.tick + 1;
158+
await deps.reschedule(
159+
{ ...payload, tick: next },
160+
{
161+
delay: `${payload.cadenceMinutes}m`,
162+
// Keyed on the epoch too, so a re-armed chain can't collide with its
163+
// predecessor's keys.
164+
idempotencyKey: `watch-batch:${payload.environmentId}:${payload.cadenceMinutes}:${payload.epoch}:tick:${next}`,
165+
}
166+
);
167+
}
168+
144169
/** One watch of a batch: each resolves in its own try, so a failure isolates. */
145170
async function resolveBatchEntry(
146171
payload: WatchBatchTickPayload,

internal-packages/dashboard-agent/src/watch-tick.test.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,28 +1344,42 @@ describe("runWatchBatchTick", () => {
13441344
expect(appends.map((append) => append.chatId)).toEqual(["chat_2"]);
13451345
});
13461346

1347-
it("throws when the batch check itself can't be read, before anything is scheduled", async () => {
1348-
const rows = group(2);
1347+
it("a batch check that can't be read keeps the chain alive, and records nothing", async () => {
1348+
const rows = group(2, { tickCount: 6, lastCheckedAt: NOW, lastResult: { runs: 1 } });
13491349
const { store, calls } = fakeStore(rows[0]!, rows[1]!);
1350-
const { deliver } = fakeDeliver();
1351-
const triggers: unknown[] = [];
1350+
const { appends, deliver } = fakeDeliver();
1351+
const triggers: Array<{ payload: WatchBatchTickPayload; options: Record<string, unknown> }> =
1352+
[];
13521353

1353-
await expect(
1354-
runWatchBatchTick(
1355-
batchPayload(7),
1356-
batchDeps({
1357-
store,
1358-
response: async () => {
1359-
throw new Error("the batch check returned 500");
1360-
},
1361-
deliver,
1362-
reschedule: async () => void triggers.push(1),
1363-
})
1364-
)
1365-
).rejects.toThrow("the batch check returned 500");
1354+
const result = await runWatchBatchTick(
1355+
batchPayload(7),
1356+
batchDeps({
1357+
store,
1358+
response: async () => {
1359+
throw new Error("the batch check returned 401");
1360+
},
1361+
deliver,
1362+
reschedule: async (payload, options) => void triggers.push({ payload, options }),
1363+
})
1364+
);
1365+
1366+
expect(result).toEqual({ outcome: "unavailable", results: [], rescheduled: true });
13661367

1368+
// Nothing was looked at, so the streak and the last-checked time stand.
13671369
expect(calls.checks).toHaveLength(0);
1368-
expect(triggers).toHaveLength(0);
1370+
expect(calls.claims).toHaveLength(0);
1371+
expect(appends).toHaveLength(0);
1372+
expect(rows.map((row) => row.status)).toEqual(["active", "active"]);
1373+
expect(rows.map((row) => row.tickCount)).toEqual([6, 6]);
1374+
expect(rows.map((row) => row.lastCheckedAt)).toEqual([NOW, NOW]);
1375+
1376+
expect(triggers).toHaveLength(1);
1377+
expect(triggers[0]?.payload).toEqual(batchPayload(8));
1378+
// The same key the healthy path uses, so a retried tick can't fork the chain.
1379+
expect(triggers[0]?.options).toEqual({
1380+
delay: "5m",
1381+
idempotencyKey: "watch-batch:env_1:5:3:tick:8",
1382+
});
13691383
});
13701384
});
13711385

0 commit comments

Comments
 (0)