Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/agent/environment.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -107,6 +108,57 @@ async function gatherTopLevel(cwd: string): Promise<string | undefined> {
}
}

/** 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<string, EnvironmentCacheEntry>();
const environmentInflight = new Map<string, Promise<EnvironmentInfo>>();

export async function gatherEnvironmentCached(
cwd: string,
date = new Date(),
ttlMs = ENVIRONMENT_CACHE_TTL_MS,
): Promise<EnvironmentInfo> {
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(),
Expand Down
4 changes: 4 additions & 0 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions src/extensions/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, readonly SkillSummary[]>();

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<SkillSummary[]> {
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();
}
6 changes: 6 additions & 0 deletions src/session/assemble-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Awaited<ReturnType<typeof createInferenceDependencies>>> {
const inferenceDeps = await createInferenceDependencies();
if (opts?.skipPricingSeed === true) return inferenceDeps;
const seed = seedPricingMetadataFromCache({
cachePath: defaultPricingCachePath(),
});
Expand Down
10 changes: 10 additions & 0 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
Expand Down
25 changes: 20 additions & 5 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -612,7 +612,11 @@ async function runSubAgentInner(
telemetryRollup: SubAgentTelemetryRollup,
settlementState: { latestModel: string },
): Promise<RunSubAgentResult> {
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.
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 }
: {}),
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading