Skip to content

Commit bf88292

Browse files
Merge pull request #1291 from corbitsdev/cl-9010-spawn-cold-start
perf(subagent): reuse parent setup across spawns
2 parents 0a4273d + c0a0b20 commit bf88292

8 files changed

Lines changed: 626 additions & 5 deletions

File tree

‎src/agent/environment.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { spawn } from "node:child_process";
22
import { readdir } from "node:fs/promises";
33
import { arch, release, type as osType } from "node:os";
4+
import { resolve } from "node:path";
45

56
export interface EnvironmentInfo {
67
cwd: string;
@@ -107,6 +108,57 @@ async function gatherTopLevel(cwd: string): Promise<string | undefined> {
107108
}
108109
}
109110

111+
/** CL-9010: how long a cached environment snapshot stays fresh. */
112+
export const ENVIRONMENT_CACHE_TTL_MS = 5_000;
113+
114+
type EnvironmentCacheEntry = {
115+
at: number;
116+
value: EnvironmentInfo;
117+
};
118+
119+
// CL-9010: short-TTL environment cache keyed by resolved cwd. Spawned
120+
// workers share the dispatcher's git/top-level snapshot: the first spawn
121+
// pays the git calls and same-cwd spawns within the TTL reuse it, so
122+
// spawn_agent waves do not re-run `git status` per lane. Concurrent spawns
123+
// share one in-flight gather instead of stampeding git. Hits return a fresh
124+
// top-level copy with a current date so one worker cannot mutate another's
125+
// snapshot and the prompt never shows a stale clock.
126+
const environmentCache = new Map<string, EnvironmentCacheEntry>();
127+
const environmentInflight = new Map<string, Promise<EnvironmentInfo>>();
128+
129+
export async function gatherEnvironmentCached(
130+
cwd: string,
131+
date = new Date(),
132+
ttlMs = ENVIRONMENT_CACHE_TTL_MS,
133+
): Promise<EnvironmentInfo> {
134+
const key = resolve(cwd);
135+
const entry = environmentCache.get(key);
136+
if (entry !== undefined && Date.now() - entry.at < ttlMs) {
137+
return { ...entry.value, date };
138+
}
139+
const inflight = environmentInflight.get(key);
140+
if (inflight !== undefined) {
141+
return { ...(await inflight), date };
142+
}
143+
const gather = gatherEnvironment(cwd, date);
144+
environmentInflight.set(key, gather);
145+
try {
146+
const settled = await gather;
147+
environmentCache.set(key, { at: Date.now(), value: settled });
148+
return { ...settled, date };
149+
} finally {
150+
// A gather failure caches nothing — the next spawn retries.
151+
if (environmentInflight.get(key) === gather)
152+
environmentInflight.delete(key);
153+
}
154+
}
155+
156+
/** Test hook: drop every cached environment snapshot. */
157+
export function resetEnvironmentCacheForTests(): void {
158+
environmentCache.clear();
159+
environmentInflight.clear();
160+
}
161+
110162
export async function gatherEnvironment(
111163
cwd: string,
112164
date = new Date(),

‎src/agent/tools.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,10 @@ export async function createAgentToolset(
597597
? { secretGuardExtraDeniedPaths }
598598
: {}),
599599
...(skillDirs.length > 0 ? { skillDirs } : {}),
600+
// CL-9010: the parent's already-discovered catalog. Shared-cwd lanes
601+
// reuse it instead of rescanning; worktree lanes (different cwd)
602+
// ignore it and rediscover for their own directory.
603+
skillSnapshot: skills,
600604
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
601605
cwd,
602606
getWorkdirBase: sa.getWorkdirBase,

‎src/extensions/skills.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,41 @@ export async function discoverSkills(
205205
}
206206
return skills;
207207
}
208+
209+
// CL-9010: process-lifetime skill catalog cache keyed by resolved cwd +
210+
// plugin dirs. Spawned workers share the dispatcher's catalog: the first
211+
// same-cwd spawn pays discovery and later spawns reuse the snapshot, so
212+
// spawn_agent dispatch stays non-blocking on repeated waves. The fallback
213+
// base dirs are cwd-relative, so the key must include the cwd — different
214+
// dirs (or a different cwd) always rediscover. Callers get a copy; the
215+
// cached canonical is never handed out, so one worker cannot mutate
216+
// another's catalog. Unbounded in practice the same way the inference
217+
// singleton is: one entry per distinct (cwd, dirs) pair per process.
218+
const skillSnapshotCache = new Map<string, readonly SkillSummary[]>();
219+
220+
function skillSnapshotCacheKey(
221+
cwd: string,
222+
pluginDirs: readonly string[],
223+
): string {
224+
return `${resolve(cwd)}\0${pluginDirs.join("\0")}`;
225+
}
226+
227+
export async function discoverSkillsCached(
228+
cwd: string,
229+
pluginDirs: readonly string[] = [],
230+
): Promise<SkillSummary[]> {
231+
const key = skillSnapshotCacheKey(cwd, pluginDirs);
232+
const cached = skillSnapshotCache.get(key);
233+
if (cached !== undefined) return cached.map((skill) => ({ ...skill }));
234+
const fresh = await discoverSkills(cwd, [...pluginDirs]);
235+
skillSnapshotCache.set(
236+
key,
237+
fresh.map((skill) => ({ ...skill })),
238+
);
239+
return fresh;
240+
}
241+
242+
/** Test hook: drop every cached skill snapshot so suites start unpolluted. */
243+
export function resetSkillDiscoveryCacheForTests(): void {
244+
skillSnapshotCache.clear();
245+
}

‎src/session/assemble-runtime.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,17 @@ import {
138138
* best-effort in long-lived entry points; a one-shot runner passes onError to
139139
* log and continue instead. Without onError a seed failure rejects, matching
140140
* the previous TUI boot behavior.
141+
*
142+
* CL-9010: fleet-spawned workers pass skipPricingSeed — the parent runtime
143+
* already applied the process seed at boot (and the inference singleton is
144+
* a no-op on repeat resolves), so the worker skips the cache re-read.
141145
*/
142146
export async function assembleInferenceBase(
143147
onPricingError?: (err: unknown) => void,
148+
opts?: { skipPricingSeed?: boolean },
144149
): Promise<Awaited<ReturnType<typeof createInferenceDependencies>>> {
145150
const inferenceDeps = await createInferenceDependencies();
151+
if (opts?.skipPricingSeed === true) return inferenceDeps;
146152
const seed = seedPricingMetadataFromCache({
147153
cachePath: defaultPricingCachePath(),
148154
});

‎src/subagent/agent-fleet.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,16 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
15321532
...(deps.skillDirs !== undefined
15331533
? { skillDirs: deps.skillDirs }
15341534
: {}),
1535+
// CL-9010: shared-cwd lanes reuse the dispatcher's skill catalog
1536+
// instead of rescanning — the snapshot was discovered for this
1537+
// exact cwd. Worktree lanes run elsewhere, so they rediscover
1538+
// (through the cached discovery) for their own directory.
1539+
// Every fleet-spawned worker skips the pricing-cache seed read:
1540+
// the parent runtime already applied the process seed at boot.
1541+
...(deps.skillSnapshot !== undefined && worktreeCwd === undefined
1542+
? { skills: deps.skillSnapshot }
1543+
: {}),
1544+
skipPricingSeed: true,
15351545
...(resolved.systemPromptRole !== undefined
15361546
? { systemPromptRole: resolved.systemPromptRole }
15371547
: {}),

‎src/subagent/run.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ import {
8989
import { createShellCollectTool } from "../agent/background-shell-tool.js";
9090
import { createAttachmentRehydrateTransform } from "../session/attachment-store.js";
9191
import { tryReadPriorHandoffFile } from "../session/compaction-handoff.js";
92-
import { gatherEnvironment } from "../agent/environment.js";
92+
import { gatherEnvironmentCached } from "../agent/environment.js";
9393
import { generateSessionId } from "../session/index.js";
9494
import { consumeStream } from "../session/stream-consumer.js";
9595
import { createCycleTextRecorder } from "../session/stream-journal.js";
@@ -118,7 +118,7 @@ import {
118118
createUseSkillTool,
119119
workerUseSkillDefinition,
120120
} from "../agent/use-skill.js";
121-
import { discoverSkills } from "../extensions/skills.js";
121+
import { discoverSkillsCached } from "../extensions/skills.js";
122122
import { formatAttachedSkillConstraints } from "../agent/directors/attached-skills.js";
123123
import {
124124
createManageTasksRunner,
@@ -612,7 +612,11 @@ async function runSubAgentInner(
612612
telemetryRollup: SubAgentTelemetryRollup,
613613
settlementState: { latestModel: string },
614614
): Promise<RunSubAgentResult> {
615-
const inferenceDeps = await assembleInferenceBase();
615+
// CL-9010: fleet-spawned workers skip the pricing-cache seed re-read —
616+
// the parent runtime already applied the process seed at boot.
617+
const inferenceDeps = await assembleInferenceBase(undefined, {
618+
...(params.skipPricingSeed === true ? { skipPricingSeed: true } : {}),
619+
});
616620

617621
// CL-9475: the fleet session id the parent observes (params.id); falls
618622
// back to the local session id below when run without a fleet caller.
@@ -774,7 +778,13 @@ async function runSubAgentInner(
774778
orchestrator: params.orchestrator === true,
775779
});
776780
const skillDirs = [...(params.skillDirs ?? [])];
777-
const skillSnapshot = await discoverSkills(params.cwd, skillDirs);
781+
// CL-9010: reuse the dispatcher's catalog when the lane shares its cwd;
782+
// otherwise fall back to the cached discovery (same-cwd repeat spawns
783+
// skip the rescan). Copies keep one worker from mutating another's list.
784+
const skillSnapshot =
785+
params.skills !== undefined
786+
? [...params.skills]
787+
: await discoverSkillsCached(params.cwd, skillDirs);
778788
tools = [
779789
...tools,
780790
createSkillSearchTool({
@@ -975,6 +985,9 @@ async function runSubAgentInner(
975985
? { secretGuardExtraDeniedPaths: nd.secretGuardExtraDeniedPaths }
976986
: {}),
977987
...(nd.skillDirs !== undefined ? { skillDirs: nd.skillDirs } : {}),
988+
// CL-9010: nested shared-cwd lanes reuse this worker's catalog, which
989+
// was discovered (or inherited) for exactly this cwd.
990+
skillSnapshot,
978991
...(nd.extraToolPlugins !== undefined
979992
? { extraToolPlugins: nd.extraToolPlugins }
980993
: {}),
@@ -1059,7 +1072,9 @@ async function runSubAgentInner(
10591072
getContextDir: () => childContextDir,
10601073
});
10611074

1062-
const environment = await gatherEnvironment(params.cwd);
1075+
// CL-9010: same-cwd spawns within the TTL share the git/top-level
1076+
// snapshot instead of re-running git per lane.
1077+
const environment = await gatherEnvironmentCached(params.cwd);
10631078
const attachedSection =
10641079
params.attachedSkills !== undefined && params.attachedSkills.length > 0
10651080
? await formatAttachedSkillConstraints({

0 commit comments

Comments
 (0)