summaryrefslogtreecommitdiffhomepage
path: root/src/app
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/app
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/app')
-rw-r--r--src/app/App.svelte4
-rw-r--r--src/app/store.svelte.ts80
-rw-r--r--src/app/store.test.ts112
3 files changed, 195 insertions, 1 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 59f949c..3e7d05a 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -76,6 +76,7 @@
type DeleteConcurrencyLimit,
type LoadConcurrencyLimits,
type LoadConcurrencyStatus,
+ type SaveConcurrencyCooldown,
type SaveConcurrencyLimit,
} from "../features/concurrency";
import type { ChatStore } from "../features/chat";
@@ -491,6 +492,8 @@
const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
store.deleteConcurrencyLimit(providerId);
const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
+ const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) =>
+ store.setConcurrencyCooldown(providerId, cooldownMs);
</script>
<main class="relative flex h-screen overflow-hidden">
@@ -841,6 +844,7 @@
saveLimit={saveConcurrencyLimit}
deleteLimit={deleteConcurrencyLimit}
loadStatus={loadConcurrencyStatus}
+ saveCooldown={saveConcurrencyCooldown}
/>
{/if}
{/snippet}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index ead27a6..d725dc0 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -55,12 +55,14 @@ import {
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
import type {
+ ConcurrencyCooldownResult,
ConcurrencyDeleteResult,
ConcurrencyLimitResult,
ConcurrencyLimitsResult,
ConcurrencyStatusResult,
} from "../features/concurrency";
import {
+ normalizeConcurrencyCooldown,
normalizeConcurrencyLimit,
normalizeConcurrencyLimits,
normalizeConcurrencyStatus,
@@ -442,11 +444,29 @@ export interface AppStore {
/**
* Fetch live concurrency status for every provider with a configured limit
* (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
- * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Returns
+ * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each
+ * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag
+ * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns
* an empty list when the extension isn't loaded (`{ providers: [] }`).
*/
concurrencyStatus(): Promise<ConcurrencyStatusResult>;
/**
+ * Fetch the per-slot release cooldown (ms) for one provider
+ * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
+ * all) and `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>;
+ /**
+ * Set the per-slot release cooldown (ms) for one provider
+ * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs`
+ * must be a non-negative integer (0 = no cooldown / instant re-admission); an
+ * invalid body is `400`. Persists + applies to subsequently recycled slots.
+ */
+ setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult>;
+ /**
* A critical error that blocks normal operation (e.g. the cross-device tab
* restore fetch failed). When non-null, a full-screen modal is shown with the
* error details. Cleared by `clearFatalError` (the modal's dismiss button).
@@ -1958,6 +1978,64 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Get concurrency cooldown request failed",
+ };
+ }
+ },
+
+ async setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ cooldownMs }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency cooldown request failed",
+ };
+ }
+ },
+
async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
try {
const res = await fetchImpl(`${httpBase}/system-prompt`);
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 2cf473c..a2f7f2f 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1458,6 +1458,20 @@ describe("createAppStore", () => {
if (url.includes("/concurrency/limits/") && method === "DELETE") {
return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 });
}
+ if (url.includes("/concurrency/cooldown/") && method === "GET") {
+ return new Response(JSON.stringify({ providerId: "umans", cooldownMs: 350 }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/concurrency/cooldown/") && method === "PUT") {
+ const seg = url.slice(
+ url.lastIndexOf("/concurrency/cooldown/") + "/concurrency/cooldown/".length,
+ );
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ providerId: seg, cooldownMs: body.cooldownMs ?? 0 }), {
+ status: 200,
+ });
+ }
return base(input, init);
};
}
@@ -1641,11 +1655,15 @@ describe("createAppStore", () => {
inFlight: 2,
queued: 1,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
});
expect(result.providers[1]).toMatchObject({
providerId: "openai-compat",
paused: true,
pausedUntil: 1_719_408_000_000,
+ cooldownMs: 350,
+ autoReduced: false,
});
store.dispose();
});
@@ -1663,6 +1681,100 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("getConcurrencyCooldown loads + coerces the cooldown", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyCooldown("umans");
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ expect(result.cooldownMs).toBe(350);
+ store.dispose();
+ });
+
+ it("getConcurrencyCooldown surfaces a 404 (no concurrency config) as ok:false", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/cooldown/")) {
+ return new Response(
+ JSON.stringify({ error: "No concurrency configuration for this provider" }),
+ {
+ status: 404,
+ },
+ );
+ }
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyCooldown("ghost");
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("No concurrency configuration");
+ store.dispose();
+ });
+
+ it("setConcurrencyCooldown PUTs { cooldownMs } + returns the echoed value", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ calls.push({ url, method, body: init?.body ? JSON.parse(init.body as string) : null });
+ if (url.includes("/concurrency/cooldown/") && method === "PUT") {
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(
+ JSON.stringify({ providerId: "umans", cooldownMs: body.cooldownMs }),
+ { status: 200 },
+ );
+ }
+ return fakeFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyCooldown("umans", 500);
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.cooldownMs).toBe(500);
+ const cooldownCall = calls.find(
+ (c) => c.url.includes("/concurrency/cooldown/") && c.method === "PUT",
+ );
+ expect(cooldownCall?.url).toContain("/concurrency/cooldown/umans");
+ expect(cooldownCall?.body).toEqual({ cooldownMs: 500 });
+ store.dispose();
+ });
+
+ it("setConcurrencyCooldown surfaces a 400 (invalid body) as ok:false", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/cooldown/") && init?.method === "PUT") {
+ return new Response(
+ JSON.stringify({ error: "Body must be { cooldownMs: <non-negative integer> }" }),
+ { status: 400 },
+ );
+ }
+ return fakeFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyCooldown("umans", -1);
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("non-negative integer");
+ store.dispose();
+ });
+
// ── Conversation status: `queued` (CR-13 — waiting for a concurrency slot) ────
it("conversation.statusChanged 'queued' sets the status (tab spinner) without opening a duplicate tab", () => {