From 95e34fa2ec60a2d064c0cf13235ca94a59b7fe19 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 06:12:30 -0500 Subject: [PATCH 1/2] fix(acp-registry): installing one agent no longer blocks other registry agents All ACP Registry install work shared one permit, so while one agent downloaded its binary or ran a package install, sessions and status checks for every other registry agent waited behind it. Each agent now takes its own permit. Work on the same agent stays serialized. Co-Authored-By: Claude Opus 5.5 --- .../src/server/AcpRegistrySupport.test.ts | 67 +++++++++++++++++++ .../src/server/AcpRegistrySupport.ts | 21 ++++-- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index 7b11b520ea999..4e99f1f04d25e 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -1445,6 +1445,73 @@ describe("AcpRegistrySupport", () => { }); }); + it.effect("resolves an agent while another agent's download is still running", () => { + const slowArchiveUrl = "https://registry.test/slow-agent.bin"; + const agent = makeAgent({ + binary: { "linux-x86_64": { archive: archiveUrl, cmd: "./bin/example-agent" } }, + }); + const slowAgent = { + ...makeAgent({ + binary: { "linux-x86_64": { archive: slowArchiveUrl, cmd: "./bin/slow-agent" } }, + }), + id: "slow-agent", + name: "Slow Agent", + }; + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); + return Effect.gen(function* () { + const downloadStarted = yield* Deferred.make(); + const releaseDownload = yield* Deferred.make(); + return yield* Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-parallel-install-", + }); + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + + const prepareFiber = yield* resolver + .prepare({ agentId: slowAgent.id }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(downloadStarted); + + const resolved = yield* resolver.resolve(settings(), "/workspace"); + expect(resolved.spawn.command).toContain( + "/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent", + ); + expect(prepareFiber.pollUnsafe()).toBeUndefined(); + + yield* Deferred.succeed(releaseDownload, undefined); + expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); + }).pipe( + Effect.scoped, + Effect.provide( + layerResolver((request) => { + if (request.url === registryUrl) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ version: "1.0.0", agents: [slowAgent, agent] })), + ), + ); + } + const response = HttpClientResponse.fromWeb( + request, + new Response(binaryBytes.buffer as ArrayBuffer), + ); + if (request.url !== slowArchiveUrl) return Effect.succeed(response); + return Deferred.succeed(downloadStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseDownload)), + Effect.as(response), + ); + }), + ), + ); + }); + }); + it.effect("reserves a prepared binary until a configured instance inspects it", () => { const agent = makeAgent({ binary: { diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index f06b8101f728a..16f4caa9dd4f6 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -662,7 +662,14 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const registryRef = yield* Ref.make(undefined); const registryRevision = yield* Ref.make(0); const registrySemaphore = yield* Semaphore.make(1); - const installSemaphore = yield* Semaphore.make(1); + // Downloads and package installs can take minutes, so each agent waits only + // for its own install work, not for another agent's. + const installPermits = new Map(); + const withInstallPermit = (agentId: string) => { + const permit = installPermits.get(agentId) ?? Semaphore.makeUnsafe(1); + installPermits.set(agentId, permit); + return permit.withPermits(1); + }; const preparedBinaryReservations = yield* Ref.make>(new Map()); const managerBinDirectory = Effect.fn("AcpRegistryCatalog.managerBinDirectory")(function* ( @@ -1646,13 +1653,13 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct if (distribution.kind === "binary") { // A PATH executable can be a different version with different ACP // capabilities. Only an explicit command override opts into that copy. - yield* installSemaphore.withPermits(1)( + yield* withInstallPermit(agent.id)( installBinary(agent, distribution.binaryTarget!).pipe( Effect.tap(() => reservePreparedBinary(agent.id)), ), ); } else { - yield* installSemaphore.withPermits(1)( + yield* withInstallPermit(agent.id)( ensureGlobalPackage(agent, distribution.kind, distribution.packageName!, hostEnvironment), ); } @@ -1729,7 +1736,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct } if (distribution.kind === "binary") { - return yield* installSemaphore.withPermits(1)( + return yield* withInstallPermit(agent.id)( Effect.gen(function* () { const paths = binaryPaths(agent, distribution.binaryTarget!); if (paths === undefined) { @@ -1841,7 +1848,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct command = resolvedOverride; args = distribution.args; } else if (distribution.kind === "npx" || distribution.kind === "uvx") { - const installed = yield* installSemaphore.withPermits(1)( + const installed = yield* withInstallPermit(agent.id)( ensureGlobalPackage( agent, distribution.kind, @@ -1854,7 +1861,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct commandBinDirectory = installed.binDirectory; } else { const target = distribution.binaryTarget!; - command = yield* installSemaphore.withPermits(1)( + command = yield* withInstallPermit(agent.id)( installBinary(agent, target).pipe( Effect.tap(() => consumePreparedBinaryReservation(agent.id)), ), @@ -1887,7 +1894,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => hostSettings .withSnapshot((settings) => - installSemaphore.withPermits(1)( + withInstallPermit(input.agentId)( Effect.gen(function* () { const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( Effect.mapError( From e6e913c3be6176a5ed56b9c7d0e688ca3477a602 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 06:19:30 +0000 Subject: [PATCH 2/2] test(acp-registry): cover resolution of cached agents during installs --- .../src/server/AcpRegistrySupport.test.ts | 9 ++++++--- .../src/server/AcpRegistrySupport.ts | 3 +-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index 4e99f1f04d25e..e30476832b56e 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -1445,15 +1445,16 @@ describe("AcpRegistrySupport", () => { }); }); - it.effect("resolves an agent while another agent's download is still running", () => { + it.effect("resolves an installed agent while another agent's download is still running", () => { const slowArchiveUrl = "https://registry.test/slow-agent.bin"; const agent = makeAgent({ binary: { "linux-x86_64": { archive: archiveUrl, cmd: "./bin/example-agent" } }, }); const slowAgent = { - ...makeAgent({ + ...agent, + distribution: { binary: { "linux-x86_64": { archive: slowArchiveUrl, cmd: "./bin/slow-agent" } }, - }), + }, id: "slow-agent", name: "Slow Agent", }; @@ -1472,6 +1473,8 @@ describe("AcpRegistrySupport", () => { registryUrl, }); + yield* resolver.resolve(settings(), "/workspace"); + const prepareFiber = yield* resolver .prepare({ agentId: slowAgent.id }) .pipe(Effect.forkChild({ startImmediately: true })); diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index 35ca5e27b3341..4b3d2ae1e5fdd 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -661,8 +661,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const registryRef = yield* Ref.make(undefined); const registryRevision = yield* Ref.make(0); const registrySemaphore = yield* Semaphore.make(1); - // Downloads and package installs can take minutes, so each agent waits only - // for its own install work, not for another agent's. + // Long installs must only block operations on the same agent. const installPermits = new Map(); const withInstallPermit = (agentId: string) => { const permit = installPermits.get(agentId) ?? Semaphore.makeUnsafe(1);