From c0a0b20654c315b5472867a68065d49209e79e37 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 1 Oct 2026 22:12:35 -0700 Subject: [PATCH] perf(subagent): reuse parent setup across spawns Reuse skill catalog, inference/pricing, and env snapshot from parent setup across spawns. Isolation between spawns is kept and spawn stays non-blocking. Fixes CL-9010 --- src/agent/environment.ts | 52 +++ src/agent/tools.ts | 4 + src/extensions/skills.ts | 38 +++ src/session/assemble-runtime.ts | 6 + src/subagent/agent-fleet.ts | 10 + src/subagent/run.ts | 25 +- src/subagent/spawn-cold-start.test.ts | 475 ++++++++++++++++++++++++++ src/subagent/types.ts | 21 ++ 8 files changed, 626 insertions(+), 5 deletions(-) create mode 100644 src/subagent/spawn-cold-start.test.ts diff --git a/src/agent/environment.ts b/src/agent/environment.ts index 1c97f7540..efddb588d 100644 --- a/src/agent/environment.ts +++ b/src/agent/environment.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { readdir } from "node:fs/promises"; import { arch, release, type as osType } from "node:os"; +import { resolve } from "node:path"; export interface EnvironmentInfo { cwd: string; @@ -107,6 +108,57 @@ async function gatherTopLevel(cwd: string): Promise { } } +/** CL-9010: how long a cached environment snapshot stays fresh. */ +export const ENVIRONMENT_CACHE_TTL_MS = 5_000; + +type EnvironmentCacheEntry = { + at: number; + value: EnvironmentInfo; +}; + +// CL-9010: short-TTL environment cache keyed by resolved cwd. Spawned +// workers share the dispatcher's git/top-level snapshot: the first spawn +// pays the git calls and same-cwd spawns within the TTL reuse it, so +// spawn_agent waves do not re-run `git status` per lane. Concurrent spawns +// share one in-flight gather instead of stampeding git. Hits return a fresh +// top-level copy with a current date so one worker cannot mutate another's +// snapshot and the prompt never shows a stale clock. +const environmentCache = new Map(); +const environmentInflight = new Map>(); + +export async function gatherEnvironmentCached( + cwd: string, + date = new Date(), + ttlMs = ENVIRONMENT_CACHE_TTL_MS, +): Promise { + const key = resolve(cwd); + const entry = environmentCache.get(key); + if (entry !== undefined && Date.now() - entry.at < ttlMs) { + return { ...entry.value, date }; + } + const inflight = environmentInflight.get(key); + if (inflight !== undefined) { + return { ...(await inflight), date }; + } + const gather = gatherEnvironment(cwd, date); + environmentInflight.set(key, gather); + try { + const settled = await gather; + environmentCache.set(key, { at: Date.now(), value: settled }); + return { ...settled, date }; + } finally { + // A gather failure caches nothing — the next spawn retries. + if (environmentInflight.get(key) === gather) + environmentInflight.delete(key); + } +} + +/** Test hook: drop every cached environment snapshot. */ +export function resetEnvironmentCacheForTests(): void { + environmentCache.clear(); + environmentInflight.clear(); +} + export async function gatherEnvironment( cwd: string, date = new Date(), diff --git a/src/agent/tools.ts b/src/agent/tools.ts index cb84630fa..56e400edf 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -597,6 +597,10 @@ export async function createAgentToolset( ? { secretGuardExtraDeniedPaths } : {}), ...(skillDirs.length > 0 ? { skillDirs } : {}), + // CL-9010: the parent's already-discovered catalog. Shared-cwd lanes + // reuse it instead of rescanning; worktree lanes (different cwd) + // ignore it and rediscover for their own directory. + skillSnapshot: skills, ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), cwd, getWorkdirBase: sa.getWorkdirBase, diff --git a/src/extensions/skills.ts b/src/extensions/skills.ts index 639f1c336..13ff1d3de 100644 --- a/src/extensions/skills.ts +++ b/src/extensions/skills.ts @@ -205,3 +205,41 @@ export async function discoverSkills( } return skills; } + +// CL-9010: process-lifetime skill catalog cache keyed by resolved cwd + +// plugin dirs. Spawned workers share the dispatcher's catalog: the first +// same-cwd spawn pays discovery and later spawns reuse the snapshot, so +// spawn_agent dispatch stays non-blocking on repeated waves. The fallback +// base dirs are cwd-relative, so the key must include the cwd — different +// dirs (or a different cwd) always rediscover. Callers get a copy; the +// cached canonical is never handed out, so one worker cannot mutate +// another's catalog. Unbounded in practice the same way the inference +// singleton is: one entry per distinct (cwd, dirs) pair per process. +const skillSnapshotCache = new Map(); + +function skillSnapshotCacheKey( + cwd: string, + pluginDirs: readonly string[], +): string { + return `${resolve(cwd)}\0${pluginDirs.join("\0")}`; +} + +export async function discoverSkillsCached( + cwd: string, + pluginDirs: readonly string[] = [], +): Promise { + const key = skillSnapshotCacheKey(cwd, pluginDirs); + const cached = skillSnapshotCache.get(key); + if (cached !== undefined) return cached.map((skill) => ({ ...skill })); + const fresh = await discoverSkills(cwd, [...pluginDirs]); + skillSnapshotCache.set( + key, + fresh.map((skill) => ({ ...skill })), + ); + return fresh; +} + +/** Test hook: drop every cached skill snapshot so suites start unpolluted. */ +export function resetSkillDiscoveryCacheForTests(): void { + skillSnapshotCache.clear(); +} diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index df4c9ac1d..3cd9bc71e 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -138,11 +138,17 @@ import { * best-effort in long-lived entry points; a one-shot runner passes onError to * log and continue instead. Without onError a seed failure rejects, matching * the previous TUI boot behavior. + * + * CL-9010: fleet-spawned workers pass skipPricingSeed — the parent runtime + * already applied the process seed at boot (and the inference singleton is + * a no-op on repeat resolves), so the worker skips the cache re-read. */ export async function assembleInferenceBase( onPricingError?: (err: unknown) => void, + opts?: { skipPricingSeed?: boolean }, ): Promise>> { const inferenceDeps = await createInferenceDependencies(); + if (opts?.skipPricingSeed === true) return inferenceDeps; const seed = seedPricingMetadataFromCache({ cachePath: defaultPricingCachePath(), }); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 2aa96ed0a..4156cd2fd 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -1532,6 +1532,16 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { ...(deps.skillDirs !== undefined ? { skillDirs: deps.skillDirs } : {}), + // CL-9010: shared-cwd lanes reuse the dispatcher's skill catalog + // instead of rescanning — the snapshot was discovered for this + // exact cwd. Worktree lanes run elsewhere, so they rediscover + // (through the cached discovery) for their own directory. + // Every fleet-spawned worker skips the pricing-cache seed read: + // the parent runtime already applied the process seed at boot. + ...(deps.skillSnapshot !== undefined && worktreeCwd === undefined + ? { skills: deps.skillSnapshot } + : {}), + skipPricingSeed: true, ...(resolved.systemPromptRole !== undefined ? { systemPromptRole: resolved.systemPromptRole } : {}), diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 272a7404c..9096bc3a1 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -89,7 +89,7 @@ import { import { createShellCollectTool } from "../agent/background-shell-tool.js"; import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; import { tryReadPriorHandoffFile } from "../session/compaction-handoff.js"; -import { gatherEnvironment } from "../agent/environment.js"; +import { gatherEnvironmentCached } from "../agent/environment.js"; import { generateSessionId } from "../session/index.js"; import { consumeStream } from "../session/stream-consumer.js"; import { createCycleTextRecorder } from "../session/stream-journal.js"; @@ -118,7 +118,7 @@ import { createUseSkillTool, workerUseSkillDefinition, } from "../agent/use-skill.js"; -import { discoverSkills } from "../extensions/skills.js"; +import { discoverSkillsCached } from "../extensions/skills.js"; import { formatAttachedSkillConstraints } from "../agent/directors/attached-skills.js"; import { createManageTasksRunner, @@ -612,7 +612,11 @@ async function runSubAgentInner( telemetryRollup: SubAgentTelemetryRollup, settlementState: { latestModel: string }, ): Promise { - const inferenceDeps = await assembleInferenceBase(); + // CL-9010: fleet-spawned workers skip the pricing-cache seed re-read — + // the parent runtime already applied the process seed at boot. + const inferenceDeps = await assembleInferenceBase(undefined, { + ...(params.skipPricingSeed === true ? { skipPricingSeed: true } : {}), + }); // CL-9475: the fleet session id the parent observes (params.id); falls // back to the local session id below when run without a fleet caller. @@ -774,7 +778,13 @@ async function runSubAgentInner( orchestrator: params.orchestrator === true, }); const skillDirs = [...(params.skillDirs ?? [])]; - const skillSnapshot = await discoverSkills(params.cwd, skillDirs); + // CL-9010: reuse the dispatcher's catalog when the lane shares its cwd; + // otherwise fall back to the cached discovery (same-cwd repeat spawns + // skip the rescan). Copies keep one worker from mutating another's list. + const skillSnapshot = + params.skills !== undefined + ? [...params.skills] + : await discoverSkillsCached(params.cwd, skillDirs); tools = [ ...tools, createSkillSearchTool({ @@ -975,6 +985,9 @@ async function runSubAgentInner( ? { secretGuardExtraDeniedPaths: nd.secretGuardExtraDeniedPaths } : {}), ...(nd.skillDirs !== undefined ? { skillDirs: nd.skillDirs } : {}), + // CL-9010: nested shared-cwd lanes reuse this worker's catalog, which + // was discovered (or inherited) for exactly this cwd. + skillSnapshot, ...(nd.extraToolPlugins !== undefined ? { extraToolPlugins: nd.extraToolPlugins } : {}), @@ -1059,7 +1072,9 @@ async function runSubAgentInner( getContextDir: () => childContextDir, }); - const environment = await gatherEnvironment(params.cwd); + // CL-9010: same-cwd spawns within the TTL share the git/top-level + // snapshot instead of re-running git per lane. + const environment = await gatherEnvironmentCached(params.cwd); const attachedSection = params.attachedSkills !== undefined && params.attachedSkills.length > 0 ? await formatAttachedSkillConstraints({ diff --git a/src/subagent/spawn-cold-start.test.ts b/src/subagent/spawn-cold-start.test.ts new file mode 100644 index 000000000..ead361886 --- /dev/null +++ b/src/subagent/spawn-cold-start.test.ts @@ -0,0 +1,475 @@ +/** + * CL-9010 spawn cold start: fleet workers reuse the dispatcher's already-paid + * init work — the parent skill catalog, the inference deps + pricing seed, + * and the short-TTL environment snapshot — instead of re-running discovery, + * git, and the pricing seed per lane. Spawn dispatch itself stays + * non-blocking: every reuse lookup on the dispatch path is a sync + * cache/reference handoff. + * + * Pattern follows run-skill-scope.test.ts (real runSubAgent against the + * failing local provider; mount decisions run before the send) and + * run-authority.test.ts (re-import run.js inside the module mock). + */ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { withMockedModuleDuring } from "../../testkit/mock-module.js"; +import { defined } from "../../testkit/defined.js"; +import { initTemporaryGitRepo } from "../../testkit/temporary-git-repo.js"; +import { + discoverSkillsCached, + resetSkillDiscoveryCacheForTests, +} from "../extensions/skills.js"; +import { + gatherEnvironmentCached, + resetEnvironmentCacheForTests, +} from "../agent/environment.js"; +import { createSpawnAgentTool } from "./agent-fleet.js"; +import type { RunSubAgentParams, RunSubAgentResult } from "./types.js"; +import { + callFleetTool, + createFleetDeps, + deferred, + spawnAgentId, +} from "./fleet-test-harness.js"; +import { testPermissionGate } from "./fleet-test-harness.js"; +import { + baseRunParams, + pollUntil, + runWithFailingInference, + tmpSubAgentCwd, +} from "./run-test-harness.js"; + +const tempDirs: string[] = []; + +const runGit = promisify(execFile); + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir !== undefined) await rm(dir, { recursive: true, force: true }); + } +}); + +async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +beforeEach(() => { + resetSkillDiscoveryCacheForTests(); + resetEnvironmentCacheForTests(); +}); + +async function writeSkillFile( + pluginDir: string, + name: string, + description: string, +): Promise { + await mkdir(join(pluginDir, "skills", name), { recursive: true }); + await writeFile( + join(pluginDir, "skills", name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\nBody.\n`, + ); +} + +function probeParams( + cwd: string, + baseURL: string, + extra: Partial = {}, +): RunSubAgentParams { + return baseRunParams(cwd, { + provider: { providerName: "test", baseURL, model: "test-model" }, + description: "cold start probe", + prompt: "no-op", + ...extra, + }); +} + +/** Drive the real runSubAgent under the failing provider; init decisions run + * before the send fails. run.js is re-imported so module mocks are visible + * to its bindings. */ +async function probeRun( + cwd: string, + baseURL: string, + extra: Partial = {}, +): Promise { + const { runSubAgent: run } = await import("./run.js"); + await run(probeParams(cwd, baseURL, extra)).catch(() => { + // Inference fails by design; init decisions run first. + }); +} + +/** Wrap createSkillSearchTool to record the catalog each run mounts. */ +function captureSearchCatalog() { + const catalogs: { name: string; description: string }[][] = []; + const withCapturedCatalog = (body: () => Promise): Promise => + withMockedModuleDuring( + import.meta.resolve("../agent/skill-search.js"), + (real: typeof import("../agent/skill-search.js")) => ({ + ...real, + createSkillSearchTool: ( + args: Parameters[0], + ) => { + catalogs.push([...args.skills]); + return real.createSkillSearchTool(args); + }, + }), + body, + ); + return { catalogs, withCapturedCatalog }; +} + +describe("CL-9010 skill snapshot cache", () => { + test("same-cwd second lookup skips rediscovery", async () => { + const cwd = await tempDir("cl9010-skill-cache-"); + const plugin = join(cwd, "plugin"); + await writeSkillFile(plugin, "alpha", "Alpha skill."); + expect( + (await discoverSkillsCached(cwd, [plugin])).map((s) => s.name), + ).toEqual(["alpha"]); + await rm(join(plugin, "skills", "alpha"), { recursive: true, force: true }); + expect( + (await discoverSkillsCached(cwd, [plugin])).map((s) => s.name), + ).toEqual(["alpha"]); + }); + + test("different skill dirs respawn discovery", async () => { + const cwd = await tempDir("cl9010-skill-dirs-"); + const pluginA = join(cwd, "a"); + const pluginB = join(cwd, "b"); + await writeSkillFile(pluginA, "alpha", "Alpha skill."); + await mkdir(join(pluginB, "skills"), { recursive: true }); + expect( + (await discoverSkillsCached(cwd, [pluginA])).map((s) => s.name), + ).toEqual(["alpha"]); + expect(await discoverSkillsCached(cwd, [pluginB])).toEqual([]); + }); + + test("a different cwd rediscovers even with the same dirs", async () => { + const root = await tempDir("cl9010-skill-cwd-"); + const shared = join(root, "shared"); + await writeSkillFile(shared, "alpha", "Alpha skill."); + const cwdA = join(root, "lane-a"); + const cwdB = join(root, "lane-b"); + await mkdir(join(cwdA, ".agents", "skills", "local-a"), { + recursive: true, + }); + await writeFile( + join(cwdA, ".agents", "skills", "local-a", "SKILL.md"), + "---\nname: local-a\ndescription: Lane A skill.\n---\n\nBody.\n", + ); + await mkdir(cwdB, { recursive: true }); + expect( + (await discoverSkillsCached(cwdA, [shared])).map((s) => s.name).sort(), + ).toEqual(["alpha", "local-a"]); + expect( + (await discoverSkillsCached(cwdB, [shared])).map((s) => s.name), + ).toEqual(["alpha"]); + }); + + test("callers get isolated copies — no cross-session leakage", async () => { + const cwd = await tempDir("cl9010-skill-isolation-"); + const plugin = join(cwd, "plugin"); + await writeSkillFile(plugin, "alpha", "Alpha skill."); + const first = await discoverSkillsCached(cwd, [plugin]); + first.push({ name: "injected", description: "must not leak" }); + defined(first[0], "cached skill").description = "mutated"; + const second = await discoverSkillsCached(cwd, [plugin]); + expect(second).toEqual([{ name: "alpha", description: "Alpha skill." }]); + expect(second).not.toBe(first); + }); +}); + +describe("CL-9010 environment snapshot cache", () => { + test("reuses the snapshot within the TTL and refetches after expiry", async () => { + const cwd = await tempDir("cl9010-env-cache-"); + await writeFile(join(cwd, "first.txt"), "first"); + const first = await gatherEnvironmentCached(cwd); + expect(first.topLevel).toContain("first.txt"); + await writeFile(join(cwd, "second.txt"), "second"); + const cached = await gatherEnvironmentCached(cwd); + expect(cached.topLevel).toBe(first.topLevel); + expect(cached.topLevel).not.toContain("second.txt"); + const refetched = await gatherEnvironmentCached(cwd, new Date(), 0); + expect(refetched.topLevel).toContain("second.txt"); + }); + + test("hits return isolated copies stamped with a fresh date", async () => { + const cwd = await tempDir("cl9010-env-isolation-"); + const dayOne = new Date("2026-01-01T00:00:00Z"); + const dayTwo = new Date("2026-06-01T00:00:00Z"); + const first = await gatherEnvironmentCached(cwd, dayOne); + first.platform = "mutated"; + const second = await gatherEnvironmentCached(cwd, dayTwo); + expect(second.platform).not.toBe("mutated"); + expect(second.date).toEqual(dayTwo); + }); +}); + +describe("CL-9010 fleet dispatch threading", () => { + function captureRun(onParams: (params: RunSubAgentParams) => void): { + gate: ReturnType>; + run: (params: RunSubAgentParams) => Promise; + } { + const gate = deferred(); + return { + gate, + run: async (params: RunSubAgentParams) => { + onParams(params); + params.onAgentReady?.({ + close: async () => undefined, + interrupt: () => undefined, + followup: async () => "", + deliver: () => undefined, + }); + return gate.promise; + }, + }; + } + + async function spawnExploreAndCapture(opts: { + cwd: string; + skillSnapshot?: { name: string; description: string }[]; + useWorktree?: boolean; + getWorkdirBase?: () => string; + }): Promise { + let captured: RunSubAgentParams | undefined; + const { gate, run } = captureRun((params) => { + captured = params; + }); + const deps = createFleetDeps(run, { cwd: opts.cwd }); + if (opts.skillSnapshot !== undefined) + deps.skillSnapshot = opts.skillSnapshot; + if (opts.useWorktree === true) deps.useWorktree = true; + if (opts.getWorkdirBase !== undefined) + deps.getWorkdirBase = opts.getWorkdirBase; + const spawn = createSpawnAgentTool(deps); + const id = await spawnAgentId(spawn, { + description: "cold start lane", + prompt: "do it", + intent: "explore", + }); + expect(typeof id).toBe("string"); + await pollUntil(() => captured !== undefined, { + message: "worker run never started", + }); + gate.resolve({ report: "done" }); + return defined(captured, "captured run params"); + } + + test("shared-cwd lanes inherit the parent snapshot and skip the pricing seed", async () => { + const snapshot = [{ name: "style", description: "Style rules." }]; + const params = await spawnExploreAndCapture({ + cwd: "/repo", + skillSnapshot: snapshot, + }); + expect(params.skills).toEqual(snapshot); + expect(params.skipPricingSeed).toBe(true); + }); + + test("lanes without a parent snapshot discover on their own but still skip the seed", async () => { + const params = await spawnExploreAndCapture({ cwd: "/repo" }); + expect(params.skills).toBeUndefined(); + expect(params.skipPricingSeed).toBe(true); + }); + + test("worktree lanes ignore the parent snapshot — their cwd differs", async () => { + const repo = await tempDir("cl9010-fleet-wt-"); + initTemporaryGitRepo(repo); + await writeFile(join(repo, "seed.txt"), "seed"); + await runGit("git", ["add", "."], { cwd: repo }); + await runGit("git", ["commit", "-m", "seed"], { cwd: repo }); + const base = await tempDir("cl9010-fleet-wt-base-"); + const snapshot = [{ name: "style", description: "Style rules." }]; + const params = await spawnExploreAndCapture({ + cwd: repo, + skillSnapshot: snapshot, + useWorktree: true, + getWorkdirBase: () => base, + }); + expect(params.cwd).not.toBe(repo); + expect(params.skills).toBeUndefined(); + expect(params.skipPricingSeed).toBe(true); + }); +}); + +describe("CL-9010 run-level reuse", () => { + test("params.skills bypasses discovery entirely", async () => { + const cwd = await tmpSubAgentCwd("cl9010-run-bypass-"); + const { catalogs, withCapturedCatalog } = captureSearchCatalog(); + await runWithFailingInference((baseURL) => + withCapturedCatalog(() => + probeRun(cwd, baseURL, { + skills: [{ name: "handed-down", description: "From the parent." }], + }), + ), + ); + expect(catalogs.length).toBe(1); + expect(defined(catalogs[0])).toEqual([ + { name: "handed-down", description: "From the parent." }, + ]); + }, 30_000); + + test("same-cwd second spawn skips rediscovery; different dirs respawn", async () => { + const cwd = await tmpSubAgentCwd("cl9010-run-respawn-"); + const pluginA = join(cwd, "plugin-a"); + const pluginB = join(cwd, "plugin-b"); + await writeSkillFile(pluginA, "alpha", "Alpha skill."); + await mkdir(join(pluginB, "skills"), { recursive: true }); + const { catalogs, withCapturedCatalog } = captureSearchCatalog(); + await runWithFailingInference((baseURL) => + withCapturedCatalog(() => + probeRun(cwd, baseURL, { skillDirs: [pluginA] }), + ), + ); + expect(defined(catalogs[0]).map((s) => s.name)).toEqual(["alpha"]); + await rm(join(pluginA, "skills", "alpha"), { + recursive: true, + force: true, + }); + await runWithFailingInference((baseURL) => + withCapturedCatalog(() => + probeRun(cwd, baseURL, { skillDirs: [pluginA] }), + ), + ); + expect(defined(catalogs[1]).map((s) => s.name)).toEqual(["alpha"]); + await runWithFailingInference((baseURL) => + withCapturedCatalog(() => + probeRun(cwd, baseURL, { skillDirs: [pluginB] }), + ), + ); + expect(defined(catalogs[2])).toEqual([]); + }, 60_000); + + test("the second same-cwd spawn reuses the snapshot instead of re-gathering", async () => { + const cwd = await tmpSubAgentCwd("cl9010-run-env-"); + let uncachedCalls = 0; + await runWithFailingInference((baseURL) => + withMockedModuleDuring( + import.meta.resolve("../agent/environment.js"), + (real: typeof import("../agent/environment.js")) => ({ + ...real, + gatherEnvironment: ( + ...args: Parameters + ) => { + uncachedCalls += 1; + return real.gatherEnvironment(...args); + }, + }), + async () => { + const { runSubAgent: run } = await import("./run.js"); + // First spawn pays the gather and populates the cache... + await run(probeParams(cwd, baseURL)).catch(() => undefined); + expect(uncachedCalls).toBe(1); + // ...the second same-cwd spawn reuses it with no re-gather. + await run(probeParams(cwd, baseURL)).catch(() => undefined); + expect(uncachedCalls).toBe(1); + }, + ), + ); + }, 60_000); + + test("skipPricingSeed skips the seed read; without it the seed runs", async () => { + const cwd = await tmpSubAgentCwd("cl9010-run-seed-"); + const seedCalls: unknown[][] = []; + await runWithFailingInference((baseURL) => + withMockedModuleDuring( + import.meta.resolve("../cost/pricing-metadata.js"), + (real: typeof import("../cost/pricing-metadata.js")) => ({ + ...real, + seedPricingMetadataFromCache: ( + ...args: Parameters + ) => { + seedCalls.push(args); + return real.seedPricingMetadataFromCache(...args); + }, + }), + async () => { + const { runSubAgent: run } = await import("./run.js"); + await run(probeParams(cwd, baseURL)).catch(() => undefined); + expect(seedCalls.length).toBeGreaterThan(0); + seedCalls.length = 0; + await run(probeParams(cwd, baseURL, { skipPricingSeed: true })).catch( + () => undefined, + ); + expect(seedCalls).toEqual([]); + }, + ), + ); + }, 60_000); + + test("orchestrator lanes hand their catalog to nested dispatch", async () => { + const cwd = await tmpSubAgentCwd("cl9010-run-nested-"); + await writeSkillFile(join(cwd, "plugin"), "alpha", "Alpha skill."); + const seen: unknown[] = []; + await runWithFailingInference((baseURL) => + withMockedModuleDuring( + import.meta.resolve("./agent-fleet.js"), + (real: typeof import("./agent-fleet.js")) => ({ + ...real, + createSpawnAgentTool: ( + deps: Parameters[0], + ) => { + seen.push(deps); + return real.createSpawnAgentTool(deps); + }, + }), + async () => { + const { runSubAgent: run } = await import("./run.js"); + await run( + probeParams(cwd, baseURL, { + orchestrator: true, + orchestratorTier: "nested-orchestrator", + skillDirs: [join(cwd, "plugin")], + nestedDispatch: { + permissionGate: testPermissionGate, + getWorkdirBase: () => join(cwd, ".ctx"), + provider: { + providerName: "test", + baseURL, + model: "test-model", + }, + }, + }), + ).catch(() => undefined); + }, + ), + ); + expect(seen.length).toBeGreaterThan(0); + const nested = defined(seen[0]) as { + skillSnapshot?: { name: string }[]; + }; + expect(nested.skillSnapshot?.map((s) => s.name)).toContain("alpha"); + }, 30_000); +}); + +describe("CL-9010 spawn dispatch stays non-blocking", () => { + test("the handler returns while the worker run is still pending", async () => { + const gate = deferred(); + const deps = createFleetDeps(async (params) => { + params.onAgentReady?.({ + close: async () => undefined, + interrupt: () => undefined, + followup: async () => "", + deliver: () => undefined, + }); + return gate.promise; + }); + deps.skillSnapshot = [{ name: "style", description: "Style rules." }]; + const spawn = createSpawnAgentTool(deps); + const result = await callFleetTool(spawn, { + description: "cold start lane", + prompt: "do it", + intent: "explore", + }); + // The gate is still unresolved here: dispatch returned first. + expect(result.status).toBe("running"); + gate.resolve({ report: "done" }); + }); +}); diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 273c533fc..fe36b79c2 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -20,6 +20,7 @@ import type { TaskIntent } from "./report.js"; import type { SubagentTier } from "../agent/directors/types.js"; import type { ForcedStopReason } from "./stop-policy.js"; import type { AdmissionQueue } from "./admission.js"; +import type { SkillSummary } from "../extensions/skills.js"; export interface SubAgentProvider { providerName: string; @@ -60,6 +61,13 @@ export interface SubAgentSandboxDeps { * Workers resolve attached/optional skill bodies through these dirs. */ skillDirs?: readonly string[]; + /** + * CL-9010: the dispatcher's already-discovered skill catalog. A worker + * whose lane cwd matches the discovery cwd reuses it instead of + * rescanning; run.ts falls back to the cached discovery when unset (or + * when the lane runs in an isolated worktree with a different cwd). + */ + skillSnapshot?: readonly SkillSummary[]; } export type NestedDispatchDeps = SubAgentSandboxDeps & { @@ -167,6 +175,19 @@ export type RunSubAgentParams = { * resolve the same way they do on the primary. */ skillDirs?: readonly string[]; + /** + * CL-9010: pre-discovered skill catalog for this lane's cwd (the + * dispatcher's snapshot when the lane shares its cwd). Skips the worker's + * own discovery scan. Unset (or a worktree lane with a different cwd) + * falls back to the cached discovery. + */ + skills?: readonly SkillSummary[]; + /** + * CL-9010: skip the worker's pricing-cache seed read. Fleet-spawned + * workers reuse the process seed the parent runtime already applied at + * boot; the singleton no-op keeps this a pure file-read saving. + */ + skipPricingSeed?: boolean; systemPromptRole?: string; /** Resolved closed-director id (e.g. "critic") when the worker is one. Structured gate key — prefer over persona-string matching in systemPromptRole. */ directorId?: string;