Skip to content

Commit e7477b3

Browse files
committed
feat(watch): queue conditions — back below N, stalled, oldest-age SLA (TRI-12890)
- queue_depth_below: the depth reader inverted, threshold in the identity; the honesty fence generalizes — a stale reading at or under the quiet line is unavailable for every depth kind (fixes a latent hole where queue_depth_above could verify 'stayed below' off stale data) - queue_stalled: the first stateful kind — the check receives the previous check's frozen facts through a generic seam; an unavailable tick freezes the streak, never resets it; K=3 ticks internal default - queue_oldest_age: 'runs wait longer than N minutes' over the same live seam the queue page's Oldest wait uses; live-only — a stale age is unavailable in both directions - the queue card offers all five conditions with one contextual field; on a queue past the shared warning threshold the recommendation switches to the age SLA (single-source constant, the page aliases it) - recurrence handoff: canonical error URIs never carry the friendly prefix (full-URI citations are normalized like bare ids)
1 parent e4141a4 commit e7477b3

23 files changed

Lines changed: 1816 additions & 94 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
A queue watch can now wait for three more things under **Customize**: the queue coming back below a number you pick, the queue stopping moving at all, and runs waiting longer than a limit you set. On a queue where runs are already waiting too long, the **Watch…** button opens on that wait instead of on "until it clears".

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

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ import { AgentSpinner } from "~/components/primitives/Spinner";
3636
import { cn } from "~/utils/cn";
3737
import { ChatSystemBlock } from "./chat-layout";
3838
import {
39-
variantOf,
39+
variantsOf,
4040
watchDraftError,
41+
withAgeMinutes,
4142
withCadence,
4243
withFollowUp,
4344
withThreshold,
@@ -60,6 +61,9 @@ const VARIANT_LABEL: Record<WatchKind, string> = {
6061
run_failed: "if it fails",
6162
backlog_drain: "when it drains",
6263
queue_depth_above: "if it grows",
64+
queue_depth_below: "when it's back below",
65+
queue_stalled: "if it stops moving",
66+
queue_oldest_age: "if runs wait too long",
6367
error_recurrence: "if it recurs",
6468
health_recovery: "when it recovers",
6569
};
@@ -127,7 +131,7 @@ export function WatchCard({
127131
}) {
128132
const [expanded, setExpanded] = useState(defaultExpanded);
129133
const { spec } = draft;
130-
const variant = variantOf(draft);
134+
const variants = variantsOf(draft);
131135
// Local validation first: a draft the schema would refuse never reaches the
132136
// server, and the same sentence appears whether it was caught here or there.
133137
const localError = watchDraftError(draft);
@@ -180,28 +184,30 @@ export function WatchCard({
180184
<div className="flex flex-col gap-3 pt-2">
181185
{/* The condition variant (§3). Only rendered where a second question
182186
exists — the kinds with none must not show an empty picker. */}
183-
{variant ? (
187+
{variants.length > 1 ? (
184188
<Field label="Tell me">
185-
<Choice
186-
selected
187-
onSelect={() => {
188-
/* already the current condition */
189-
}}
190-
>
191-
{VARIANT_LABEL[spec.kind]}
192-
</Choice>
193-
<Choice selected={false} onSelect={() => onChange(withVariant(draft, variant))}>
194-
{VARIANT_LABEL[variant]}
195-
</Choice>
189+
{variants.map((kind) => (
190+
<Choice
191+
key={kind}
192+
selected={kind === spec.kind}
193+
onSelect={() => {
194+
if (kind !== spec.kind) onChange(withVariant(draft, kind));
195+
}}
196+
>
197+
{VARIANT_LABEL[kind]}
198+
</Choice>
199+
))}
196200
</Field>
197201
) : (
198202
<Field label="Tell me">
199203
<span className="text-xs text-text-dimmed">{watchConditionLabel(spec)}</span>
200204
</Field>
201205
)}
202206

203-
{spec.kind === "queue_depth_above" ? (
204-
<Field label="Above">
207+
{/* ONE contextual parameter per condition, and only for the conditions
208+
that have one. The stall count stays internal (§3). */}
209+
{spec.kind === "queue_depth_above" || spec.kind === "queue_depth_below" ? (
210+
<Field label={spec.kind === "queue_depth_above" ? "Above" : "Below"}>
205211
<Input
206212
type="number"
207213
min={0}
@@ -218,6 +224,23 @@ export function WatchCard({
218224
</Field>
219225
) : null}
220226

227+
{spec.kind === "queue_oldest_age" ? (
228+
<Field label="Waiting longer than">
229+
<Input
230+
type="number"
231+
min={1}
232+
variant="small"
233+
className="w-28"
234+
value={Number.isFinite(spec.thresholdMinutes) ? String(spec.thresholdMinutes) : ""}
235+
onChange={(event) =>
236+
onChange(withAgeMinutes(draft, Number.parseInt(event.target.value, 10)))
237+
}
238+
aria-label="Wait limit in minutes"
239+
/>
240+
<span className="text-xs text-text-dimmed">minutes</span>
241+
</Field>
242+
) : null}
243+
221244
<Field label="For">
222245
{WATCH_WINDOW_HOURS_OPTIONS.map((hours) => (
223246
<Choice

apps/webapp/app/components/dashboard-agent/wake-banner.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,123 @@ describe("wakePresentation", () => {
162162
);
163163
});
164164

165+
// The queue pack (TRI-12890). Every headline is fact first (§5.3), and the
166+
// numbers come from the frozen observation — never a fresh read.
167+
it("says a queue came back below its threshold, and when it never did", () => {
168+
const below = {
169+
id: "watch_below",
170+
kind: "queue_depth_below",
171+
identity: "queue_depth_below:email-sends:100",
172+
note: "",
173+
};
174+
expect(
175+
wakePresentation("fired", {
176+
...below,
177+
resolution: "condition_met",
178+
observedOutcome: { kind: "queue_depth_below", verified: true, depth: 42, threshold: 100 },
179+
})
180+
).toMatchObject({ headline: "email-sends queue is back below 100", category: "positive" });
181+
182+
expect(
183+
wakePresentation("expired", {
184+
...below,
185+
resolution: "window_completed",
186+
observedOutcome: { kind: "queue_depth_below", verified: true, depth: 780, threshold: 100 },
187+
})
188+
).toMatchObject({ headline: "email-sends queue is still above 100", category: "attention" });
189+
});
190+
191+
it("says a queue is stuck at the depth it stalled on, and that it kept moving", () => {
192+
const stalled = {
193+
id: "watch_stalled",
194+
kind: "queue_stalled",
195+
identity: "queue_stalled:email-sends",
196+
note: "",
197+
};
198+
expect(
199+
wakePresentation("fired", {
200+
...stalled,
201+
resolution: "condition_met",
202+
observedOutcome: {
203+
kind: "queue_stalled",
204+
verified: true,
205+
depth: 42,
206+
notDecreasingStreak: 3,
207+
ticks: 3,
208+
},
209+
})
210+
).toMatchObject({ headline: "email-sends queue is stuck at 42", category: "attention" });
211+
212+
expect(
213+
wakePresentation("expired", {
214+
...stalled,
215+
resolution: "window_completed",
216+
observedOutcome: {
217+
kind: "queue_stalled",
218+
verified: true,
219+
depth: 3,
220+
notDecreasingStreak: 1,
221+
ticks: 3,
222+
},
223+
})
224+
).toMatchObject({ headline: "email-sends queue kept moving", category: "positive" });
225+
});
226+
227+
it("states the wait and the limit it passed, in minutes", () => {
228+
const age = {
229+
id: "watch_age",
230+
kind: "queue_oldest_age",
231+
identity: "queue_oldest_age:email-sends:5",
232+
note: "",
233+
};
234+
expect(
235+
wakePresentation("fired", {
236+
...age,
237+
resolution: "condition_met",
238+
observedOutcome: {
239+
kind: "queue_oldest_age",
240+
verified: true,
241+
ageMs: 12 * 60_000,
242+
thresholdMinutes: 5,
243+
},
244+
})
245+
).toMatchObject({
246+
headline: "runs in email-sends are waiting 12m (over your 5m limit)",
247+
category: "attention",
248+
});
249+
250+
expect(
251+
wakePresentation("expired", {
252+
...age,
253+
resolution: "window_completed",
254+
observedOutcome: {
255+
kind: "queue_oldest_age",
256+
verified: true,
257+
ageMs: 30_000,
258+
thresholdMinutes: 5,
259+
},
260+
})
261+
).toMatchObject({ headline: "email-sends queue stayed under 5m", category: "positive" });
262+
});
263+
264+
it("names the queue, not the threshold, when a queue-pack watch's queue is gone", () => {
265+
for (const [kind, identity] of [
266+
["queue_depth_below", "queue_depth_below:email-sends:100"],
267+
["queue_stalled", "queue_stalled:email-sends"],
268+
["queue_oldest_age", "queue_oldest_age:email-sends:5"],
269+
] as const) {
270+
expect(
271+
wakePresentation("expired", {
272+
id: `watch_${kind}`,
273+
kind,
274+
identity,
275+
note: "",
276+
resolution: "condition_impossible",
277+
})
278+
).toMatchObject({ headline: "email-sends queue no longer exists", category: "neutral" });
279+
}
280+
});
281+
165282
it("recovers health without naming an identity", () => {
166283
expect(
167284
wakePresentation("fired", {

0 commit comments

Comments
 (0)