summaryrefslogtreecommitdiffhomepage
path: root/packages/provider-concurrency/src/extension.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/provider-concurrency/src/extension.ts')
-rw-r--r--packages/provider-concurrency/src/extension.ts319
1 files changed, 319 insertions, 0 deletions
diff --git a/packages/provider-concurrency/src/extension.ts b/packages/provider-concurrency/src/extension.ts
new file mode 100644
index 0000000..48c019b
--- /dev/null
+++ b/packages/provider-concurrency/src/extension.ts
@@ -0,0 +1,319 @@
+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 `<providerId>` 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<void> {
+ 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:<providerId>` 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<void> {
+ 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:<providerId>` keys (distinct from the
+ * bare-`<providerId>` limit keys) so the two settings persist independently.
+ */
+async function loadCooldowns(
+ storage: StorageNamespace,
+ manager: ConcurrencyService,
+ logger: Logger,
+): Promise<void> {
+ 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<void> {
+ 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,
+};