diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 13:18:25 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 14:41:47 +0900 |
| commit | 0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (patch) | |
| tree | b198da2719987ffd58e9c2633fc36684cf2ad316 /src/app | |
| parent | a59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff) | |
| download | dispatch-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.svelte | 4 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 80 | ||||
| -rw-r--r-- | src/app/store.test.ts | 112 |
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", () => { |
