Skip to content

Commit 3b2f958

Browse files
fix(server): ACP Registry searches reuse a recently fetched index (#26248)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4b00290 commit 3b2f958

3 files changed

Lines changed: 122 additions & 5 deletions

File tree

‎packages/client-runtime/src/state/server.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1310,7 +1310,8 @@ export function createServerEnvironmentAtoms<R, E>(
13101310
searchAcpRegistry: createEnvironmentRpcQueryAtomFamily(runtime, {
13111311
label: "environment-data:server:acp-registry:search",
13121312
tag: WS_METHODS.serverSearchAcpRegistry,
1313-
// Reopening Add provider reuses recent results; submitting the same search refreshes it.
1313+
// Reopening Add provider reuses recent results. Submitting the same search asks the
1314+
// server again, which reuses a Registry index fetched in the last five minutes.
13141315
staleTimeMs: 5 * 60_000,
13151316
}),
13161317
configProjection,

‎packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as Path from "effect/Path";
1515
import * as Fiber from "effect/Fiber";
1616
import * as Layer from "effect/Layer";
1717
import * as Schema from "effect/Schema";
18+
import * as TestClock from "effect/testing/TestClock";
1819
import * as HttpClient from "effect/http/HttpClient";
1920
import * as HttpClientResponse from "effect/http/HttpClientResponse";
2021
import * as ChildProcess from "effect/process/ChildProcess";
@@ -821,7 +822,7 @@ describe("AcpRegistrySupport", () => {
821822
);
822823
});
823824

824-
it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => {
825+
it.effect("searches reuse an index fetched in the last five minutes", () => {
825826
const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
826827
let requests = 0;
827828
return Effect.gen(function* () {
@@ -840,6 +841,11 @@ describe("AcpRegistrySupport", () => {
840841
);
841842
expect(requests).toBe(1);
842843

844+
yield* TestClock.adjust("4 minutes");
845+
yield* resolver.search({ query: "other" });
846+
expect(requests).toBe(1);
847+
848+
yield* TestClock.adjust("1 minute");
843849
yield* resolver.search({ query: "example" });
844850
expect(requests).toBe(2);
845851
}).pipe(
@@ -855,6 +861,94 @@ describe("AcpRegistrySupport", () => {
855861
);
856862
});
857863

864+
it.effect("searches retry the network after falling back to the cached index", () => {
865+
const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
866+
let requests = 0;
867+
return Effect.gen(function* () {
868+
const fileSystem = yield* FileSystem.FileSystem;
869+
const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
870+
prefix: "t3-acp-registry-retry-",
871+
});
872+
yield* fileSystem.makeDirectory(`${cacheDir}/acp-registry`, { recursive: true });
873+
yield* fileSystem.writeFileString(
874+
`${cacheDir}/acp-registry/registry.json`,
875+
makeRegistry(agent),
876+
);
877+
const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
878+
cacheDir,
879+
toolsDir: `${cacheDir}/tools`,
880+
registryUrl,
881+
});
882+
const offline = yield* resolver.search({ query: "example" });
883+
expect(offline.agents.map((result) => result.id)).toEqual([agent.id]);
884+
expect(requests).toBe(1);
885+
886+
yield* resolver.search({ query: "example" });
887+
expect(requests).toBe(2);
888+
yield* resolver.search({ query: "example" });
889+
expect(requests).toBe(2);
890+
}).pipe(
891+
Effect.scoped,
892+
Effect.provide(
893+
layerResolver((request) => {
894+
requests += 1;
895+
return Effect.succeed(
896+
HttpClientResponse.fromWeb(
897+
request,
898+
requests === 1
899+
? new Response("unavailable", { status: 503 })
900+
: new Response(makeRegistry(agent)),
901+
),
902+
);
903+
}),
904+
),
905+
);
906+
});
907+
908+
it.effect("searches retry the network after a refresh falls back within the reuse window", () => {
909+
const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
910+
let requests = 0;
911+
let online = true;
912+
return Effect.gen(function* () {
913+
const fileSystem = yield* FileSystem.FileSystem;
914+
const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
915+
prefix: "t3-acp-registry-refresh-fallback-",
916+
});
917+
const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
918+
cacheDir,
919+
toolsDir: `${cacheDir}/tools`,
920+
registryUrl,
921+
});
922+
yield* resolver.search({ query: "example" });
923+
expect(requests).toBe(1);
924+
925+
// Prepare always refreshes; an unknown agent stops it right after the refresh.
926+
online = false;
927+
const missing = yield* resolver.prepare({ agentId: "missing-agent" }).pipe(Effect.flip);
928+
expect(missing.reason).toBe("agent_not_found");
929+
expect(requests).toBe(2);
930+
931+
online = true;
932+
yield* resolver.search({ query: "example" });
933+
expect(requests).toBe(3);
934+
}).pipe(
935+
Effect.scoped,
936+
Effect.provide(
937+
layerResolver((request) => {
938+
requests += 1;
939+
return Effect.succeed(
940+
HttpClientResponse.fromWeb(
941+
request,
942+
online
943+
? new Response(makeRegistry(agent))
944+
: new Response("unavailable", { status: 503 }),
945+
),
946+
);
947+
}),
948+
),
949+
);
950+
});
951+
858952
it.effect("filters runner recipes that the environment cannot prepare", () => {
859953
const runnerAgent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
860954
const binaryAgent = {

‎packages/provider-acp-registry/src/server/AcpRegistrySupport.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024;
5555
// Older clients omit the limit and validate responses against a 20-agent cap.
5656
const DEFAULT_SEARCH_LIMIT = 20;
5757
const REGISTRY_REQUEST_TIMEOUT = "30 seconds";
58+
// Searches reuse an index fetched this recently; prepare always fetches the latest.
59+
const REGISTRY_SEARCH_MAX_AGE_MS = 5 * 60 * 1_000;
5860
const ARCHIVE_REQUEST_TIMEOUT = "20 minutes";
5961
const PACKAGE_INSTALL_TIMEOUT = "20 minutes";
6062
const PACKAGE_QUERY_TIMEOUT = "30 seconds";
@@ -659,6 +661,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
659661
: path.join(packageInstallRoot(agent, distribution), "bin");
660662
const packageReceiptsDirectory = path.join(registryDirectory, "package-installs");
661663
const registryRef = yield* Ref.make<AcpRegistryIndex | undefined>(undefined);
664+
// When the network last served registryRef; unset while it holds the disk cache.
665+
const registryFetchedAtMs = yield* Ref.make<number | undefined>(undefined);
662666
const registryRevision = yield* Ref.make(0);
663667
const registrySemaphore = yield* Semaphore.make(1);
664668
const installSemaphore = yield* Semaphore.make(1);
@@ -848,25 +852,43 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
848852
const current = yield* Ref.get(registryRef);
849853
if (current !== undefined && currentRevision !== observedRevision) return current;
850854

851-
const registry = yield* fetchRegistry().pipe(
855+
const { registry, fetched } = yield* fetchRegistry().pipe(
856+
Effect.map((registry) => ({ registry, fetched: true })),
852857
Effect.catch((networkError) =>
853858
readCachedRegistry.pipe(
854859
Effect.flatMap(
855860
Option.match({
856861
onNone: () => Effect.fail(networkError),
857-
onSome: Effect.succeed,
862+
onSome: (registry) => Effect.succeed({ registry, fetched: false }),
858863
}),
859864
),
860865
),
861866
),
862867
);
863868
yield* Ref.set(registryRef, registry);
869+
yield* Ref.set(registryFetchedAtMs, fetched ? yield* Clock.currentTimeMillis : undefined);
864870
yield* Ref.update(registryRevision, (revision) => revision + 1);
865871
return registry;
866872
}),
867873
);
868874
});
869875

876+
const recentRegistry = Effect.fn("AcpRegistryCatalog.recentRegistry")(function* () {
877+
const current = yield* Ref.get(registryRef);
878+
const fetchedAtMs = yield* Ref.get(registryFetchedAtMs);
879+
const now = yield* Clock.currentTimeMillis;
880+
// A clock moved back makes the stamp untrustworthy, so it counts as stale.
881+
if (
882+
current !== undefined &&
883+
fetchedAtMs !== undefined &&
884+
now >= fetchedAtMs &&
885+
now - fetchedAtMs < REGISTRY_SEARCH_MAX_AGE_MS
886+
) {
887+
return current;
888+
}
889+
return yield* refreshRegistry();
890+
});
891+
870892
const loadCachedRegistry = Effect.fn("AcpRegistryCatalog.loadCachedRegistry")(function* () {
871893
const current = yield* Ref.get(registryRef);
872894
if (current !== undefined) return current;
@@ -1589,7 +1611,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
15891611

15901612
const search: AcpRegistryCatalog["Service"]["search"] = Effect.fn("AcpRegistryCatalog.search")(
15911613
function* (input) {
1592-
const registry = yield* refreshRegistry();
1614+
const registry = yield* recentRegistry();
15931615
const ranked = registry.agents.flatMap((agent) => {
15941616
const distribution = resolveAcpRegistryDistribution({
15951617
agent,

0 commit comments

Comments
 (0)