Skip to content

Commit 7fca9f5

Browse files
committed
fix(webapp): resolved-watch adoption wording, stored queue name in page context
A retry that adopts a watch which has already fired or expired now confirms what the watch found instead of claiming it is still watching. The queue page hands the agent the stored queue name, so a queue watch it proposes validates instead of being rejected as a missing target.
1 parent e954d82 commit 7fca9f5

7 files changed

Lines changed: 123 additions & 8 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ export function DashboardAgentPanel({
383383
});
384384
}, []);
385385

386-
const dismissWatchCard = useCallback(() => dispatchWatchCard({ type: "dismissed" }), []);
386+
const dismissWatchCard = () => dispatchWatchCard({ type: "dismissed" });
387387

388388
const submitWatch = useCallback(async () => {
389389
const draft = watchCard.draft;

apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,12 @@ describe("queueAgentPageContext", () => {
212212
expect(agentPageContextSchema.safeParse(context).success).toBe(true);
213213
});
214214

215+
// A watch the agent proposes off this context is validated against the stored name.
216+
it("names a task queue by its stored name, prefix and all", () => {
217+
const context = queueAgentPageContext(queueLoaderData({ type: "task", name: "send-receipt" }));
218+
expect(context?.page).toMatchObject({ kind: "queue", name: "task/send-receipt" });
219+
});
220+
215221
it("emits no saturation signal when the queue is idle under its limit", () => {
216222
const context = queueAgentPageContext(queueLoaderData({ running: 10, queued: 0 }));
217223
expect(context?.signals).toEqual([]);

apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import type { AgentPageContext, AgentPageSignal } from "@internal/dashboard-agent-contracts";
66
import { z } from "zod";
7+
import { storedQueueName } from "~/components/queues/queue-name";
78
import { isQueueAtCapacity, OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
89

910
export const FRESH_FAILURE_WINDOW_MS = 30 * 60_000;
@@ -163,6 +164,7 @@ export const QUEUE_OLDEST_WAIT_WARNING_MS = OLDEST_WAIT_WARNING_MS;
163164
const queueLoaderDataSchema = z.object({
164165
queue: z.object({
165166
name: z.string(),
167+
type: z.string(),
166168
paused: z.boolean().nullish(),
167169
running: z.number(),
168170
queued: z.number(),
@@ -200,7 +202,7 @@ export function queueAgentPageContext(data: unknown): AgentPageContext | undefin
200202
const parsed = queueLoaderDataSchema.safeParse(data);
201203
if (!parsed.success) return undefined;
202204

203-
const { name, paused, running, queued, concurrencyLimit } = parsed.data.queue;
205+
const { name, type, paused, running, queued, concurrencyLimit } = parsed.data.queue;
204206
const { environmentConcurrencyLimit, oldestQueuedAt, loadedAt, ckBreakdown } = parsed.data;
205207
const limit = concurrencyLimit ?? environmentConcurrencyLimit ?? null;
206208
const atCapacity = isQueueAtCapacity({ running, queued, limit });
@@ -217,7 +219,11 @@ export function queueAgentPageContext(data: unknown): AgentPageContext | undefin
217219
signals.push({ kind: "concurrency_saturation", severity: queued >= limit! ? "crit" : "warn" });
218220
}
219221

220-
return { page: { kind: "queue", name, health, paused: Boolean(paused) }, signals };
222+
// The stored name, not the display one: a watch the agent proposes has to validate against it.
223+
return {
224+
page: { kind: "queue", name: storedQueueName({ type, name }), health, paused: Boolean(paused) },
225+
signals,
226+
};
221227
}
222228

223229
export function deploymentsAgentPageContext(): AgentPageContext {

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
watchIdentity,
3838
watchOneShotBlockBody,
3939
watchRequestSentence,
40+
watchResolvedBlockBody,
4041
watchSubjectLabel,
4142
type WatchDraft,
4243
type WatchExternalNotification,
@@ -609,8 +610,27 @@ export async function submitDashboardAgentWatch(params: {
609610
unavailable: boolean;
610611
external: WatchExternalNotification;
611612
confirmed?: WatchDraft;
613+
/** The row this settles against, when the caller already read it. */
614+
watch?: Watch | null;
612615
}) => {
613616
const confirmed = args.confirmed ?? draft;
617+
// A retry can settle against a watch that already ran: say what it found, not "watching".
618+
const resolution = args.watch?.status !== "active" ? args.watch?.resolution : null;
619+
if (args.watch && resolution) {
620+
return confirmationMessage({
621+
id: `${WATCH_CONFIRMATION_MESSAGE_ID_PREFIX}${args.watchId}`,
622+
blockId: args.watchId,
623+
body: watchResolvedBlockBody({
624+
watchId: args.watchId,
625+
resolved: {
626+
kind: confirmed.spec.kind,
627+
identity: args.watch.identity,
628+
resolution,
629+
observed: args.watch.observedOutcome,
630+
},
631+
}),
632+
});
633+
}
614634
return confirmationMessage({
615635
id: `${WATCH_CONFIRMATION_MESSAGE_ID_PREFIX}${args.watchId}`,
616636
blockId: args.watchId,
@@ -649,6 +669,7 @@ export async function submitDashboardAgentWatch(params: {
649669
return settle({
650670
confirmation: watchingConfirmation({
651671
watchId: recorded.watchId,
672+
watch: await getWatch(dashboardAgentDb, { id: recorded.watchId }),
652673
unavailable: recorded.unavailable,
653674
// Recorded, never re-decided: the confirmation already in the transcript is
654675
// append-once, so a second decision here would contradict it forever.
@@ -796,6 +817,8 @@ export async function submitDashboardAgentWatch(params: {
796817
unavailable: boolean;
797818
/** The watch was already there: this call adopted it rather than creating it. */
798819
adopted: boolean;
820+
/** The adopted row. Absent when this call created the watch, so it is active. */
821+
watch?: Watch | null;
799822
}): Promise<SubmitWatchCardResult> => {
800823
// Attached after the watch exists, and a failure here never fails the creation — it is
801824
// said out loud in the confirmation instead, and recorded so a replay repeats it.
@@ -830,6 +853,7 @@ export async function submitDashboardAgentWatch(params: {
830853
return settle({
831854
confirmation: watchingConfirmation({
832855
watchId: args.watchId,
856+
watch: args.watch,
833857
unavailable: args.unavailable,
834858
external,
835859
}),
@@ -851,7 +875,12 @@ export async function submitDashboardAgentWatch(params: {
851875
});
852876
}
853877
// `unavailable` isn't recoverable here: it belonged to the attempt that died.
854-
return settleCreated({ watchId: reserved.id, unavailable: false, adopted: true });
878+
return settleCreated({
879+
watchId: reserved.id,
880+
unavailable: false,
881+
adopted: true,
882+
watch: reserved,
883+
});
855884
}
856885

857886
const result = await create({

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2300,6 +2300,49 @@ describe("the watch card submit", () => {
23002300
}
23012301
);
23022302

2303+
postgresTest(
2304+
"converging on a watch that already fired confirms the outcome, not 'watching'",
2305+
async ({ prisma, postgresContainer }) => {
2306+
await boot(prisma, postgresContainer.getConnectionUri());
2307+
const seeded = await seed(prisma, "submit-converge-fired");
2308+
await seedChat(seeded);
2309+
2310+
let reservedWatchId = "";
2311+
await expect(
2312+
submit({
2313+
seeded,
2314+
chatId: "chat_1",
2315+
create: async (createParams) => {
2316+
reservedWatchId = createParams.watchId!;
2317+
await createDashboardAgentWatch(createParams);
2318+
throw new Error("died after the watch was created");
2319+
},
2320+
})
2321+
).rejects.toThrow("died after the watch was created");
2322+
2323+
// The watch ran and woke the chat before anyone retried the submit.
2324+
await transitionWatchCondition(ctx.agentDb, {
2325+
id: reservedWatchId,
2326+
resolution: "condition_met",
2327+
observedOutcome: { kind: "run_start", verified: true, status: "EXECUTING", started: true },
2328+
});
2329+
2330+
const retry = await submit({ seeded, chatId: "chat_1" });
2331+
2332+
expect(retry.ok).toBe(true);
2333+
if (!retry.ok) return;
2334+
// Still one row, still the same watch: adoption is not refused.
2335+
expect(retry.watchId).toBe(reservedWatchId);
2336+
expect(await countWatchRows(prisma, "chat_1")).toBe(1);
2337+
2338+
const parts = retry.messages.at(-1)?.parts ?? [];
2339+
const block = (parts[0] as any).data.blocks[0];
2340+
expect(block.outcome).toBe("already_true");
2341+
expect(block.headline).not.toContain("Watching");
2342+
expect(block.lifetime).toBeNull();
2343+
}
2344+
);
2345+
23032346
postgresTest(
23042347
"a refusal that wins the race leaves no live watch behind",
23052348
async ({ prisma, postgresContainer }) => {

internal-packages/dashboard-agent-contracts/src/watch-wording.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,31 @@ export function watchConfirmationBlockBody(args: {
614614
};
615615
}
616616

617+
/**
618+
* The confirmation for a watch that had already resolved before this submission settled
619+
* (a retry adopting a fired or expired row). It states the outcome the watch reached
620+
* rather than claiming something is still being watched.
621+
*/
622+
export function watchResolvedBlockBody(args: { watchId: string; resolved: WatchResolvedInput }): {
623+
type: "watch_result";
624+
outcome: "already_true" | "impossible";
625+
headline: string;
626+
lifetime: null;
627+
detail: null;
628+
followUp: never[];
629+
watchId: string;
630+
} {
631+
return {
632+
type: "watch_result",
633+
outcome: args.resolved.resolution === "condition_met" ? "already_true" : "impossible",
634+
headline: presentResolvedWatch(args.resolved).headline,
635+
lifetime: null,
636+
detail: null,
637+
followUp: [],
638+
watchId: args.watchId,
639+
};
640+
}
641+
617642
/**
618643
* The one-shot result block: the immediate check answered outright, so no watch was
619644
* created. Nothing is running, so there is no lifetime and no follow-ups.

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export const WATCH_STALL_TICKS_MAX = 12;
3737
/** Ceiling on the `queue_oldest_age` SLA. */
3838
export const WATCH_MAX_QUEUE_AGE_MINUTES = 24 * 60;
3939

40+
const watchQueueNameSchema = z
41+
.string()
42+
.describe(
43+
"The stored queue name, keeping the `task/` prefix for task queues (e.g. `task/send-receipt`) — not the display name."
44+
);
45+
4046
/**
4147
* Kinds taking the same fields share one member with a `kind` enum. They still ask
4248
* different questions — `run_failed` inverts `run_finished`, `queue_depth_below` is
@@ -50,12 +56,12 @@ export const watchSpecSchema = z.discriminatedUnion("kind", [
5056
})
5157
.merge(runStateCadenceSchema),
5258
watchCommonSchema
53-
.extend({ kind: z.literal("backlog_drain"), queue: z.string() })
59+
.extend({ kind: z.literal("backlog_drain"), queue: watchQueueNameSchema })
5460
.merge(standardCadenceSchema),
5561
watchCommonSchema
5662
.extend({
5763
kind: z.enum(["queue_depth_above", "queue_depth_below"]),
58-
queue: z.string(),
64+
queue: watchQueueNameSchema,
5965
threshold: z.number().int().nonnegative().max(WATCH_MAX_QUEUE_THRESHOLD),
6066
})
6167
.merge(standardCadenceSchema),
@@ -64,7 +70,7 @@ export const watchSpecSchema = z.discriminatedUnion("kind", [
6470
watchCommonSchema
6571
.extend({
6672
kind: z.literal("queue_stalled"),
67-
queue: z.string(),
73+
queue: watchQueueNameSchema,
6874
ticks: z
6975
.number()
7076
.int()
@@ -76,7 +82,7 @@ export const watchSpecSchema = z.discriminatedUnion("kind", [
7682
watchCommonSchema
7783
.extend({
7884
kind: z.literal("queue_oldest_age"),
79-
queue: z.string(),
85+
queue: watchQueueNameSchema,
8086
thresholdMinutes: z.number().int().positive().max(WATCH_MAX_QUEUE_AGE_MINUTES),
8187
})
8288
.merge(standardCadenceSchema),

0 commit comments

Comments
 (0)