Skip to content

Commit 35f7a83

Browse files
committed
fix(agent-directory): isolate bulk reads per entry; verify stanza parity (CL-7592)
1 parent 21ad092 commit 35f7a83

7 files changed

Lines changed: 307 additions & 76 deletions

File tree

apps/hub/src/skills-mount.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Covers the `pinnedBy` resolver `mountSkills` hands the skill registry:
2+
// every definition's pins are read out of its own asset snapshot, so one
3+
// unreadable asset (a pre-cutover retired envelope, a missing blob) skips
4+
// its row — reported, never failing the whole resolve — and the fan-out
5+
// stays bounded no matter how many definitions a tenant carries.
6+
import { expect, test } from "bun:test";
7+
8+
import type { DB } from "@intx/db";
9+
import type { AssetService, RepoStore } from "@intx/hub-sessions";
10+
import {
11+
agentDefinitionSourceTree,
12+
AGENT_DEFINITION_ENTRY_PATH,
13+
buildAgentDefinitionWorkflow,
14+
reindexPinnedSkills,
15+
RetiredWorkflowEnvelopeError,
16+
serializeAgentDefinitionWorkflow,
17+
} from "@corbits/agent-directory";
18+
19+
import { mountSkills } from "./skills-mount";
20+
21+
/** Entry-module bytes pinning `names` — the stanza `pinnedBy` reads. */
22+
function definitionBytesPinning(...names: string[]): Uint8Array {
23+
const tree = agentDefinitionSourceTree({
24+
handle: "research-buddy",
25+
workflowJson: reindexPinnedSkills(
26+
serializeAgentDefinitionWorkflow(
27+
buildAgentDefinitionWorkflow({
28+
handle: "research-buddy",
29+
tenantDomain: "acme.example",
30+
description: "",
31+
systemPrompt: "You are a careful research assistant.",
32+
}),
33+
),
34+
names.map((name) => ({ name, description: `What ${name} does.` })),
35+
),
36+
});
37+
return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]);
38+
}
39+
40+
function mountFor(
41+
rows: readonly {
42+
id: string;
43+
tenantId: string;
44+
assetId: string | null;
45+
name: string;
46+
}[],
47+
readAssetBlob: AssetService["readAssetBlob"],
48+
) {
49+
const db = {
50+
query: {
51+
workflowDefinition: { findMany: async () => rows },
52+
},
53+
} as unknown as DB["db"];
54+
return mountSkills({
55+
db,
56+
assetService: { readAssetBlob } as AssetService,
57+
repoStore: {} as RepoStore,
58+
});
59+
}
60+
61+
test("a row on the retired envelope skips while healthy rows still resolve", async () => {
62+
const mount = mountFor(
63+
[
64+
{
65+
id: "def_healthy",
66+
tenantId: "tnt_1",
67+
assetId: "ast_healthy",
68+
name: "research-buddy",
69+
},
70+
{
71+
id: "def_retired",
72+
tenantId: "tnt_1",
73+
assetId: "ast_retired",
74+
name: "old-scout",
75+
},
76+
],
77+
(params) =>
78+
params.assetId === "ast_healthy"
79+
? Promise.resolve(definitionBytesPinning("research"))
80+
: Promise.reject(new RetiredWorkflowEnvelopeError(params.assetId)),
81+
);
82+
const pinning = await mount.pinnedBy.resolve("tnt_1", "research");
83+
expect(pinning).toEqual([
84+
{ definitionId: "def_healthy", name: "research-buddy" },
85+
]);
86+
});
87+
88+
test("the blob fan-out stays bounded no matter how many definitions pin", async () => {
89+
const COUNT = 20;
90+
const rows = Array.from({ length: COUNT }, (_, index) => ({
91+
id: `def_${index}`,
92+
tenantId: "tnt_1",
93+
assetId: `ast_${index}`,
94+
name: `buddy-${index}`,
95+
}));
96+
let release!: () => void;
97+
const gate = new Promise<void>((resolve) => {
98+
release = resolve;
99+
});
100+
let active = 0;
101+
let peak = 0;
102+
const mount = mountFor(rows, async () => {
103+
active += 1;
104+
peak = Math.max(peak, active);
105+
try {
106+
await gate;
107+
return definitionBytesPinning("research");
108+
} finally {
109+
active -= 1;
110+
}
111+
});
112+
const pending = mount.pinnedBy.resolve("tnt_1", "research");
113+
// Every worker reaches the gate before any read can finish, so the
114+
// peak observed here is the whole fan-out.
115+
await new Promise((resolve) => setTimeout(resolve, 20));
116+
expect(peak).toBeLessThanOrEqual(8);
117+
release();
118+
const pinning = await pending;
119+
expect(pinning).toHaveLength(COUNT);
120+
});

apps/hub/src/skills-mount.ts

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
readPinnedSkillNames,
1515
type PinnedSkillIndexResolver,
1616
} from "@corbits/agent-directory";
17+
import { reportError } from "@corbits/error-sink";
1718
import {
1819
createDrizzleSkillAccessStore,
1920
createHubSkillAssetStore,
@@ -29,6 +30,37 @@ export type SkillsMount = {
2930
skillIndex: PinnedSkillIndexResolver;
3031
};
3132

33+
/** At most this many concurrent asset-blob reads while resolving who
34+
* pins a skill — a tenant's definition count is unbounded, and one
35+
* `readAssetBlob` per definition with no cap is a self-inflicted load
36+
* spike against the asset store. */
37+
const PINNED_BY_READ_CONCURRENCY = 8;
38+
39+
/** Runs `fn` over `items` with at most `limit` in flight, preserving
40+
* order — the same bounded fan-out every per-row asset read in this
41+
* composition root needs, so a many-definition tenant cannot open a
42+
* blob read per definition at once. */
43+
async function mapWithConcurrencyLimit<T, R>(
44+
items: readonly T[],
45+
limit: number,
46+
fn: (item: T) => Promise<R>,
47+
): Promise<readonly R[]> {
48+
const results = new Array<R>(items.length);
49+
let next = 0;
50+
const workers = Array.from(
51+
{ length: Math.min(limit, items.length) },
52+
async () => {
53+
while (next < items.length) {
54+
const index = next;
55+
next += 1;
56+
results[index] = await fn(items[index] as T);
57+
}
58+
},
59+
);
60+
await Promise.all(workers);
61+
return results;
62+
}
63+
3264
export function mountSkills(deps: {
3365
db: DB["db"];
3466
assetService: AssetService;
@@ -48,20 +80,37 @@ export function mountSkills(deps: {
4880
const rows = await deps.db.query.workflowDefinition.findMany({
4981
where: and(eq(workflowDefinition.tenantId, tenantId)),
5082
});
51-
const pinning: { definitionId: string; name: string }[] = [];
52-
for (const row of rows) {
53-
if (row.assetId === null) continue;
54-
// Pins live in the definition's own asset snapshot — the same
55-
// stanza every agent-directory read goes through.
56-
const workflowJson = await readAgentDefinitionWorkflowJson(
57-
deps.assetService,
58-
row.assetId,
59-
);
60-
if (readPinnedSkillNames(workflowJson).includes(skillName)) {
61-
pinning.push({ definitionId: row.id, name: row.name });
62-
}
63-
}
64-
return pinning;
83+
const candidates = rows.filter(
84+
(row): row is typeof row & { assetId: string } => row.assetId !== null,
85+
);
86+
// Pins live in each definition's own asset snapshot — the same
87+
// stanza every agent-directory read goes through. Bounded fan-out
88+
// instead of the old sequential N+1, and one unreadable asset
89+
// (a pre-cutover retired envelope, a missing blob) skips its
90+
// row — reported, never failing the whole resolve.
91+
const matches = await mapWithConcurrencyLimit(
92+
candidates,
93+
PINNED_BY_READ_CONCURRENCY,
94+
async (row) => {
95+
try {
96+
const workflowJson = await readAgentDefinitionWorkflowJson(
97+
deps.assetService,
98+
row.assetId,
99+
);
100+
return readPinnedSkillNames(workflowJson).includes(skillName)
101+
? { definitionId: row.id, name: row.name }
102+
: null;
103+
} catch (err) {
104+
reportError(err, {
105+
operation: "skills.pinnedBy.resolve",
106+
tenantId,
107+
extra: { definitionId: row.id, skillName },
108+
});
109+
return null;
110+
}
111+
},
112+
);
113+
return matches.filter((match) => match !== null);
65114
},
66115
};
67116

packages/agent-directory/src/migrations.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ export const agentDirectoryMigrations: readonly AgentDirectoryMigration[] = [
2929
// definition assets' own pinned-skills stanzas: the table 0001 created
3030
// is dropped, never read again. Append-only like every ledger entry
3131
// before it — history is not rewritten, the store is deleted forward.
32+
//
33+
// Parity (stanza ⊇ store, so the drop loses nothing): from the
34+
// table's introduction (baabe260) to this cutover, every `setSkills`
35+
// writer dual-wrote the identical skill set into the asset stanza
36+
// first — create (`createAgentDefinitionCore`), `PUT
37+
// /:definitionId/skills`, the skill-pin route, and the
38+
// capability-add skill path all `reindexPinnedSkills` the written
39+
// workflow and persist the store only in `afterWrite`, which runs
40+
// after the asset write succeeds. The store has no delete path and
41+
// reads a missing row as []. A crash between the two writes leaves
42+
// the stanza ahead (safe: reads now come from the stanza); no path
43+
// writes the store without first writing the stanza, so no dropped
44+
// row can name a skill its asset's stanza lacks.
3245
{
3346
name: "0002_drop_definition_skills",
3447
sql: `DROP TABLE IF EXISTS "agent_directory"."definition_skills";`,

packages/agent-directory/src/routes.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -274,22 +274,34 @@ export function createAgentDefinitionRoutes({
274274

275275
const entries = await Promise.all(
276276
ids.map(async (definitionId) => {
277-
const row = await db.query.workflowDefinition.findFirst({
278-
where: and(
279-
eq(workflowDefinition.id, definitionId),
280-
eq(workflowDefinition.tenantId, tenant.id),
281-
),
282-
});
283-
if (row === undefined || row.assetId === null) return null;
284-
// Pins read out of the asset's own stanza: the bulk read
285-
// survives the side table's deletion by going to the same
286-
// source `GET /:definitionId` reads.
287-
const workflowJson = await readAgentDefinitionWorkflowJson(
288-
assetService,
289-
row.assetId,
290-
);
291-
const skills = readPinnedSkillNames(workflowJson);
292-
return [definitionId, skills] as const;
277+
try {
278+
const row = await db.query.workflowDefinition.findFirst({
279+
where: and(
280+
eq(workflowDefinition.id, definitionId),
281+
eq(workflowDefinition.tenantId, tenant.id),
282+
),
283+
});
284+
if (row === undefined || row.assetId === null) return null;
285+
// Pins read out of the asset's own stanza: the bulk read
286+
// survives the side table's deletion by going to the same
287+
// source `GET /:definitionId` reads. One unreadable asset
288+
// (a pre-cutover retired envelope, a missing blob) must not
289+
// fail the whole batch — skip that id, report it, serve the
290+
// healthy ones.
291+
const workflowJson = await readAgentDefinitionWorkflowJson(
292+
assetService,
293+
row.assetId,
294+
);
295+
const skills = readPinnedSkillNames(workflowJson);
296+
return [definitionId, skills] as const;
297+
} catch (err) {
298+
reportError(err, {
299+
operation: "agentDirectory.bulkSkills",
300+
tenantId: tenant.id,
301+
extra: { definitionId },
302+
});
303+
return null;
304+
}
293305
}),
294306
);
295307

packages/agent-directory/test/routes.test.ts

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ import {
2323
serializeAgentDefinitionWorkflow,
2424
withAgentToolPackagePin,
2525
readPinnedSkillNames,
26-
reindexPinnedSkills,
2726
} from "../src/agent-workflow";
2827
import {
2928
agentDefinitionSourceTree,
3029
AGENT_DEFINITION_ENTRY_PATH,
3130
readAgentDefinitionWorkflowJson,
31+
RetiredWorkflowEnvelopeError,
3232
} from "../src/definition-asset";
3333
import { createAgentDefinitionRoutes } from "../src/routes";
3434
import type { PinnedSkillIndexResolver } from "../src/routes";
@@ -38,7 +38,11 @@ import {
3838
} from "../src/workflow-skill-pin-routes";
3939
import type { DefinitionAssetHistory } from "../src/definition-history";
4040
import type { CapabilityInventoryProvider } from "../src/capability-inventory";
41-
import { definitionFrom, SOURCE_TREE_PATHS } from "./source-tree";
41+
import {
42+
definitionFrom,
43+
SOURCE_TREE_PATHS,
44+
storedDefinitionBytesWithSkills,
45+
} from "./source-tree";
4246

4347
/** A `readAssetBlob` that always answers the definition's entry module
4448
* with `workflowBytes` — pins live in the asset's own stanza, so a test
@@ -213,27 +217,6 @@ function storedDefinitionBytesWithModel(model: string): Uint8Array {
213217
return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]);
214218
}
215219

216-
/** A stored definition that already pins skills — the state every
217-
* pin-reading route observes. The stanza is the seed: no side table to
218-
* write, the bytes carry the pins like a real asset would. */
219-
function storedDefinitionBytesWithSkills(...names: string[]): Uint8Array {
220-
const tree = agentDefinitionSourceTree({
221-
handle: "research-buddy",
222-
workflowJson: reindexPinnedSkills(
223-
serializeAgentDefinitionWorkflow(
224-
buildAgentDefinitionWorkflow({
225-
handle: "research-buddy",
226-
tenantDomain: TENANT.domain,
227-
description: "",
228-
systemPrompt: "You are a careful research assistant.",
229-
}),
230-
),
231-
names.map((name) => ({ name, description: `What ${name} does.` })),
232-
),
233-
});
234-
return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]);
235-
}
236-
237220
/** The model the one step agent resolves against, or `undefined` when it
238221
* pins none. */
239222
function modelFrom(workflowJson: string): string | undefined {
@@ -763,6 +746,49 @@ test("GET /skills omits unknown definition ids from the map rather than erroring
763746
expect(body.skills).toEqual({});
764747
});
765748

749+
test("GET /skills serves the healthy ids when one asset is on the retired envelope", async () => {
750+
// The route issues one `findFirst` per requested id, in request order
751+
// (each `map` callback runs synchronously to its first await), so the
752+
// fake answers each call from this queue — drizzle's `where`
753+
// expression tree isn't inspectable without a real query builder.
754+
const rows = [
755+
{ id: "def_healthy", assetId: "ast_healthy" },
756+
{ id: "def_retired", assetId: "ast_retired" },
757+
];
758+
const db = {
759+
query: {
760+
workflowDefinition: {
761+
findFirst: async () => {
762+
const row = rows.shift();
763+
return row === undefined
764+
? undefined
765+
: {
766+
id: row.id,
767+
tenantId: TENANT.id,
768+
assetId: row.assetId,
769+
name: "Research Buddy",
770+
};
771+
},
772+
},
773+
},
774+
} as unknown as DB["db"];
775+
const app = buildApp(
776+
fakeAssetService({
777+
readAssetBlob: (params) =>
778+
params.assetId === "ast_healthy"
779+
? Promise.resolve(storedDefinitionBytesWithSkills("web-research"))
780+
: Promise.reject(new RetiredWorkflowEnvelopeError(params.assetId)),
781+
}),
782+
db,
783+
);
784+
const response = await app.request("/skills?ids=def_healthy,def_retired");
785+
expect(response.status).toBe(200);
786+
const body = (await response.json()) as {
787+
skills: Record<string, readonly string[]>;
788+
};
789+
expect(body.skills).toEqual({ def_healthy: ["web-research"] });
790+
});
791+
766792
test("PUT /:definitionId/skills replaces the skill set, writing the definition source tree to the asset", async () => {
767793
let writtenFiles: Record<string, string | Uint8Array> | undefined;
768794
const app = buildApp(

0 commit comments

Comments
 (0)