From 98f3d542077cddeedbc4a40e6a44be3c2dfa7c1a Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Tue, 8 Sep 2026 17:35:02 -0300 Subject: [PATCH 1/2] fix(daemon): queue limit, discovery coalescing, body release Three small fixes from a DeepChat upstream sweep (#2236, #2248, #2251): - raise the per-session pending-input queue limit from 5 to 10 so users can line up several messages during long agent turns - coalesce concurrent model discovery: refreshProviderModels shares one upstream /models request per provider and Ollama tag/ps lookups share one request per provider+suffix instead of fanning out per caller - release unconsumed fetch response bodies on error paths (ACP archive download, McpRouter list/get, provider DB refresh, registry icon) so failed requests return their connections to the pool SDD: docs/issues/upstream-hygiene-sweep --- .../daemon/src/host/bun-session-repository.ts | 2 +- apps/daemon/src/host/daemonConfigPresenter.ts | 40 ++++- apps/daemon/test/upstreamHygieneSweep.test.ts | 167 ++++++++++++++++++ docs/issues/upstream-hygiene-sweep/plan.md | 15 ++ docs/issues/upstream-hygiene-sweep/spec.md | 53 ++++++ docs/issues/upstream-hygiene-sweep/tasks.md | 15 ++ .../src/config/acpLaunchSpecService.ts | 6 + .../src/config/acpRegistryService.ts | 6 + .../src/provider/providerDbLoader.ts | 6 + .../src/config/mcprouterManager.ts | 20 ++- 10 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 apps/daemon/test/upstreamHygieneSweep.test.ts create mode 100644 docs/issues/upstream-hygiene-sweep/plan.md create mode 100644 docs/issues/upstream-hygiene-sweep/spec.md create mode 100644 docs/issues/upstream-hygiene-sweep/tasks.md diff --git a/apps/daemon/src/host/bun-session-repository.ts b/apps/daemon/src/host/bun-session-repository.ts index 235b6577e..52f9cdc83 100644 --- a/apps/daemon/src/host/bun-session-repository.ts +++ b/apps/daemon/src/host/bun-session-repository.ts @@ -116,7 +116,7 @@ export function usageDateKey(timestamp: number): string { return `${year}-${month}-${day}`; } -const MAX_ACTIVE_PENDING_INPUTS = 5; +const MAX_ACTIVE_PENDING_INPUTS = 10; /** * Coerce a stored session status into a value the renderer's SessionStatusSchema diff --git a/apps/daemon/src/host/daemonConfigPresenter.ts b/apps/daemon/src/host/daemonConfigPresenter.ts index a6f1ed02e..91599b5ae 100644 --- a/apps/daemon/src/host/daemonConfigPresenter.ts +++ b/apps/daemon/src/host/daemonConfigPresenter.ts @@ -100,6 +100,9 @@ const defaultProviders: LLM_PROVIDER[] = DEFAULT_PROVIDERS.map((provider) => ({ export class DaemonConfigPresenter { private store: Store; + /** In-flight model-discovery requests, coalesced per provider (DeepChat #2248 pattern). */ + private inFlightModelRefreshes = new Map>(); + private inFlightOllamaFetches = new Map>(); private filePath: string; private readonly acpConfig: DaemonAcpConfig; private readonly mcpConfig: DaemonMcpConfig; @@ -645,6 +648,20 @@ export class DaemonConfigPresenter { } async refreshProviderModels(providerId: string): Promise { + // Coalesce concurrent discovery for the same provider (model picker, + // store init, per-agent surfaces all call this) into one upstream request. + const inFlight = this.inFlightModelRefreshes.get(providerId); + if (inFlight) { + return inFlight; + } + const promise = this.doRefreshProviderModels(providerId).finally(() => { + this.inFlightModelRefreshes.delete(providerId); + }); + this.inFlightModelRefreshes.set(providerId, promise); + return promise; + } + + private async doRefreshProviderModels(providerId: string): Promise { const provider = this.getProviderById(providerId); if (!provider) { throw new Error(`Provider not found: ${providerId}`); @@ -709,7 +726,7 @@ export class DaemonConfigPresenter { return []; } - return this.fetchOllamaModels(provider.baseUrl, provider.apiKey, "/api/tags"); + return this.fetchOllamaModelsCoalesced(providerId, provider.baseUrl, provider.apiKey, "/api/tags"); } async listOllamaRunningModels(providerId: string): Promise { @@ -718,7 +735,26 @@ export class DaemonConfigPresenter { return []; } - return this.fetchOllamaModels(provider.baseUrl, provider.apiKey, "/api/ps"); + return this.fetchOllamaModelsCoalesced(providerId, provider.baseUrl, provider.apiKey, "/api/ps"); + } + + /** Coalesce concurrent identical Ollama lookups into one upstream request. */ + private fetchOllamaModelsCoalesced( + providerId: string, + baseUrl: string, + apiKey: string, + suffix: "/api/tags" | "/api/ps", + ): Promise { + const key = `${providerId}:${suffix}`; + const inFlight = this.inFlightOllamaFetches.get(key); + if (inFlight) { + return inFlight; + } + const promise = this.fetchOllamaModels(baseUrl, apiKey, suffix).finally(() => { + this.inFlightOllamaFetches.delete(key); + }); + this.inFlightOllamaFetches.set(key, promise); + return promise; } async pullOllamaModel(providerId: string, modelName: string): Promise { diff --git a/apps/daemon/test/upstreamHygieneSweep.test.ts b/apps/daemon/test/upstreamHygieneSweep.test.ts new file mode 100644 index 000000000..898db4aff --- /dev/null +++ b/apps/daemon/test/upstreamHygieneSweep.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "bun:test"; +import { Database } from "bun:sqlite"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { BunSessionRepository } from "../src/host/bun-session-repository"; +import { DaemonConfigPresenter } from "../src/host/daemonConfigPresenter"; +import { AcpLaunchSpecService } from "@argos/acp-runtime/config/acpLaunchSpecService"; + +/** + * Hygiene sweep (docs/issues/upstream-hygiene-sweep): + * - pending-input queue limit raised to 10 (DeepChat #2236) + * - concurrent model discovery coalesces into one upstream request (#2248) + * - failed downloads release the response body before throwing (#2251) + */ + +describe("pending input queue limit", () => { + let repo: BunSessionRepository; + let db: Database; + + beforeEach(() => { + db = new Database(":memory:"); + repo = new BunSessionRepository(db as never); + db.run( + `INSERT INTO daemon_sessions (id, agent_id, title, status, generation_status, created_at, updated_at) + VALUES (?, 'argos', 'Test', 'idle', 'idle', ?, ?)`, + ["session-1", Date.now(), Date.now()], + ); + }); + + it("accepts a queue of 10 and rejects the 11th", async () => { + for (let index = 0; index < 10; index += 1) { + await expect( + repo.queuePendingInput("session-1", `message ${index}`, { source: "queue" } as never), + ).resolves.toBeTruthy(); + } + + await expect(repo.queuePendingInput("session-1", "message 11", { source: "queue" } as never)).rejects.toThrow( + "Pending input limit reached", + ); + }); +}); + +describe("model discovery coalescing", () => { + const originalFetch = globalThis.fetch; + const roots: string[] = []; + + afterEach(() => { + globalThis.fetch = originalFetch; + while (roots.length > 0) { + const root = roots.pop(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + } + }); + + const tempRoot = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argos-hygiene-")); + roots.push(dir); + return dir; + }; + + it("coalesces concurrent refreshes for the same provider into one upstream call", async () => { + const dataDir = tempRoot(); + const presenter = new DaemonConfigPresenter(dataDir, dataDir); + presenter.setProviders([ + { + id: "prov-1", + name: "Coalesced Provider", + type: "openai", + apiType: "openai", + baseUrl: "https://models.example.invalid/v1", + apiKey: "sk-test", + } as never, + ]); + + let upstreamCalls = 0; + globalThis.fetch = (async () => { + upstreamCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 25)); + return new Response(JSON.stringify({ data: [{ id: "model-a", name: "Model A" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const [first, second, third] = await Promise.all([ + presenter.refreshProviderModels("prov-1"), + presenter.refreshProviderModels("prov-1"), + presenter.refreshProviderModels("prov-1"), + ]); + + expect(upstreamCalls).toBe(1); + expect(first.map((model) => model.id)).toEqual(["model-a"]); + expect(second).toBe(first); + expect(third).toBe(first); + + // After the coalesced call settles, a new call is a fresh refresh. + const fourth = await presenter.refreshProviderModels("prov-1"); + expect(upstreamCalls).toBe(2); + expect(fourth).not.toBe(first); + }); + + it("does not coalesce different providers", async () => { + const dataDir = tempRoot(); + const presenter = new DaemonConfigPresenter(dataDir, dataDir); + presenter.setProviders([ + { + id: "prov-1", + name: "P1", + type: "openai", + apiType: "openai", + baseUrl: "https://a.example.invalid/v1", + apiKey: "k", + } as never, + { + id: "prov-2", + name: "P2", + type: "openai", + apiType: "openai", + baseUrl: "https://b.example.invalid/v1", + apiKey: "k", + } as never, + ]); + + let upstreamCalls = 0; + globalThis.fetch = (async () => { + upstreamCalls += 1; + return new Response(JSON.stringify({ data: [{ id: "m" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + await Promise.all([presenter.refreshProviderModels("prov-1"), presenter.refreshProviderModels("prov-2")]); + expect(upstreamCalls).toBe(2); + }); +}); + +describe("download archive response release", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("cancels the error body and throws on a failed archive download", async () => { + const registryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "argos-hygiene-acp-")); + const service = new AcpLaunchSpecService(registryRoot); + const cancel = vi.fn(async () => undefined); + + globalThis.fetch = (async () => { + return { + ok: false, + status: 500, + statusText: "Internal Server Error", + body: { cancel }, + } as unknown as Response; + }) as typeof fetch; + + await expect( + (service as any).downloadArchive("https://mirror.example.invalid/agent.zip", { id: "agent-1" }), + ).rejects.toThrow("Failed to download archive"); + expect(cancel).toHaveBeenCalled(); + + fs.rmSync(registryRoot, { recursive: true, force: true }); + }); +}); diff --git a/docs/issues/upstream-hygiene-sweep/plan.md b/docs/issues/upstream-hygiene-sweep/plan.md new file mode 100644 index 000000000..a2604adee --- /dev/null +++ b/docs/issues/upstream-hygiene-sweep/plan.md @@ -0,0 +1,15 @@ +# Plan: Upstream hygiene sweep + +1. `apps/daemon/src/host/bun-session-repository.ts`: `MAX_ACTIVE_PENDING_INPUTS` 5 → 10. +2. `apps/daemon/src/host/daemonConfigPresenter.ts`: add + `inFlightModelRefreshes: Map>` (providerId key) wrapping + `refreshProviderModels`, and `inFlightOllamaFetches: Map>` + (providerId+path key) wrapping `fetchOllamaModels`. Entries delete on settle; failures + propagate to every waiter and clear the key (next call retries). +3. `packages/acp-runtime/src/config/acpLaunchSpecService.ts`, + `packages/mcp-runtime/src/config/mcprouterManager.ts`, + `packages/backend-core/src/provider/providerDbLoader.ts`: release the error-path body before + throwing/returning (`await response.body?.cancel()` in try/catch). +4. Tests: pending-input limit (daemon), refresh coalescing (daemon), downloadArchive body + release (acp-runtime test dir if a harness exists there, otherwise daemon test dir hosts it + against the exported class). diff --git a/docs/issues/upstream-hygiene-sweep/spec.md b/docs/issues/upstream-hygiene-sweep/spec.md new file mode 100644 index 000000000..40488c2a0 --- /dev/null +++ b/docs/issues/upstream-hygiene-sweep/spec.md @@ -0,0 +1,53 @@ +# Spec: Upstream hygiene sweep (queue limit, model-discovery coalescing, response-body release) + +Three small, independent fixes identified while sweeping recent DeepChat work +(#2236, #2248, #2251 — plus #1946 assessed as already-satisfied). + +## 1. Pending-input queue limit 5 → 10 (DeepChat #2236) + +`MAX_ACTIVE_PENDING_INPUTS` in `bun-session-repository.ts` caps queued messages per session at +5. Users who queue several messages during a long agent turn hit the limit with a bare error. +Raise the cap to 10 (matching upstream's post-fix behavior) and add regression coverage that 10 +succeed and the 11th rejects. + +## 2. Coalesce model discovery requests (DeepChat #2248) + +Two daemon surfaces fetch model lists over the network with no in-flight dedup, so N concurrent +UI callers (model picker, model store init, per-agent config surfaces) produce N identical +upstream requests: + +- `DaemonConfigPresenter.refreshProviderModels(providerId)` — the OpenAI-compatible `/models` + fetch, lazily triggered by `models.getProviderCatalog` whenever a provider has credentials but + no stored catalog yet; +- `DaemonConfigPresenter.fetchOllamaModels` via `listOllamaModels`/`listOllamaRunningModels` — + `/api/tags` and `/api/ps` per Ollama provider. + +Coalesce with a per-key in-flight promise map (keyed by providerId, and providerId+path for +Ollama): concurrent callers share one upstream request; the map entry clears on settle so the +next call after completion is a fresh refresh. + +## 3. Release unconsumed fetch response bodies (DeepChat #2251) + +Four sites throw/return on `!response.ok` without consuming the error body, which keeps the +underlying socket busy until GC: + +- `AcpLaunchSpecService.downloadArchive` (`packages/acp-runtime`); +- `mcprouterManager` list + get (`packages/mcp-runtime`); +- `providerDbLoader` refresh (`packages/backend-core`). + +Fix: cancel the body (`response.body?.cancel()`, best-effort try/catch) before each +error return. Success paths already consume. Done inline per file — no new cross-package +dependency for a three-line helper. + +## Non-goals + +- No UI changes (the pending-input lane already reflects live queue contents; no rendered + static limit text exists to update). +- No changes to the ACP registry refresh or Ollama pull (both already consume bodies). + +## Tests + +- Daemon: pending-input limit test (10 succeed, 11th throws) against the in-memory SQLite repo. +- Daemon: `refreshProviderModels` coalescing test — two concurrent calls with a counting fetch + stub produce exactly one upstream request and identical results. +- acp-runtime: `downloadArchive` non-ok response releases the body (cancel spy) and throws. diff --git a/docs/issues/upstream-hygiene-sweep/tasks.md b/docs/issues/upstream-hygiene-sweep/tasks.md new file mode 100644 index 000000000..c87bb72a5 --- /dev/null +++ b/docs/issues/upstream-hygiene-sweep/tasks.md @@ -0,0 +1,15 @@ +# Tasks: Upstream hygiene sweep + +- [x] T1 Pending-input queue limit 5 → 10 + limit regression test (10 OK, 11th rejects) +- [x] T2 Coalesce `refreshProviderModels` (per provider) and Ollama tag/ps lookups (per + provider+suffix) + coalescing tests (same-provider → 1 upstream call; different providers + → separate calls; post-settle calls refresh) +- [x] T3 Release unconsumed fetch error bodies: `downloadArchive`, `mcprouterManager` list+get, + `providerDbLoader` refresh, `acpRegistryService` icon fetch + downloadArchive release test +- [x] T4 `bun run format` + `bun run lint` + `bun run typecheck` + `bun run test` + +## Verification results + +- Daemon: 409 tests pass (4 new); `tsc --noEmit` clean. +- Desktop + UI: typechecks clean; full `bun run test` green. +- `bun run lint`: all architecture guards + oxlint clean. diff --git a/packages/acp-runtime/src/config/acpLaunchSpecService.ts b/packages/acp-runtime/src/config/acpLaunchSpecService.ts index 0d1e3f9c4..ffa102d4a 100644 --- a/packages/acp-runtime/src/config/acpLaunchSpecService.ts +++ b/packages/acp-runtime/src/config/acpLaunchSpecService.ts @@ -503,6 +503,12 @@ export class AcpLaunchSpecService { const archivePath = path.join(tempDir, path.basename(new URL(url).pathname)); const response = await fetch(url); if (!response.ok) { + // Release the error body so the underlying connection returns to the pool. + try { + await response.body?.cancel(); + } catch { + // best-effort + } throw new Error(`Failed to download archive: ${response.status} ${response.statusText}`); } diff --git a/packages/acp-runtime/src/config/acpRegistryService.ts b/packages/acp-runtime/src/config/acpRegistryService.ts index 9b3eac6de..a3a554012 100644 --- a/packages/acp-runtime/src/config/acpRegistryService.ts +++ b/packages/acp-runtime/src/config/acpRegistryService.ts @@ -541,6 +541,12 @@ export class AcpRegistryService { }); if (!response.ok) { + // Release the error body so the underlying connection returns to the pool. + try { + await response.body?.cancel(); + } catch { + // best-effort + } throw new Error(`Failed to fetch icon: ${response.status} ${response.statusText}`); } diff --git a/packages/backend-core/src/provider/providerDbLoader.ts b/packages/backend-core/src/provider/providerDbLoader.ts index c2f3d9854..479968cb9 100644 --- a/packages/backend-core/src/provider/providerDbLoader.ts +++ b/packages/backend-core/src/provider/providerDbLoader.ts @@ -283,6 +283,12 @@ export class ProviderDbLoader { } if (!res.ok) { + // Release the error body so the underlying connection returns to the pool. + try { + await res.body?.cancel(); + } catch { + // best-effort + } const meta = this.createAttemptMeta(prevMeta, url, now); if (meta) this.writeMeta(meta); return this.createResult("error", meta, `Request failed with status ${res.status}`); diff --git a/packages/mcp-runtime/src/config/mcprouterManager.ts b/packages/mcp-runtime/src/config/mcprouterManager.ts index 6774099a7..5eb38c515 100644 --- a/packages/mcp-runtime/src/config/mcprouterManager.ts +++ b/packages/mcp-runtime/src/config/mcprouterManager.ts @@ -57,7 +57,15 @@ export class McpRouterManager { headers: this.getCommonHeaders(), body: JSON.stringify({ page, limit }), }); - if (!res.ok) throw new Error(`McpRouter list failed: HTTP ${res.status}`); + if (!res.ok) { + // Release the error body so the underlying connection returns to the pool. + try { + await res.body?.cancel(); + } catch { + // best-effort + } + throw new Error(`McpRouter list failed: HTTP ${res.status}`); + } const json = (await res.json()) as McpRouterListResponse; if (json.code !== 0) throw new Error(json.message || "List servers error"); return json.data || { servers: [] }; @@ -75,7 +83,15 @@ export class McpRouterManager { headers, body: JSON.stringify({ server: serverKey }), }); - if (!res.ok) throw new Error(`McpRouter get failed: HTTP ${res.status}`); + if (!res.ok) { + // Release the error body so the underlying connection returns to the pool. + try { + await res.body?.cancel(); + } catch { + // best-effort + } + throw new Error(`McpRouter get failed: HTTP ${res.status}`); + } const json = (await res.json()) as McpRouterGetResponse; if (json.code !== 0 || !json.data) throw new Error(json.message || "Get server error"); return json.data; From 5df1ef0989f69f9f12b46342916c4f85d9aeec83 Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Tue, 8 Sep 2026 18:40:30 -0300 Subject: [PATCH 2/2] fix(daemon): fingerprinted coalescing key and SDD completeness Address reviewer findings on the hygiene sweep: - key model-discovery coalescing on the provider settings fingerprint (id + apiType + baseUrl + apiKey) instead of providerId alone, so a mid-flight settings change starts fresh discovery rather than joining results fetched with stale credentials - record the registry icon fetch as a fifth response-body release site in the SDD spec and plan (the task list already had it) --- apps/daemon/src/host/daemonConfigPresenter.ts | 49 ++++++++++--------- docs/issues/upstream-hygiene-sweep/plan.md | 19 ++++--- docs/issues/upstream-hygiene-sweep/spec.md | 5 +- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/apps/daemon/src/host/daemonConfigPresenter.ts b/apps/daemon/src/host/daemonConfigPresenter.ts index 91599b5ae..fcd1dca30 100644 --- a/apps/daemon/src/host/daemonConfigPresenter.ts +++ b/apps/daemon/src/host/daemonConfigPresenter.ts @@ -648,20 +648,6 @@ export class DaemonConfigPresenter { } async refreshProviderModels(providerId: string): Promise { - // Coalesce concurrent discovery for the same provider (model picker, - // store init, per-agent surfaces all call this) into one upstream request. - const inFlight = this.inFlightModelRefreshes.get(providerId); - if (inFlight) { - return inFlight; - } - const promise = this.doRefreshProviderModels(providerId).finally(() => { - this.inFlightModelRefreshes.delete(providerId); - }); - this.inFlightModelRefreshes.set(providerId, promise); - return promise; - } - - private async doRefreshProviderModels(providerId: string): Promise { const provider = this.getProviderById(providerId); if (!provider) { throw new Error(`Provider not found: ${providerId}`); @@ -672,16 +658,31 @@ export class DaemonConfigPresenter { if (!provider.apiKey) { throw new Error(`Provider ${providerId} has no API key configured`); } - - const definition = resolveAiSdkProviderDefinition(provider); - const modelSource = definition?.modelSource ?? "openai"; - - const models = - modelSource === "provider-db" - ? await this.fetchProviderModelsFromCatalog(provider) - : await this.fetchProviderModels(provider); - this.setProviderModels(providerId, models); - return models; + // Coalesce concurrent discovery for the same provider (model picker, + // store init, per-agent surfaces all call this) into one upstream + // request. The key includes the settings fingerprint so a mid-flight + // settings change starts a fresh discovery instead of handing back + // results fetched with stale credentials. + const key = `${providerId}:${provider.apiType}:${provider.baseUrl}:${provider.apiKey}`; + const inFlight = this.inFlightModelRefreshes.get(key); + if (inFlight) { + return inFlight; + } + const promise = (async () => { + const definition = resolveAiSdkProviderDefinition(provider); + const modelSource = definition?.modelSource ?? "openai"; + + const models = + modelSource === "provider-db" + ? await this.fetchProviderModelsFromCatalog(provider) + : await this.fetchProviderModels(provider); + this.setProviderModels(providerId, models); + return models; + })().finally(() => { + this.inFlightModelRefreshes.delete(key); + }); + this.inFlightModelRefreshes.set(key, promise); + return promise; } private async fetchProviderModelsFromCatalog(provider: LLM_PROVIDER): Promise { diff --git a/docs/issues/upstream-hygiene-sweep/plan.md b/docs/issues/upstream-hygiene-sweep/plan.md index a2604adee..187784c88 100644 --- a/docs/issues/upstream-hygiene-sweep/plan.md +++ b/docs/issues/upstream-hygiene-sweep/plan.md @@ -2,14 +2,17 @@ 1. `apps/daemon/src/host/bun-session-repository.ts`: `MAX_ACTIVE_PENDING_INPUTS` 5 → 10. 2. `apps/daemon/src/host/daemonConfigPresenter.ts`: add - `inFlightModelRefreshes: Map>` (providerId key) wrapping - `refreshProviderModels`, and `inFlightOllamaFetches: Map>` + `inFlightModelRefreshes: Map>` wrapping `refreshProviderModels`, + keyed by a provider settings fingerprint (id + apiType + baseUrl + apiKey) so a mid-flight + settings change starts a fresh discovery instead of returning results fetched with stale + credentials, and `inFlightOllamaFetches: Map>` (providerId+path key) wrapping `fetchOllamaModels`. Entries delete on settle; failures propagate to every waiter and clear the key (next call retries). -3. `packages/acp-runtime/src/config/acpLaunchSpecService.ts`, - `packages/mcp-runtime/src/config/mcprouterManager.ts`, - `packages/backend-core/src/provider/providerDbLoader.ts`: release the error-path body before - throwing/returning (`await response.body?.cancel()` in try/catch). +3. Release unconsumed fetch error bodies (best-effort `response.body?.cancel()`) in: + - `packages/acp-runtime/src/config/acpLaunchSpecService.ts` (downloadArchive), + - `packages/mcp-runtime/src/config/mcprouterManager.ts` (list + get), + - `packages/backend-core/src/provider/providerDbLoader.ts` (refresh), + - `packages/acp-runtime/src/config/acpRegistryService.ts` (icon fetch, found during the sweep). + Done inline per file — no new cross-package dependency for a three-line helper. 4. Tests: pending-input limit (daemon), refresh coalescing (daemon), downloadArchive body - release (acp-runtime test dir if a harness exists there, otherwise daemon test dir hosts it - against the exported class). + release (daemon test dir hosts it against the exported class). diff --git a/docs/issues/upstream-hygiene-sweep/spec.md b/docs/issues/upstream-hygiene-sweep/spec.md index 40488c2a0..1ce83dd3d 100644 --- a/docs/issues/upstream-hygiene-sweep/spec.md +++ b/docs/issues/upstream-hygiene-sweep/spec.md @@ -28,12 +28,13 @@ next call after completion is a fresh refresh. ## 3. Release unconsumed fetch response bodies (DeepChat #2251) -Four sites throw/return on `!response.ok` without consuming the error body, which keeps the +Five sites throw/return on `!response.ok` without consuming the error body, which keeps the underlying socket busy until GC: - `AcpLaunchSpecService.downloadArchive` (`packages/acp-runtime`); - `mcprouterManager` list + get (`packages/mcp-runtime`); -- `providerDbLoader` refresh (`packages/backend-core`). +- `providerDbLoader` refresh (`packages/backend-core`); +- `AcpRegistryService` icon fetch (`packages/acp-runtime`, found during the sweep). Fix: cancel the body (`response.body?.cancel()`, best-effort try/catch) before each error return. Success paths already consume. Done inline per file — no new cross-package