import { conversationStoreHandle } from "@dispatch/conversation-store"; import type { Extension, HostAPI, Logger, Manifest, StorageNamespace } from "@dispatch/kernel"; import type { ConcurrencyManagerOpts, ConcurrencyService } from "./concurrency-manager.js"; import { createConcurrencyManager } from "./concurrency-manager.js"; import { concurrencyServiceHandle } from "./service.js"; export const manifest: Manifest = { id: "provider-concurrency", name: "Provider Concurrency Limits", version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", activation: "eager", capabilities: { db: true }, dependsOn: ["conversation-store"], contributes: { services: ["provider-concurrency/service"] }, }; /** * Default tuning constants. * * - `SLOT_TIMEOUT_MS` (5 min): a slot held longer than this is force-reclaimed * by the watchdog (deadlock / stuck-agent recovery). Generation streams * rarely exceed 2–3 minutes; 5 min is a generous safety margin. * - `WATCHDOG_INTERVAL_MS` (30s): how often the watchdog sweeps for stale slots. * - `DEFAULT_PAUSE_MS` (30s): default 429 backoff when no Retry-After is given. * Umans docs note each concurrency 429 deprioritizes the account for ~30 min, * but a 30s queue pause prevents immediate re-overshoot while still allowing * recovery. Combined with adaptive headroom (limit reduced by 1) + the usage * gate, the resumed queue no longer re-overshoots — so the pause is kept * (gives upstream a breather) rather than dropped. * - `RELEASE_COOLDOWN_MS` (350ms): after a slot is released, hold it for this * duration before recycling it to the next waiter. Covers the upstream * provider's accounting lag — the provider's concurrent_sessions counter may * not decrement the instant our stream completes, so re-admitting immediately * risks an N+1 overshoot that triggers a 429. Raised from 200ms to 350ms * (Umans's accounting lag exceeded the 200ms cooldown, causing overshoot at 4 * connections). Configurable + persisted per provider (PUT * /concurrency/cooldown/:providerId). */ const SLOT_TIMEOUT_MS = 5 * 60 * 1000; const WATCHDOG_INTERVAL_MS = 30 * 1000; const DEFAULT_PAUSE_MS = 30 * 1000; const RELEASE_COOLDOWN_MS = 350; /** * Storage key prefixes. Limits are stored under the bare `` key * (unchanged for backward compatibility). Cooldowns + the adaptive-headroom * auto-reduce marker are stored under their own prefixed keys so they persist * independently without loadLimits misreading them as limits. */ const COOLDOWN_KEY_PREFIX = "cooldown:"; const AUTOREDUCE_KEY_PREFIX = "auto-reduce:"; /** * Wrap a `ConcurrencyService` so `setLimit`/`removeLimit`/`setCooldown` persist * to the given `StorageNamespace`. All other methods delegate directly to the * inner service. Persistence is fire-and-forget — a storage write failure logs * a warning but does NOT fail the API call (the in-memory value is already set). * * `restoreLimit` is NOT persisted here — it is a startup restore FROM disk, so * it delegates straight through (the value is already on disk). */ function createPersistedService( inner: ConcurrencyService, storage: StorageNamespace, logger: Logger, ): ConcurrencyService { return { acquire: inner.acquire.bind(inner), reportRateLimit: inner.reportRateLimit.bind(inner), setLimit(providerId, limit) { inner.setLimit(providerId, limit); storage.set(providerId, String(limit)).catch((err) => logger.warn("provider-concurrency: failed to persist limit", { providerId, err: err instanceof Error ? err.message : String(err), }), ); // A MANUAL limit set clears the auto-reduce notice (the user took // control) → drop the persisted auto-reduce marker too. storage.delete(`${AUTOREDUCE_KEY_PREFIX}${providerId}`).catch(() => { /* absent marker is fine */ }); }, restoreLimit: inner.restoreLimit.bind(inner), removeLimit(providerId) { inner.removeLimit(providerId); storage.delete(providerId).catch((err) => logger.warn("provider-concurrency: failed to delete persisted limit", { providerId, err: err instanceof Error ? err.message : String(err), }), ); storage.delete(`${AUTOREDUCE_KEY_PREFIX}${providerId}`).catch(() => { /* absent marker is fine */ }); }, setCooldown(providerId, cooldownMs) { inner.setCooldown(providerId, cooldownMs); storage.set(`${COOLDOWN_KEY_PREFIX}${providerId}`, String(cooldownMs)).catch((err) => logger.warn("provider-concurrency: failed to persist cooldown", { providerId, err: err instanceof Error ? err.message : String(err), }), ); }, getLimit: inner.getLimit.bind(inner), getLimits: inner.getLimits.bind(inner), getCooldown: inner.getCooldown.bind(inner), getCooldowns: inner.getCooldowns.bind(inner), getStatus: inner.getStatus.bind(inner), getStatusAll: inner.getStatusAll.bind(inner), notifyWorkspaceStarred: inner.notifyWorkspaceStarred.bind(inner), destroy: inner.destroy.bind(inner), }; } /** * Load saved limits from storage and apply them to the manager via * `restoreLimit` (NOT `setLimit` — Bug 3). `setLimit` is a MANUAL user action * that clears the auto-reduce notice; using it at startup would wipe the * persisted auto-reduce banner. `restoreLimit` seeds the limit WITHOUT clearing * the notice, and `loadAutoReduce` re-applies the notice afterward. * * Skips prefixed keys (cooldown:/auto-reduce:) — those are loaded by their * own loaders. */ async function loadLimits( storage: StorageNamespace, manager: ConcurrencyService, logger: Logger, ): Promise { const keys = await storage.keys(); for (const key of keys) { if (key.startsWith(COOLDOWN_KEY_PREFIX)) continue; // cooldown settings if (key.startsWith(AUTOREDUCE_KEY_PREFIX)) continue; // auto-reduce markers const providerId = key; const raw = await storage.get(providerId); if (raw === null) continue; const limit = Number.parseInt(raw, 10); if (!Number.isNaN(limit) && limit > 0) { manager.restoreLimit(providerId, limit); logger.info(`provider-concurrency: restored limit ${limit} for "${providerId}"`); } } } /** * Load saved auto-reduce markers and re-apply them via `restoreLimit` so the * frontend banner survives a restart (Bug 3). A marker is stored under * `auto-reduce:` with the value = the ORIGINAL limit before * reduction (autoReducedFrom). The current (reduced) limit was already restored * by {@link loadLimits}; this call re-marks it as auto-reduced. */ async function loadAutoReduce( storage: StorageNamespace, manager: ConcurrencyService, logger: Logger, ): Promise { const keys = await storage.keys(AUTOREDUCE_KEY_PREFIX); for (const key of keys) { const providerId = key.slice(AUTOREDUCE_KEY_PREFIX.length); if (providerId.length === 0) continue; const raw = await storage.get(key); if (raw === null) continue; const autoReducedFrom = Number.parseInt(raw, 10); if (!Number.isNaN(autoReducedFrom) && autoReducedFrom > 0) { const currentLimit = manager.getLimit(providerId); if (currentLimit !== undefined && currentLimit < autoReducedFrom) { manager.restoreLimit(providerId, currentLimit, autoReducedFrom); logger.info( `provider-concurrency: restored auto-reduce notice for "${providerId}" ` + `(${autoReducedFrom} -> ${currentLimit})`, ); } } } } /** * Load saved cooldowns from storage and apply them to the manager. * Cooldowns are stored under `cooldown:` keys (distinct from the * bare-`` limit keys) so the two settings persist independently. */ async function loadCooldowns( storage: StorageNamespace, manager: ConcurrencyService, logger: Logger, ): Promise { const keys = await storage.keys(COOLDOWN_KEY_PREFIX); for (const key of keys) { const providerId = key.slice(COOLDOWN_KEY_PREFIX.length); if (providerId.length === 0) continue; const raw = await storage.get(key); if (raw === null) continue; const cooldownMs = Number.parseInt(raw, 10); if (!Number.isNaN(cooldownMs) && cooldownMs >= 0) { manager.setCooldown(providerId, cooldownMs); logger.info(`provider-concurrency: restored cooldown ${cooldownMs}ms for "${providerId}"`); } } } export async function activate(host: HostAPI): Promise { const logger = host.logger; const storage = host.storage("provider-concurrency"); // Build the injected usage-poll effect from the host's provider registry. // Lazy (called at poll time, not activate time) so activation order with the // provider extensions doesn't matter. A provider that doesn't expose // `getUsage` (or isn't registered) → returns undefined → the manager's usage // gate falls back to cooldown-only recycling for that provider. This keeps // the manager pure (the HTTP poll is an injected effect, not hardcoded fetch). const fetchUsage = async (providerId: string) => { const provider = host.getProviders().get(providerId); if (provider === undefined || provider.getUsage === undefined) return undefined; return provider.getUsage(); }; // Resolve the conversation store to seed the in-memory starred-workspace // cache. The `isWorkspaceStarred` callback reads this cache synchronously // (the queue sort comparator is sync), so we must populate it before the // manager handles its first acquire. `dependsOn: ["conversation-store"]` // in the manifest guarantees the store is registered before we activate. const conversationStore = host.getService(conversationStoreHandle); // The manager owns the in-memory `starredWorkspaces` set internally (the // default `isWorkspaceStarred` callback checks it). We seed it by calling // `notifyWorkspaceStarred` for each starred workspace found in the store. const managerOpts: ConcurrencyManagerOpts = { now: () => Date.now(), slotTimeoutMs: SLOT_TIMEOUT_MS, watchdogIntervalMs: WATCHDOG_INTERVAL_MS, defaultPauseMs: DEFAULT_PAUSE_MS, releaseCooldownMs: RELEASE_COOLDOWN_MS, fetchUsage, onWatchdogReclaim: (providerId, conversationId, heldMs) => { logger.warn("provider-concurrency: watchdog reclaimed stale slot", { providerId, conversationId, heldMs, }); }, onPause: (providerId, durationMs) => { logger.warn("provider-concurrency: 429 backoff — pausing queue", { providerId, durationMs, }); }, onLimitReduced: (providerId, newLimit, oldLimit) => { logger.warn("provider-concurrency: 429 adaptive headroom — limit reduced", { providerId, oldLimit, newLimit, }); // Persist the reduced (one-way) limit so it survives a restart, AND the // auto-reduce marker (autoReducedFrom) so the banner survives too (Bug 3). storage.set(providerId, String(newLimit)).catch((err) => logger.warn("provider-concurrency: failed to persist auto-reduced limit", { providerId, err: err instanceof Error ? err.message : String(err), }), ); storage.set(`${AUTOREDUCE_KEY_PREFIX}${providerId}`, String(oldLimit)).catch((err) => logger.warn("provider-concurrency: failed to persist auto-reduce marker", { providerId, err: err instanceof Error ? err.message : String(err), }), ); }, onUsagePollError: (providerId, err) => { // A throwing getUsage() is treated as "no usage info" (cooldown-only // fallback) by the manager — this is WARN-level observability only (Bug 2). logger.warn("provider-concurrency: usage poll failed — falling back to cooldown-only", { providerId, err: err instanceof Error ? err.message : String(err), }); }, }; const inner = createConcurrencyManager(managerOpts); // Restore persisted limits + auto-reduce notices + cooldowns before registering // the service so the first request sees the correct configuration. await loadLimits(storage, inner, logger); await loadAutoReduce(storage, inner, logger); await loadCooldowns(storage, inner, logger); // Seed the in-memory starred cache from the conversation store so the // priority scheduling is correct on a fresh server start (previously-starred // workspaces are respected without requiring the user to re-star them). try { const workspaces = await conversationStore.listWorkspaces(); for (const ws of workspaces) { if (ws.starred) { inner.notifyWorkspaceStarred(ws.id, true); } } if (workspaces.some((w) => w.starred)) { logger.info("provider-concurrency: restored starred workspaces", { count: workspaces.filter((w) => w.starred).length, }); } } catch (err) { logger.warn("provider-concurrency: failed to load starred workspaces", { err: err instanceof Error ? err.message : String(err), }); } const service = createPersistedService(inner, storage, logger); host.provideService(concurrencyServiceHandle, service); logger.info("provider-concurrency: registered"); } export const extension: Extension = { manifest, activate, };