Skip to content

Commit 60154ec

Browse files
authored
Merge pull request #291 from Blazity/fix/aiw-277-dispatch-at-capacity
feat(worker): surface at-capacity dispatch refusals in logs, Jira, and dashboard (AIW-277)
2 parents 7339fa6 + 3bed19a commit 60154ec

20 files changed

Lines changed: 8063 additions & 37 deletions

File tree

apps/dashboard/app/overview-data.tsx

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
KpisResponse,
1111
EvalHealthResponse,
1212
LiveRunsResponse,
13+
DispatchCapacityResponse,
1314
RunsResponse,
1415
WorkflowsResponse,
1516
} from "@shared/contracts";
@@ -18,6 +19,7 @@ import {
1819
evalHealthFallback,
1920
recentRunsFallback,
2021
liveRunsFallback,
22+
dispatchCapacityFallback,
2123
workflowsFallback,
2224
} from "@/lib/api/fallbacks";
2325
import { deriveKpisFromRuns } from "@/lib/api/derive-kpis";
@@ -43,21 +45,27 @@ export async function OverviewData({ window }: { window: TimeWindow }) {
4345

4446
// Window scopes the historical aggregates (KPIs, recent runs, workflows).
4547
// Eval-health (Arthur) and live runs (registry) are not windowed here.
46-
const [kpis, evalHealth, recentRuns, liveRuns, workflows] = await Promise.all([
47-
getJSON<KpisResponse>(withQuery("/api/v1/overview/kpis", { window })).catch(
48-
(e) => authAwareFallback(e, () => kpisFallback(now)),
49-
),
50-
getJSON<EvalHealthResponse>("/api/v1/overview/eval-health").catch(
51-
(e) => authAwareFallback(e, () => evalHealthFallback()),
52-
),
53-
getJSON<RunsResponse>(withQuery("/api/v1/runs", { window })).catch((e) =>
54-
authAwareFallback(e, () => recentRunsFallback(now)),
55-
),
56-
getJSON<LiveRunsResponse>("/api/v1/runs/live").catch((e) => authAwareFallback(e, () => liveRunsFallback(now))),
57-
getJSON<WorkflowsResponse>(withQuery("/api/v1/workflows", { window })).catch(
58-
(e) => authAwareFallback(e, () => workflowsFallback(now)),
59-
),
60-
]);
48+
const [kpis, evalHealth, recentRuns, liveRuns, capacity, workflows] =
49+
await Promise.all([
50+
getJSON<KpisResponse>(withQuery("/api/v1/overview/kpis", { window })).catch(
51+
(e) => authAwareFallback(e, () => kpisFallback(now)),
52+
),
53+
getJSON<EvalHealthResponse>("/api/v1/overview/eval-health").catch(
54+
(e) => authAwareFallback(e, () => evalHealthFallback()),
55+
),
56+
getJSON<RunsResponse>(withQuery("/api/v1/runs", { window })).catch((e) =>
57+
authAwareFallback(e, () => recentRunsFallback(now)),
58+
),
59+
getJSON<LiveRunsResponse>("/api/v1/runs/live").catch((e) =>
60+
authAwareFallback(e, () => liveRunsFallback(now)),
61+
),
62+
getJSON<DispatchCapacityResponse>("/api/v1/dispatch/capacity").catch((e) =>
63+
authAwareFallback(e, () => dispatchCapacityFallback(now)),
64+
),
65+
getJSON<WorkflowsResponse>(withQuery("/api/v1/workflows", { window })).catch(
66+
(e) => authAwareFallback(e, () => workflowsFallback(now)),
67+
),
68+
]);
6169

6270
// The worker's KPI endpoint returns null when its run-store fetch is rejected
6371
// (page-size cap). Derive the tiles from the runs list we already have so the
@@ -80,6 +88,7 @@ export async function OverviewData({ window }: { window: TimeWindow }) {
8088
kpis: mergedKpis,
8189
evalHealth,
8290
liveRuns: reconcileOverviewLiveRuns(recentRuns, liveRuns),
91+
capacity,
8392
recentRuns,
8493
workflows,
8594
};

apps/dashboard/components/cockpit/screens/overview.test.tsx

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from "rea
55
import { AppRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
66

77
import type { Run } from "@/lib/types";
8-
import { AwaitingInputPanel } from "./overview";
8+
import type { DispatchCapacityResponse } from "@shared/contracts";
9+
import { AwaitingInputPanel, NowRunningPanel } from "./overview";
910

1011
(globalThis as typeof globalThis & { React: typeof React }).React = React;
1112
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -71,6 +72,89 @@ function renderPanel(t: TestContext, rows: Run[]): { root: ReactTestInstance; op
7172
return { root: renderer.root, opened };
7273
}
7374

75+
function capacity(
76+
over: Partial<DispatchCapacityResponse> = {},
77+
): DispatchCapacityResponse {
78+
return {
79+
generatedAt: "2026-08-16T12:00:00.000Z",
80+
occupiedSlots: 0,
81+
maxSlots: 3,
82+
queued: [],
83+
...over,
84+
};
85+
}
86+
87+
function renderNowRunning(
88+
t: TestContext,
89+
rows: Run[],
90+
cap: DispatchCapacityResponse,
91+
): ReactTestInstance {
92+
let renderer!: ReactTestRenderer;
93+
act(() => {
94+
renderer = create(
95+
<AppRouterContext.Provider value={stubRouter() as never}>
96+
<NowRunningPanel rows={rows} capacity={cap} onOpenRun={() => {}} />
97+
</AppRouterContext.Provider>,
98+
);
99+
});
100+
t.after(() => act(() => renderer.unmount()));
101+
return renderer.root;
102+
}
103+
104+
test("a full pool with zero executing runs shows it is full and lists the waiting tickets", (t) => {
105+
// The bug AIW-277 fixes: parked claims fill every slot, nothing is "running",
106+
// and the panel used to read as idle. It must now show the occupied count and
107+
// the at-capacity queue.
108+
const root = renderNowRunning(t, [], capacity({
109+
occupiedSlots: 3,
110+
maxSlots: 3,
111+
queued: [
112+
{ ticketKey: "AWT-9", queuedAt: new Date(Date.now() - 5 * 60_000 - 5_000).toISOString() },
113+
],
114+
}));
115+
116+
const text = nodeText(root);
117+
assert.match(text, /3\/3 slots/);
118+
assert.match(text, /waiting for capacity/);
119+
assert.match(text, /AWT-9/);
120+
assert.match(text, /waiting 5m/);
121+
});
122+
123+
test("an idle pool shows free slots and no waiting queue", (t) => {
124+
const root = renderNowRunning(t, [], capacity({ occupiedSlots: 0, maxSlots: 3 }));
125+
126+
const text = nodeText(root);
127+
assert.match(text, /0\/3 slots/);
128+
assert.doesNotMatch(text, /waiting for capacity/);
129+
});
130+
131+
test("the worker-unavailable fallback (maxSlots 0) reads as unknown, never full", (t) => {
132+
const root = renderNowRunning(t, [], capacity({ occupiedSlots: 0, maxSlots: 0 }));
133+
134+
const text = nodeText(root);
135+
assert.match(text, /slots /);
136+
// The 0/0 fallback must not render as "0/0" nor claim the pool is full.
137+
assert.doesNotMatch(text, /0\/0/);
138+
});
139+
140+
test("a running ticket is excluded from the waiting-for-capacity list", (t) => {
141+
// A ticket stays in the AI column while it runs, so its stale queue row could
142+
// leak into the waiting list — the panel must drop any ticket already live.
143+
const runningRow: Run = { ...BASE_RUN, id: "run_live", status: "running", ticket: "AWT-9" };
144+
const root = renderNowRunning(
145+
t,
146+
[runningRow],
147+
capacity({
148+
occupiedSlots: 3,
149+
maxSlots: 3,
150+
queued: [{ ticketKey: "AWT-9", queuedAt: new Date(Date.now() - 3 * 60_000).toISOString() }],
151+
}),
152+
);
153+
154+
const text = nodeText(root);
155+
assert.doesNotMatch(text, /waiting for capacity/);
156+
});
157+
74158
test("a clarification row keeps its Answer CTA to the run trace, unchanged", (t) => {
75159
const row: Run = {
76160
...BASE_RUN,

apps/dashboard/components/cockpit/screens/overview.tsx

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@/components/ui";
1515
import { Spark, Donut } from "@/components/charts";
1616
import { runModelLabel } from "@/lib/run-model";
17+
import { formatWaited } from "@/lib/waited";
1718
import { spanColor } from "@/lib/theme";
1819
import { useCockpit } from "@/components/cockpit/context";
1920
import { WindowSelector } from "@/components/cockpit/controls";
@@ -29,15 +30,17 @@ import type {
2930
KpisResponse,
3031
EvalHealthResponse,
3132
LiveRunsResponse,
33+
DispatchCapacityResponse,
3234
RunsResponse,
3335
WorkflowsResponse,
3436
} from "@shared/contracts";
3537

36-
/** Bundle of the five server-fetched responses passed into the presentational Overview. */
38+
/** Bundle of the server-fetched responses passed into the presentational Overview. */
3739
export interface OverviewScreenData {
3840
kpis: KpisResponse;
3941
evalHealth: EvalHealthResponse;
4042
liveRuns: LiveRunsResponse;
43+
capacity: DispatchCapacityResponse;
4144
recentRuns: RunsResponse;
4245
workflows: WorkflowsResponse;
4346
}
@@ -120,27 +123,54 @@ function EvalHealthKPI({ data }: { data: EvalHealthResponse | undefined }) {
120123
);
121124
}
122125

123-
/* Live "Now running" panel — currently executing runs only. */
124-
function NowRunningPanel({
126+
/* Live "Now running" panel. Shows executing runs, plus the occupied-slot count
127+
* counted the way dispatch refuses (parked claims included, from
128+
* listCapacityConsumers) and the at-capacity waiting queue — so a full pool with
129+
* zero executing runs no longer looks idle. */
130+
export function NowRunningPanel({
125131
rows,
132+
capacity,
126133
onOpenRun,
127134
}: {
128135
rows: Run[];
136+
capacity: DispatchCapacityResponse;
129137
onOpenRun: (run: Run) => void;
130138
}) {
131139
const running = rows.filter((r) => r.status === "running");
140+
const { occupiedSlots, maxSlots } = capacity;
141+
// maxSlots === 0 is the worker-unavailable fallback: capacity is UNKNOWN, not
142+
// full, so never render the amber "full" style for it.
143+
const capacityUnknown = maxSlots === 0;
144+
const poolFull = !capacityUnknown && occupiedSlots >= maxSlots;
145+
// Defense in depth against a stale row: a ticket that is running (or otherwise
146+
// holds a live slot) is not waiting, so it must never show in both panels.
147+
const liveTickets = new Set(rows.map((r) => r.ticket));
148+
const queued = capacity.queued.filter((q) => !liveTickets.has(q.ticketKey));
132149

133150
return (
134151
<CkCard
135152
eyebrow="Vercel workflow · live"
136153
title="Now running"
137154
action={
138-
<span className="inline-flex items-center gap-1.5 font-mono text-[10px] text-mariner tracking-[0.04em] uppercase">
139-
<span className="relative w-1.5 h-1.5">
140-
<span className="absolute inset-0 rounded-full bg-mariner" />
141-
<span className="absolute -inset-[3px] rounded-full border border-mariner animate-ck-pulse" />
155+
<span className="inline-flex items-center gap-2.5 font-mono text-[10px] tracking-[0.04em] uppercase">
156+
<span className="inline-flex items-center gap-1.5 text-mariner">
157+
<span className="relative w-1.5 h-1.5">
158+
<span className="absolute inset-0 rounded-full bg-mariner" />
159+
<span className="absolute -inset-[3px] rounded-full border border-mariner animate-ck-pulse" />
160+
</span>
161+
{running.length} executing
162+
</span>
163+
{/* Slots in use, from the same count dispatch refuses against, so a
164+
pool full of parked claims is not mistaken for an idle one. */}
165+
<span
166+
className={`inline-flex items-center rounded-[3px] border px-1.5 py-[2px] ${
167+
poolFull
168+
? "border-amber-300 bg-amber-50 text-amber-800"
169+
: "border-neutral-200 bg-off-white text-neutral-600"
170+
}`}
171+
>
172+
{capacityUnknown ? "slots —" : `${occupiedSlots}/${maxSlots} slots`}
142173
</span>
143-
{running.length} executing
144174
</span>
145175
}
146176
pad={0}
@@ -209,6 +239,26 @@ function NowRunningPanel({
209239
})}
210240
</div>
211241
)}
242+
{queued.length > 0 && (
243+
<div className="border-t border-amber-200 bg-amber-50 px-5 py-3">
244+
<div className="font-mono text-[10px] uppercase tracking-[0.06em] text-amber-800 mb-2">
245+
{queued.length} waiting for capacity
246+
</div>
247+
<div className="flex flex-col gap-1.5">
248+
{queued.map((q) => (
249+
<div
250+
key={q.ticketKey}
251+
className="flex items-center justify-between gap-2 font-body text-xs text-amber-900"
252+
>
253+
<span className="font-medium">{q.ticketKey}</span>
254+
<span className="font-mono text-[11px] text-amber-700 whitespace-nowrap">
255+
waiting {formatWaited(q.queuedAt)}
256+
</span>
257+
</div>
258+
))}
259+
</div>
260+
</div>
261+
)}
212262
</CkCard>
213263
);
214264
}
@@ -472,7 +522,7 @@ export function OverviewScreen({
472522

473523
{/* Live row */}
474524
<div className="grid grid-cols-2 gap-3">
475-
<NowRunningPanel rows={liveRows} onOpenRun={openRun} />
525+
<NowRunningPanel rows={liveRows} capacity={data.capacity} onOpenRun={openRun} />
476526
<AwaitingInputPanel rows={liveRows} onOpenRun={openRun} />
477527
</div>
478528

apps/dashboard/lib/api/fallbacks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
RunsResponse,
77
RunDetailResponse,
88
LiveRunsResponse,
9+
DispatchCapacityResponse,
910
WorkflowsResponse,
1011
TicketRunsResponse,
1112
WorkflowRunReplayResponse,
@@ -53,6 +54,10 @@ export function liveRunsFallback(now: string): LiveRunsResponse {
5354
return { generatedAt: now, rows: [] };
5455
}
5556

57+
export function dispatchCapacityFallback(now: string): DispatchCapacityResponse {
58+
return { generatedAt: now, occupiedSlots: 0, maxSlots: 0, queued: [] };
59+
}
60+
5661
export function workflowsFallback(now: string): WorkflowsResponse {
5762
return { generatedAt: now, rows: [], total: 0 };
5863
}

apps/dashboard/lib/waited.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { formatWaited } from "./waited";
4+
5+
const NOW = Date.parse("2026-08-16T12:00:00.000Z");
6+
7+
test("under a minute reads as such, never negative", () => {
8+
assert.equal(formatWaited("2026-08-16T11:59:30.000Z", NOW), "under a minute");
9+
// A queued_at fractionally in the future (clock skew) must not go negative.
10+
assert.equal(formatWaited("2026-08-16T12:00:05.000Z", NOW), "under a minute");
11+
});
12+
13+
test("minutes below an hour", () => {
14+
assert.equal(formatWaited("2026-08-16T11:55:00.000Z", NOW), "5m");
15+
assert.equal(formatWaited("2026-08-16T11:01:00.000Z", NOW), "59m");
16+
});
17+
18+
test("hours, with and without trailing minutes", () => {
19+
assert.equal(formatWaited("2026-08-16T10:00:00.000Z", NOW), "2h");
20+
assert.equal(formatWaited("2026-08-16T09:38:00.000Z", NOW), "2h 22m");
21+
});

apps/dashboard/lib/waited.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Compact "how long it has been waiting" label from an ISO timestamp. Computed
3+
* on the client from a first-seen instant, so `now` is injectable for tests.
4+
*/
5+
export function formatWaited(fromIso: string, now: number = Date.now()): string {
6+
const ms = now - new Date(fromIso).getTime();
7+
if (!Number.isFinite(ms) || ms < 60_000) return "under a minute";
8+
const totalMinutes = Math.floor(ms / 60_000);
9+
if (totalMinutes < 60) return `${totalMinutes}m`;
10+
const hours = Math.floor(totalMinutes / 60);
11+
const minutes = totalMinutes % 60;
12+
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
13+
}

apps/shared/contracts/api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,26 @@ export interface LiveRunsResponse {
107107
rows: Run[];
108108
}
109109

110+
/** One ticket waiting for a run slot because the pool is full (AIW-277). */
111+
export interface QueuedTicketEntry {
112+
ticketKey: string;
113+
/** ISO-8601 first-seen timestamp — how long it has been waiting. */
114+
queuedAt: string;
115+
}
116+
117+
/**
118+
* Dispatch-capacity snapshot for the Overview. `occupiedSlots` is counted the
119+
* way the refusal counts it (listCapacityConsumers — parked claims included),
120+
* so a full pool with zero executing runs no longer looks idle. `queued` lists
121+
* the tickets that were refused for capacity and are waiting.
122+
*/
123+
export interface DispatchCapacityResponse {
124+
generatedAt: string;
125+
occupiedSlots: number;
126+
maxSlots: number;
127+
queued: QueuedTicketEntry[];
128+
}
129+
110130
export interface RunsResponse {
111131
generatedAt: string;
112132
available: boolean;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CREATE TABLE "dispatch_capacity_queue" (
2+
"ticket_key" text PRIMARY KEY NOT NULL,
3+
"queued_at" timestamp with time zone DEFAULT now() NOT NULL,
4+
"attempted_at" timestamp with time zone,
5+
"confirmed_at" timestamp with time zone
6+
);

0 commit comments

Comments
 (0)