summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 13:18:25 +0900
committerAdam Malczewski <[email protected]>2026-06-28 14:41:47 +0900
commit0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (patch)
treeb198da2719987ffd58e9c2633fc36684cf2ad316 /src/features/concurrency/logic
parenta59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff)
downloaddispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.tar.gz
dispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.zip
feat(concurrency): configurable cooldown + adaptive-headroom auto-reduce banner
Consumes the backend's concurrency-fixes (commit 2d27666) — additive to [email protected], NO version bump. ConcurrencyStatusEntry gains cooldownMs / autoReduced / autoReducedFrom? / notice?; new ConcurrencyCooldownResponse / SetConcurrencyCooldownRequest + GET/PUT /concurrency/cooldown/:providerId. FE: - Pure core (logic/view-model.ts): DEFAULT_COOLDOWN_MS; parseCooldownInput (non-negative int — 0 is valid, unlike the limit); normalizeCooldown; cooldownLabel ("350ms"/"1.2s"/"0ms (off)"); viewConcurrencyStatus extended (cooldown + autoReduce fields; auto-reduce → warning badge, not busy); viewAutoReduce/autoReduceNotices (banner view — prefers backend notice, synthesizes a fallback); summarizeStatus "N auto-reduced" fragment; normalizeConcurrencyStatus coerces new fields (immutable readonly build; autoReducedFrom/notice only when autoReduced===true); normalizeConcurrencyCooldown. - Types (logic/types.ts): re-export the 2 new contract types + ConcurrencyCooldownResult + Get/SaveConcurrencyCooldown ports. - UI: ConcurrencyCooldownRow.svelte (inline-edit cooldown + Save → PUT, seeded via the ChatLimitField pattern); AutoReduceBanner.svelte (dismissible banner — backend notice + "Was N, now M." + "Restore to N"); ConcurrencyView renders the cooldown per status card + the banner section. The banner persists while autoReduced===true, is dismissible, and clears automatically once a poll shows autoReduced===false after a restore PUT. - Store (store.svelte.ts): getConcurrencyCooldown + setConcurrencyCooldown (surface 400/404/503 as ok:false; normalize at the seam) + interface decls. - App.svelte: saveConcurrencyCooldown adapter → ConcurrencyView. - Tests: +47 (view-model cooldown/auto-reduce/normalizers; component banner render, cooldown PUT, restore clears banner, dismiss; store cooldown GET/PUT). Re-synced the file: dep (bun install) + re-mirrored .dispatch/transport-contract. reference.md. typecheck 0/0, 1048 tests green (run twice), biome clean, build OK. Worktree env: an untracked dispatch-backend → backend symlink was created in the worktree parent so the canonical file:../dispatch-backend/... paths resolve (NOT committed — per the §2d/§2j worktree convention).
Diffstat (limited to 'src/features/concurrency/logic')
-rw-r--r--src/features/concurrency/logic/types.ts37
-rw-r--r--src/features/concurrency/logic/view-model.test.ts232
-rw-r--r--src/features/concurrency/logic/view-model.ts211
3 files changed, 460 insertions, 20 deletions
diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts
index f05211f..a0c5f6b 100644
--- a/src/features/concurrency/logic/types.ts
+++ b/src/features/concurrency/logic/types.ts
@@ -1,8 +1,10 @@
import type {
+ ConcurrencyCooldownResponse,
ConcurrencyLimitResponse,
ConcurrencyLimitsResponse,
ConcurrencyStatusEntry,
ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
SetConcurrencyLimitRequest,
} from "@dispatch/transport-contract";
@@ -23,14 +25,23 @@ import type {
* are imported directly (mirrors `mcp` / `computer`). The result types + injected
* ports below are FE-owned (the composition root adapts the store's HTTP calls to
* them). The endpoints are GLOBAL (not workspace- or conversation-scoped).
+ *
+ * Concurrency-fixes (additive, no version bump): each `ConcurrencyStatusEntry`
+ * now also carries `cooldownMs` (per-slot release cooldown, configurable +
+ * persisted), `autoReduced` (a 429 auto-reduced the limit by 1, one-way), and
+ * when auto-reduced, `autoReducedFrom` + a `notice` banner string. A manual
+ * `PUT /concurrency/limits/:providerId` clears `autoReduced`. Two new endpoints
+ * `GET`/`PUT /concurrency/cooldown/:providerId` view/change the cooldown.
*/
/** Re-export the contract shapes so consumers import a single surface. */
export type {
+ ConcurrencyCooldownResponse,
ConcurrencyLimitResponse,
ConcurrencyLimitsResponse,
ConcurrencyStatusEntry,
ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
SetConcurrencyLimitRequest,
};
@@ -69,6 +80,25 @@ export type ConcurrencyStatusResult =
| { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] }
| { readonly ok: false; readonly error: string };
+/**
+ * Outcome of `GET`/`PUT /concurrency/cooldown/:providerId` — the per-slot
+ * release cooldown (ms) for one provider. `GET` returns `404` when the provider
+ * has no concurrency config at all (no limit, no cooldown); `PUT` returns `400`
+ * for a non-negative-integer body. Both return `503` when the extension isn't
+ * loaded.
+ */
+export type ConcurrencyCooldownResult =
+ | { readonly ok: true; readonly providerId: string; readonly cooldownMs: number }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of an auto-reduce banner's "Restore to N" action (PUT the limit back
+ * to `autoReducedFrom` via `PUT /concurrency/limits/:providerId`). Carried back to
+ * the banner so a FAILED restore surfaces an inline error next to the button
+ * (instead of silently re-enabling the button / showing the error far away).
+ */
+export type RestoreOutcome = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
// ── Injected ports (consumer-defines-port; the composition root adapts the
// store's HTTP calls to these shapes). ──────────────────────────────────────
@@ -80,3 +110,10 @@ export type SaveConcurrencyLimit = (
) => Promise<ConcurrencyLimitResult>;
export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>;
export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>;
+/** `GET /concurrency/cooldown/:providerId` — read the per-slot release cooldown. */
+export type GetConcurrencyCooldown = (providerId: string) => Promise<ConcurrencyCooldownResult>;
+/** `PUT /concurrency/cooldown/:providerId` — set the per-slot release cooldown (non-negative int). */
+export type SaveConcurrencyCooldown = (
+ providerId: string,
+ cooldownMs: number,
+) => Promise<ConcurrencyCooldownResult>;
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
index 82b72b0..c284ad0 100644
--- a/src/features/concurrency/logic/view-model.test.ts
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -1,16 +1,22 @@
import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import {
+ autoReduceNotices,
+ cooldownLabel,
+ DEFAULT_COOLDOWN_MS,
formatPauseDuration,
+ normalizeConcurrencyCooldown,
normalizeConcurrencyLimit,
normalizeConcurrencyLimits,
normalizeConcurrencyStatus,
+ parseCooldownInput,
parseLimitInput,
pauseLabel,
providerFromModel,
providerOptions,
summarizeLimits,
summarizeStatus,
+ viewAutoReduce,
viewConcurrencyLimit,
viewConcurrencyLimits,
viewConcurrencyStatus,
@@ -23,6 +29,8 @@ const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEn
inFlight: 2,
queued: 0,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
...over,
});
@@ -46,6 +54,41 @@ describe("parseLimitInput", () => {
});
});
+// ── parseCooldownInput (non-negative integer — 0 is valid, unlike the limit) ──
+
+describe("parseCooldownInput", () => {
+ it("accepts zero + positive integers", () => {
+ expect(parseCooldownInput("0")).toBe(0);
+ expect(parseCooldownInput("350")).toBe(350);
+ expect(parseCooldownInput(" 100 ")).toBe(100);
+ });
+
+ it("rejects negatives, non-integers, and garbage", () => {
+ expect(parseCooldownInput("-1")).toBeNull();
+ expect(parseCooldownInput("4.5")).toBeNull();
+ expect(parseCooldownInput("")).toBeNull();
+ expect(parseCooldownInput("abc")).toBeNull();
+ expect(parseCooldownInput("100ms")).toBeNull();
+ });
+});
+
+// ── cooldownLabel ─────────────────────────────────────────────────────────────
+
+describe("cooldownLabel", () => {
+ it("0 → off label", () => {
+ expect(cooldownLabel(0)).toBe("0ms (off)");
+ });
+ it("sub-second → ms", () => {
+ expect(cooldownLabel(350)).toBe("350ms");
+ expect(cooldownLabel(999)).toBe("999ms");
+ });
+ it("≥1s → seconds (trims trailing .0)", () => {
+ expect(cooldownLabel(1000)).toBe("1s");
+ expect(cooldownLabel(1500)).toBe("1.5s");
+ expect(cooldownLabel(60_000)).toBe("60s");
+ });
+});
+
// ── providerFromModel / providerOptions ───────────────────────────────────────
describe("providerFromModel", () => {
@@ -178,6 +221,8 @@ describe("viewConcurrencyStatus", () => {
inFlight: Number.NaN,
queued: "oops" as unknown as number,
paused: false,
+ cooldownMs: Number.NaN,
+ autoReduced: false,
},
0,
);
@@ -185,6 +230,7 @@ describe("viewConcurrencyStatus", () => {
expect(v.inFlight).toBe(0);
expect(v.queued).toBe(0);
expect(v.inFlightLabel).toBe("0/1");
+ expect(v.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
});
it("viewConcurrencyStatuses maps a list preserving order", () => {
@@ -194,6 +240,81 @@ describe("viewConcurrencyStatus", () => {
);
expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
});
+
+ it("carries cooldownMs + label + autoReduced fields onto the view", () => {
+ const v = viewConcurrencyStatus(status({ cooldownMs: 1500 }), 0);
+ expect(v.cooldownMs).toBe(1500);
+ expect(v.cooldownLabel).toBe("1.5s");
+ expect(v.autoReduced).toBe(false);
+ expect(v.autoReducedFrom).toBeNull();
+ });
+
+ it("auto-reduced → warning badge (not busy) + autoReducedFrom carried", () => {
+ const v = viewConcurrencyStatus(
+ status({ limit: 3, autoReduced: true, autoReducedFrom: 4, inFlight: 0 }),
+ 0,
+ );
+ expect(v.autoReduced).toBe(true);
+ expect(v.autoReducedFrom).toBe(4);
+ expect(v.badge).toBe("warning");
+ // autoReduced alone does NOT flip busy (a reduced limit still admits agents).
+ expect(v.busy).toBe(false);
+ });
+});
+
+// ── viewAutoReduce / autoReduceNotices (the auto-reduce banner view) ───────────
+
+describe("viewAutoReduce", () => {
+ it("returns null when not auto-reduced", () => {
+ expect(viewAutoReduce(status({ autoReduced: false }))).toBeNull();
+ });
+
+ it("uses the backend notice verbatim + carries from/current limits", () => {
+ const notice = viewAutoReduce(
+ status({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ );
+ expect(notice).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ });
+
+ it("synthesizes a fallback notice when the backend notice is absent/empty", () => {
+ expect(viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4 }))).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ expect(
+ viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4, notice: "" })),
+ ).not.toBeNull();
+ });
+
+ it("falls back to currentLimit+1 when autoReducedFrom is missing/garbage", () => {
+ const notice = viewAutoReduce(status({ limit: 3, autoReduced: true }));
+ expect(notice?.fromLimit).toBe(4); // 3 + 1
+ });
+});
+
+describe("autoReduceNotices", () => {
+ it("collects one banner per auto-reduced provider (input order), empty when none", () => {
+ expect(autoReduceNotices([status({ providerId: "a" })])).toEqual([]);
+ const out = autoReduceNotices([
+ status({ providerId: "a", autoReduced: true, autoReducedFrom: 4, limit: 3 }),
+ status({ providerId: "b" }),
+ status({ providerId: "c", autoReduced: true, autoReducedFrom: 2, limit: 1 }),
+ ]);
+ expect(out.map((n) => n.providerId)).toEqual(["a", "c"]);
+ expect(out[1]?.fromLimit).toBe(2);
+ });
});
// ── viewConcurrencyLimit ───────────────────────────────────────────────────────
@@ -273,6 +394,16 @@ describe("summarizeStatus", () => {
"1 provider · 1/4 in flight",
);
});
+ it("includes an auto-reduced fragment only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 3, inFlight: 1, autoReduced: true, autoReducedFrom: 4 }),
+ status({ providerId: "b", limit: 4, inFlight: 1 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 2/7 in flight · 1 auto-reduced");
+ });
});
// ── Network-seam normalizers ───────────────────────────────────────────────────
@@ -359,6 +490,8 @@ describe("normalizeConcurrencyStatus", () => {
inFlight: 2,
queued: 1,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
});
expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
expect(second).toEqual({
@@ -368,6 +501,8 @@ describe("normalizeConcurrencyStatus", () => {
queued: 3,
paused: true,
pausedUntil: now,
+ cooldownMs: 350,
+ autoReduced: false,
});
});
@@ -388,8 +523,24 @@ describe("normalizeConcurrencyStatus", () => {
],
});
expect(providers).toEqual([
- { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
- { providerId: "x", limit: 1, inFlight: 0, queued: 0, paused: false },
+ {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
+ {
+ providerId: "x",
+ limit: 1,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
]);
});
@@ -402,4 +553,81 @@ describe("normalizeConcurrencyStatus", () => {
});
for (const p of providers) expect("pausedUntil" in p).toBe(false);
});
+
+ it("coerces cooldownMs (default 350) + carries auto-reduce fields only when true", () => {
+ const [reduced, healthy] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "umans",
+ limit: 3,
+ inFlight: 1,
+ queued: 0,
+ paused: false,
+ cooldownMs: 500,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "auto-reduced to 3 after a 429.",
+ },
+ { providerId: "openai", limit: 4, inFlight: 0, queued: 0, paused: false },
+ ],
+ });
+ expect(reduced?.cooldownMs).toBe(500);
+ expect(reduced?.autoReduced).toBe(true);
+ expect(reduced?.autoReducedFrom).toBe(4);
+ expect(reduced?.notice).toBe("auto-reduced to 3 after a 429.");
+ // Healthy entry: cooldownMs defaults to 350 when absent; auto-reduce fields
+ // are NOT present (they are only included when autoReduced===true).
+ expect(healthy?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(healthy?.autoReduced).toBe(false);
+ expect(healthy && "autoReducedFrom" in healthy).toBe(false);
+ expect(healthy && "notice" in healthy).toBe(false);
+ });
+
+ it("drops autoReducedFrom/notice when autoReduced is false (even if present in JSON)", () => {
+ const [p] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "x",
+ limit: 4,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ autoReduced: false,
+ autoReducedFrom: 9,
+ notice: "stale",
+ },
+ ],
+ });
+ expect(p?.autoReduced).toBe(false);
+ expect(p && "autoReducedFrom" in p).toBe(false);
+ expect(p && "notice" in p).toBe(false);
+ });
+});
+
+// ── normalizeConcurrencyCooldown ───────────────────────────────────────────────
+
+describe("normalizeConcurrencyCooldown", () => {
+ it("coerces a well-formed body", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "umans", cooldownMs: 500 })).toEqual({
+ providerId: "umans",
+ cooldownMs: 500,
+ });
+ });
+
+ it("defaults a malformed/absent cooldownMs to 350", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: -1 })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ expect(normalizeConcurrencyCooldown({ providerId: "x" })?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: "fast" })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ });
+
+ it("returns null for a missing/malformed providerId", () => {
+ expect(normalizeConcurrencyCooldown({ cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown({ providerId: "", cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown(null)).toBeNull();
+ expect(normalizeConcurrencyCooldown({})).toBeNull();
+ });
});
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
index 7a4fe0e..e05423b 100644
--- a/src/features/concurrency/logic/view-model.ts
+++ b/src/features/concurrency/logic/view-model.ts
@@ -1,12 +1,16 @@
-import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import type {
+ ConcurrencyCooldownResponse,
+ ConcurrencyStatusEntry,
+} from "@dispatch/transport-contract";
import type { ConcurrencyLimitEntry } from "./types";
/**
* Pure view-models for the concurrency feature — zero DOM, zero effects, zero
* Svelte. Maps backend `ConcurrencyLimitEntry` / `ConcurrencyStatusEntry` to
- * display shapes (badges, "2/4" in-flight labels, pause countdowns, summaries),
- * holds the limit-input parsing, and the network-seam normalizers the composition
- * root coerces the untyped JSON with.
+ * display shapes (badges, "2/4" in-flight labels, pause countdowns, cooldown
+ * labels, auto-reduce banners, summaries), holds the limit/cooldown-input
+ * parsing, and the network-seam normalizers the composition root coerces the
+ * untyped JSON with.
*/
export type Badge = "success" | "warning" | "error" | "neutral";
@@ -36,6 +40,29 @@ export interface ConcurrencyStatusView {
readonly badge: Badge;
/** True when paused or at capacity (show a spinner). */
readonly busy: boolean;
+ /** Per-slot release cooldown in ms (defensive default 350 on garbage). */
+ readonly cooldownMs: number;
+ /** "350ms" / "1.2s" / "0ms (off)" — display label for the cooldown. */
+ readonly cooldownLabel: string;
+ /** Whether the limit was auto-reduced by a 429 (one-way; user restores manually). */
+ readonly autoReduced: boolean;
+ /** The original limit before auto-reduction; null when not auto-reduced. */
+ readonly autoReducedFrom: number | null;
+}
+
+/**
+ * A view-model for the auto-reduce banner — derived from a status entry whose
+ * `autoReduced` is `true`. `message` is the backend's `notice` when present, else
+ * a synthesized fallback. `viewAutoReduce` returns this (or null) so the banner
+ * section renders without reaching into the raw entry.
+ */
+export interface AutoReduceNotice {
+ readonly providerId: string;
+ readonly message: string;
+ /** The original limit before reduction — the value "Restore to N" PUTs. */
+ readonly fromLimit: number;
+ /** The current (reduced) limit. */
+ readonly currentLimit: number;
}
// ── Limit input parsing ───────────────────────────────────────────────────────
@@ -63,6 +90,53 @@ export function normalizeLimit(value: unknown): number {
return int >= 1 ? int : 1;
}
+// ── Cooldown input parsing ────────────────────────────────────────────────────
+//
+// The per-slot release cooldown (ms) is a NON-NEGATIVE integer (0 = no cooldown,
+// instant re-admission) — unlike the limit, 0 is a VALID value. The default is
+// 350ms (the backend's server default when a limit is set but no explicit
+// cooldown was configured). It is configurable + persisted per provider via
+// `PUT /concurrency/cooldown/:providerId`.
+
+/** The server's default cooldown (ms) — used when none is explicitly set. */
+export const DEFAULT_COOLDOWN_MS = 350;
+
+/**
+ * Parse a raw cooldown input into a non-negative integer, or `null` when it is
+ * not valid. Accepts "0" → 0, "350" → 350; rejects "-1", "4.5", "", "abc".
+ * Drives the cooldown Save button's disabled state so an invalid value never
+ * reaches the backend (the backend 400s a non-negative-integer body).
+ */
+export function parseCooldownInput(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) && n >= 0 ? n : null;
+}
+
+/**
+ * Coerce an untrusted cooldown value into a non-negative integer (default
+ * {@link DEFAULT_COOLDOWN_MS}). Used when normalizing backend responses so a
+ * malformed `cooldownMs` can never be negative/non-finite.
+ */
+export function normalizeCooldown(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_COOLDOWN_MS;
+ const int = Math.floor(n);
+ return int >= 0 ? int : DEFAULT_COOLDOWN_MS;
+}
+
+/**
+ * Format a cooldown (ms) as a short display label:
+ * 0 → "0ms (off)" · <1000 → "350ms" · ≥1000 → "1.2s" (trailing ".0" trimmed).
+ */
+export function cooldownLabel(ms: number): string {
+ if (ms <= 0) return "0ms (off)";
+ if (ms < 1000) return `${ms}ms`;
+ const secs = ms / 1000;
+ const fixed = secs.toFixed(1);
+ return `${fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed}s`;
+}
+
// ── Provider options (the Add-form dropdown) ───────────────────────────────────
//
// A concurrency `providerId` is the credential name that prefixes a model name
@@ -140,6 +214,10 @@ export function pauseLabel(
* Build a display view for a status entry. `now` is injectable for tests
* (defaults to `Date.now()`); the composition-root component passes nothing in
* production (it recomputes on each poll).
+ *
+ * `autoReduced` does NOT flip `busy` (a reduced limit still admits agents; it is
+ * a degraded-but-active state surfaced via the banner, not a spinner) — it only
+ * nudges the badge to `warning` so the row signals attention.
*/
export function viewConcurrencyStatus(
entry: ConcurrencyStatusEntry,
@@ -149,12 +227,21 @@ export function viewConcurrencyStatus(
const inFlight = clampCount(entry.inFlight);
const queued = clampCount(entry.queued);
const paused = entry.paused === true;
+ const autoReduced = entry.autoReduced === true;
const atCapacity = inFlight >= limit;
let badge: Badge;
if (paused) badge = "warning";
+ else if (autoReduced) badge = "warning";
else if (atCapacity && queued > 0) badge = "warning";
else if (inFlight > 0) badge = "success";
else badge = "neutral";
+ const cooldownMs = normalizeCooldown(entry.cooldownMs);
+ const autoReducedFrom =
+ autoReduced &&
+ typeof entry.autoReducedFrom === "number" &&
+ Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : null;
return {
providerId: entry.providerId,
limit,
@@ -166,9 +253,53 @@ export function viewConcurrencyStatus(
pausedLabel: pauseLabel(paused, entry.pausedUntil, now),
badge,
busy: paused || (atCapacity && queued > 0),
+ cooldownMs,
+ cooldownLabel: cooldownLabel(cooldownMs),
+ autoReduced,
+ autoReducedFrom,
};
}
+/**
+ * The auto-reduce banner view for a status entry, or `null` when it is not
+ * auto-reduced. `message` prefers the backend's `notice` (verbatim, when present
+ * + non-empty); otherwise a synthesized fallback is built from
+ * `autoReducedFrom` → `limit`. `fromLimit` is the value "Restore to N" PUTs back.
+ */
+export function viewAutoReduce(entry: ConcurrencyStatusEntry): AutoReduceNotice | null {
+ if (entry.autoReduced !== true) return null;
+ const currentLimit = normalizeLimit(entry.limit);
+ const fromLimit =
+ typeof entry.autoReducedFrom === "number" && Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : currentLimit + 1;
+ const notice =
+ typeof entry.notice === "string" && entry.notice.length > 0
+ ? entry.notice
+ : `Concurrency limit auto-reduced to ${currentLimit} after a 429 — restore manually when ready.`;
+ return {
+ providerId: entry.providerId,
+ message: notice,
+ fromLimit,
+ currentLimit,
+ };
+}
+
+/**
+ * All auto-reduce banners across a status list (one per auto-reduced provider),
+ * in input order. Empty when none are auto-reduced.
+ */
+export function autoReduceNotices(
+ entries: readonly ConcurrencyStatusEntry[],
+): readonly AutoReduceNotice[] {
+ const out: AutoReduceNotice[] = [];
+ for (const e of entries) {
+ const n = viewAutoReduce(e);
+ if (n !== null) out.push(n);
+ }
+ return out;
+}
+
export function viewConcurrencyStatuses(
entries: readonly ConcurrencyStatusEntry[],
now: number = Date.now(),
@@ -197,8 +328,8 @@ export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): strin
/**
* A one-line summary of the live status, e.g.
- * "2 providers · 6/10 in flight · 1 queued · 1 paused". Only the queued / paused
- * fragments appear when non-zero.
+ * "2 providers · 6/10 in flight · 1 queued · 1 paused · 1 auto-reduced". Only the
+ * queued / paused / auto-reduced fragments appear when non-zero.
*/
export function summarizeStatus(
providers: readonly ConcurrencyStatusEntry[],
@@ -209,12 +340,14 @@ export function summarizeStatus(
let limitTotal = 0;
let queued = 0;
let paused = 0;
+ let autoReduced = 0;
for (const p of providers) {
const limit = normalizeLimit(p.limit);
inFlight += clampCount(p.inFlight);
limitTotal += limit;
queued += clampCount(p.queued);
if (p.paused === true) paused += 1;
+ if (p.autoReduced === true) autoReduced += 1;
}
const parts: string[] = [];
parts.push(
@@ -223,6 +356,7 @@ export function summarizeStatus(
);
if (queued > 0) parts.push(`${queued} queued`);
if (paused > 0) parts.push(`${paused} paused`);
+ if (autoReduced > 0) parts.push(`${autoReduced} auto-reduced`);
// Touch `now` so the summary recomputes alongside the per-row pause countdown.
void now;
return parts.join(" · ");
@@ -274,7 +408,9 @@ export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry
/**
* Coerce an untrusted `GET /concurrency/status` body into a typed status list.
* `pausedUntil` is included only when it is a finite number (it is absent when
- * not paused).
+ * not paused). `cooldownMs` (default 350) + `autoReduced` are always coerced;
+ * `autoReducedFrom` + `notice` are included only when `autoReduced` is true (and
+ * well-formed), mirroring the backend's "present only when auto-reduced" contract.
*/
export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] {
if (!isRecord(data) || !Array.isArray(data.providers)) return [];
@@ -282,18 +418,57 @@ export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyS
return providers
.filter((r): r is Record<string, unknown> => isRecord(r))
.map((r): ConcurrencyStatusEntry => {
- const base = {
- providerId: asString(r.providerId) ?? "",
- limit: normalizeLimit(r.limit),
- inFlight: clampCount(r.inFlight),
- queued: clampCount(r.queued),
- paused: r.paused === true,
+ const providerId = asString(r.providerId) ?? "";
+ const limit = normalizeLimit(r.limit);
+ const inFlight = clampCount(r.inFlight);
+ const queued = clampCount(r.queued);
+ const paused = r.paused === true;
+ const cooldownMs = normalizeCooldown(r.cooldownMs);
+ const autoReduced = r.autoReduced === true;
+ // Build immutably (the contract fields are readonly): start with the always-
+ // present fields, then layer the optional `pausedUntil` (finite number only)
+ // + the auto-reduce-only `autoReducedFrom`/`notice` (present only when true).
+ let entry: ConcurrencyStatusEntry = {
+ providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ cooldownMs,
+ autoReduced,
};
- const pausedUntil =
- typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)
- ? r.pausedUntil
- : undefined;
- return pausedUntil !== undefined ? { ...base, pausedUntil } : base;
+ if (typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)) {
+ entry = { ...entry, pausedUntil: r.pausedUntil };
+ }
+ if (autoReduced) {
+ // Accumulate the auto-reduce-only optionals into a plain record (the
+ // contract fields are readonly, so we can't mutate a typed partial —
+ // collect then spread into a fresh entry).
+ const patch: { autoReducedFrom?: number; notice?: string } = {};
+ if (typeof r.autoReducedFrom === "number" && Number.isFinite(r.autoReducedFrom)) {
+ patch.autoReducedFrom = normalizeLimit(r.autoReducedFrom);
+ }
+ if (typeof r.notice === "string" && r.notice.length > 0) {
+ patch.notice = r.notice;
+ }
+ if (patch.autoReducedFrom !== undefined || patch.notice !== undefined) {
+ entry = { ...entry, ...patch };
+ }
+ }
+ return entry;
})
.filter((r) => r.providerId !== "");
}
+
+/**
+ * Coerce an untrusted `GET`/`PUT /concurrency/cooldown/:providerId` body into a
+ * typed cooldown response, or `null` when it is malformed (missing/malformed
+ * `providerId` or `cooldownMs`). The composition root surfaces a 404/400/503 as
+ * `ok: false` separately; this only defends the success body.
+ */
+export function normalizeConcurrencyCooldown(data: unknown): ConcurrencyCooldownResponse | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, cooldownMs: normalizeCooldown(data.cooldownMs) };
+}