Skip to content

Commit 12000eb

Browse files
committed
fix(dashboard-agent): tell a queue that can't be read apart from one that isn't there
The queue's live row read collapsed every non-ok response into "no live row", so a 401, a 429 or a 5xx reached the model as exists:false — the queue does not exist. Only a 404 is evidence of absence now; anything else reports exists:"unknown" with the status, and the prompt says unknown is never missing.
1 parent dbb5acb commit 12000eb

4 files changed

Lines changed: 200 additions & 34 deletions

File tree

internal-packages/dashboard-agent/src/__snapshots__/prompt-prefix.test.ts.snap

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal-packages/dashboard-agent/src/tool-api.ts

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
isEnvUnavailable,
2727
NO_AUTH,
2828
type DashboardAgentApiClient,
29+
type EnvFetchResult,
2930
type EnvUnavailable,
3031
} from "./tool-api-client";
3132
import type { DashboardAgentToolContext } from "./tool-context";
@@ -105,18 +106,66 @@ export function consumerTasksForQueue(workers: unknown, queueName: string): stri
105106
return [...slugs].sort();
106107
}
107108

109+
/**
110+
* What the queue's live row read came back with. "The route said 404" and "the read never
111+
* landed" are different answers: only the first is evidence about the queue, and collapsing
112+
* them turns an expired token or a 5xx into "that queue doesn't exist".
113+
*/
114+
export type QueueLiveRead =
115+
| { kind: "row"; row: Record<string, unknown> }
116+
| { kind: "missing" }
117+
| { kind: "unknown"; status?: number };
118+
119+
/** Reads the live-row response into those three states. */
120+
export function readQueueLiveState(result: EnvFetchResult | null): QueueLiveRead {
121+
// No current environment, or a read that never landed: nothing is known either way.
122+
if (!result) return { kind: "unknown" };
123+
if (isEnvUnavailable(result)) {
124+
return {
125+
kind: "unknown",
126+
status: result.envUnavailable === "unknown" ? result.status : undefined,
127+
};
128+
}
129+
if (!result.ok) {
130+
// A request that never landed carries no status, and says nothing about the queue.
131+
if (!("status" in result)) return { kind: "unknown" };
132+
return result.status === 404 ? { kind: "missing" } : { kind: "unknown", status: result.status };
133+
}
134+
const row = (result.data as { data?: Record<string, unknown> })?.data ?? result.data;
135+
if (!row || typeof row !== "object") return { kind: "unknown" };
136+
return { kind: "row", row: row as Record<string, unknown> };
137+
}
138+
139+
/**
140+
* The better of two live reads of the same queue name under either kind. A row wins; failing
141+
* that a failed read wins over a 404, since one 404 with the other read broken is not proof.
142+
*/
143+
export function pickQueueLiveState(first: QueueLiveRead, second: QueueLiveRead): QueueLiveRead {
144+
if (first.kind === "row") return first;
145+
if (second.kind === "row") return second;
146+
if (first.kind === "unknown") return first;
147+
return second;
148+
}
149+
108150
/**
109151
* Metrics plus the queue's live row. `paused` is the part the model must lead with: a queue
110152
* someone stopped explains its own emptiness, and every metric below it is a consequence
111-
* rather than a finding.
153+
* rather than a finding. When the read failed, `exists` is `"unknown"` rather than `false`,
154+
* because an unreachable queue is not an absent one.
112155
*/
113-
function withLiveState(
114-
metrics: unknown,
115-
queueType: "task" | "custom",
116-
state: Record<string, unknown> | undefined
117-
) {
118-
const row = (state as { data?: Record<string, unknown> })?.data ?? state;
119-
if (!row) return { ...(metrics as object), queueType, exists: false };
156+
export function withLiveState(metrics: unknown, queueType: "task" | "custom", live: QueueLiveRead) {
157+
if (live.kind === "missing") return { ...(metrics as object), queueType, exists: false };
158+
if (live.kind === "unknown") {
159+
return {
160+
...(metrics as object),
161+
queueType,
162+
exists: "unknown" as const,
163+
liveStateError: live.status
164+
? `Couldn't read the queue's live row (status ${live.status}).`
165+
: "Couldn't read the queue's live row.",
166+
};
167+
}
168+
const { row } = live;
120169
return {
121170
...(metrics as object),
122171
queueType: (row.type as string) ?? queueType,
@@ -422,18 +471,12 @@ export function buildApiTools(args: {
422471
const result = await envApiGet(
423472
`/api/v1/queues/${encodeURIComponent(queue)}?type=${kind}`
424473
);
425-
return !isEnvUnavailable(result) && result.ok
426-
? (result.data as Record<string, unknown>)
427-
: undefined;
474+
return readQueueLiveState(result);
428475
};
429476

430477
// Only a custom queue needs this read: a task queue's consumer is the task it is
431478
// named after, while a custom queue's name says nothing about who writes to it.
432-
const answer = async (
433-
metrics: unknown,
434-
kind: "task" | "custom",
435-
state: Record<string, unknown> | undefined
436-
) => {
479+
const answer = async (metrics: unknown, kind: "task" | "custom", state: QueueLiveRead) => {
437480
const base = withLiveState(metrics, kind, state);
438481
if (base.queueType !== "custom" || !hasAuth || !projectRef || !environmentName) {
439482
return base;
@@ -463,7 +506,9 @@ export function buildApiTools(args: {
463506
// Neither kind has metrics, so the live row is the only thing that can tell them
464507
// apart: a paused or empty queue that exists, against a name that doesn't.
465508
const kind = type ?? "task";
466-
const state = (await live(kind)) ?? (await live(otherKind));
509+
const primary = await live(kind);
510+
const state =
511+
primary.kind === "row" ? primary : pickQueueLiveState(primary, await live(otherKind));
467512
return await answer(first.data, kind, state);
468513
}
469514
const kind = type ?? "task";

internal-packages/dashboard-agent/src/tool-queue.test.ts

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
import { describe, expect, it } from "vitest";
2-
import { consumerTasksForQueue, queueMetricsAreEmpty } from "./tool-api";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
buildApiTools,
4+
consumerTasksForQueue,
5+
pickQueueLiveState,
6+
queueMetricsAreEmpty,
7+
readQueueLiveState,
8+
withLiveState,
9+
} from "./tool-api";
10+
import { createApiClient } from "./tool-api-client";
311

412
/**
513
* The metrics route answers an unknown queue with zeroes rather than a 404, so asking for
@@ -68,3 +76,116 @@ describe("consumerTasksForQueue", () => {
6876
expect(consumerTasksForQueue({ worker: { tasks: [{}] } }, "email-sends")).toEqual([]);
6977
});
7078
});
79+
80+
/**
81+
* A queue nobody can read is not a queue that isn't there. Only the route answering 404 is
82+
* evidence of absence; a 401, a 429 or a 5xx is evidence of nothing, and reporting one as
83+
* `exists: false` tells the model a queue holding thousands of runs was deleted.
84+
*/
85+
describe("the queue's live row has three answers, not two", () => {
86+
const metrics = { peakQueued: 4800, startedCount: 12 };
87+
88+
it("reads a row, a 404 and a failed read apart", () => {
89+
expect(readQueueLiveState({ ok: true, data: { paused: true } })).toEqual({
90+
kind: "row",
91+
row: { paused: true },
92+
});
93+
expect(readQueueLiveState({ ok: false, status: 404 })).toEqual({ kind: "missing" });
94+
for (const status of [401, 403, 429, 500, 503]) {
95+
expect(readQueueLiveState({ ok: false, status })).toEqual({ kind: "unknown", status });
96+
}
97+
// No current environment: nothing was asked, so nothing is known.
98+
expect(readQueueLiveState(null)).toEqual({ kind: "unknown" });
99+
});
100+
101+
it("says unknown rather than absent when the read failed", () => {
102+
expect(withLiveState(metrics, "custom", { kind: "unknown", status: 503 })).toMatchObject({
103+
exists: "unknown",
104+
liveStateError: "Couldn't read the queue's live row (status 503).",
105+
});
106+
expect(withLiveState(metrics, "custom", { kind: "missing" })).toMatchObject({ exists: false });
107+
expect(
108+
withLiveState(metrics, "custom", { kind: "row", row: { paused: true, queued: 9 } })
109+
).toMatchObject({ exists: true, paused: true, queuedNow: 9 });
110+
});
111+
112+
it("prefers a row, then a failed read, over a single 404", () => {
113+
const row = { kind: "row", row: { paused: false } } as const;
114+
const missing = { kind: "missing" } as const;
115+
const unknown = { kind: "unknown", status: 500 } as const;
116+
117+
expect(pickQueueLiveState(missing, row)).toEqual(row);
118+
expect(pickQueueLiveState(unknown, row)).toEqual(row);
119+
// One kind 404s while the other read broke: that is not proof the name is free.
120+
expect(pickQueueLiveState(missing, unknown)).toEqual(unknown);
121+
expect(pickQueueLiveState(unknown, missing)).toEqual(unknown);
122+
expect(pickQueueLiveState(missing, missing)).toEqual(missing);
123+
});
124+
});
125+
126+
/** The same three cases through `get_queue`, since the tool output is what the model reads. */
127+
describe("get_queue reports the live read it actually got", () => {
128+
const ORIGIN = "https://api.example.com";
129+
130+
function stubFetch(liveResponse: () => Response) {
131+
vi.stubGlobal(
132+
"fetch",
133+
vi.fn(async (input: any) => {
134+
const url = typeof input === "string" ? input : input.url;
135+
if (url.endsWith("/jwt")) {
136+
return new Response(JSON.stringify({ token: "env-jwt" }), { status: 200 });
137+
}
138+
if (url.includes("/metrics")) {
139+
return new Response(JSON.stringify({ peakQueued: 4800, startedCount: 12 }), {
140+
status: 200,
141+
});
142+
}
143+
return liveResponse();
144+
})
145+
);
146+
}
147+
148+
function getQueue() {
149+
const ctx = {
150+
userActorToken: "uat",
151+
apiOrigin: ORIGIN,
152+
projectRef: "proj_ref",
153+
environmentName: "dev",
154+
};
155+
const tools = buildApiTools({
156+
ctx,
157+
client: createApiClient(ctx),
158+
renderInvestigations: (() => []) as any,
159+
});
160+
return (input: any) => (tools.get_queue as any).execute(input, {} as any);
161+
}
162+
163+
afterEach(() => vi.unstubAllGlobals());
164+
165+
it("reports a healthy row as the queue that exists", async () => {
166+
stubFetch(
167+
() =>
168+
new Response(JSON.stringify({ type: "custom", paused: true, queued: 31 }), { status: 200 })
169+
);
170+
await expect(getQueue()({ queue: "email-sends", type: "custom" })).resolves.toMatchObject({
171+
exists: true,
172+
paused: true,
173+
queuedNow: 31,
174+
});
175+
});
176+
177+
it("reports a 404 as the queue that isn't there", async () => {
178+
stubFetch(() => new Response("", { status: 404 }));
179+
await expect(getQueue()({ queue: "email-sends", type: "custom" })).resolves.toMatchObject({
180+
exists: false,
181+
});
182+
});
183+
184+
it("reports a failed read as unknown, never as absent", async () => {
185+
stubFetch(() => new Response("", { status: 503 }));
186+
const answer = await getQueue()({ queue: "email-sends", type: "custom" });
187+
expect(answer).toMatchObject({ exists: "unknown" });
188+
expect(answer.exists).not.toBe(false);
189+
expect(answer.liveStateError).toContain("503");
190+
});
191+
});

internal-packages/dashboard-agent/src/tool-schemas.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export const getReportSchema = tool({
190190

191191
export const getQueueSchema = tool({
192192
description:
193-
"Get one queue's metrics over a window: wait latency (p50/p95), peak depth, how many runs started (throughput), and how often the queue was throttled by its concurrency limit. Use this for 'how deep is the X queue', 'is X backed up', or 'why are runs waiting'. The answer also carries the queue's live row: `paused`, `queuedNow`, `runningNow`, `concurrencyLimit`, and `exists: false` when no queue of that name is there at all. For a custom queue it also carries `consumerTasks`: the deployed tasks whose queue config names this queue.",
193+
"Get one queue's metrics over a window: wait latency (p50/p95), peak depth, how many runs started (throughput), and how often the queue was throttled by its concurrency limit. Use this for 'how deep is the X queue', 'is X backed up', or 'why are runs waiting'. The answer also carries the queue's live row: `paused`, `queuedNow`, `runningNow`, `concurrencyLimit`, and `exists: false` when no queue of that name is there at all. When that read fails rather than answers, `exists` is `\"unknown\"` with a `liveStateError`: the queue's state is unknown, not missing. For a custom queue it also carries `consumerTasks`: the deployed tasks whose queue config names this queue.",
194194
inputSchema: z.object({
195195
queue: z
196196
.string()
@@ -483,7 +483,7 @@ You have read-only tools that act as the user against their own account:
483483
- ask_support: ask the Trigger.dev support assistant about how Trigger.dev works (docs, concepts, features, configuration, how-tos).
484484
- render_view: render a structured view in the panel from the block catalog. The catalog has the "diagnosis" block (a failure card for a single run), the "chart" block (a line/bar chart of run_query results), the "actions" block (a row of 1-3 buttons offering next steps — a watch intent opens the watch card pre-filled, an ask intent sends the labelled question as the user's next message), and the "investigation" block (a live card for a hypothesis-driven investigation).
485485
- get_report: the composed health report for the current environment (flow, execution, liveness), with a severity and the metrics behind each.
486-
- get_queue: one queue's wait latency, peak depth, throughput, and throttling over a window, plus its live row. Lead with paused when it is true: a paused queue explains its own emptiness, and every metric under it is a consequence, not a finding — say it is paused, and only then the numbers. queuedNow is what is waiting right now, which a window of metrics cannot show; exists:false is the only thing that means the queue isn't there, never zeroed metrics. A custom queue's name is not a task id, so no task being named after it is not evidence about it — never conclude from list_tasks or a deployment that a queue is unconsumed, undeployed, deleted, or renamed. consumerTasks is the answer to "who feeds this queue": empty means nothing deployed writes to it, and absent means you did not ask a custom queue.
486+
- get_queue: one queue's wait latency, peak depth, throughput, and throttling over a window, plus its live row. Lead with paused when it is true: a paused queue explains its own emptiness, so say it is paused and only then the numbers. queuedNow is what is waiting right now, which a window of metrics cannot show; exists:false is the only thing that means the queue isn't there, never zeroed metrics, and exists:"unknown" means the live read failed — unknown, never missing. A custom queue's name is not a task id, so no task being named after it is not evidence about it — never conclude from list_tasks or a deployment that it is unconsumed, deleted, or renamed. consumerTasks is the answer to "who feeds this queue": empty means nothing deployed writes to it, and absent means you did not ask a custom queue.
487487
- list_deploys: recent deployments (versions) in the current environment, with status and commit message.
488488
- get_deploy: one deployment's detail, or the current promoted one when you omit the version.
489489
- correlate_version: the version, commit, and pull request a specific run actually ran.

0 commit comments

Comments
 (0)