Skip to content

Commit 33fcc03

Browse files
aaightCascade Botclaude
authored
feat(worker): refresh snapshot workspace instead of recloning on reuse (#1047)
* feat(worker): refresh snapshot workspace instead of recloning on reuse * fix(repository): tighten findSnapshotWorkspaceDir to require numeric timestamp suffix Prevents false-positive matches when one project ID is a prefix of another (e.g. project "foo" matching directory "cascade-foo-bar-<timestamp>"). Now verifies the suffix after the project-ID prefix is all digits, matching only directories created by createTempDir (cascade-<projectId>-<Date.now()>). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Cascade Bot <bot@cascade.dev> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 414fbce commit 33fcc03

5 files changed

Lines changed: 471 additions & 19 deletions

File tree

src/agents/shared/repository.ts

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { existsSync } from 'node:fs';
1+
import { existsSync, readdirSync } from 'node:fs';
22
import { join } from 'node:path';
33

44
import type { ProjectConfig } from '../../types/index.js';
5-
import { cloneRepo, createTempDir, runCommand } from '../../utils/repo.js';
5+
import { cloneRepo, createTempDir, getWorkspaceDir, runCommand } from '../../utils/repo.js';
66
import type { AgentLogger } from '../utils/logging.js';
77
import { warmTypeScriptCache } from '../utils/setup.js';
88

@@ -14,9 +14,128 @@ export interface SetupRepositoryOptions {
1414
warmTsCache?: boolean;
1515
}
1616

17+
/**
18+
* Resolve the path to the existing workspace directory for a snapshot-reuse run.
19+
*
20+
* Snapshot images bake the workspace into `/workspace/cascade-<projectId>-*`.
21+
* We locate the first matching directory rather than creating a new one so the
22+
* snapshot-resident installation artifacts are used as-is.
23+
*
24+
* Returns `null` when no baked-in workspace directory can be found.
25+
*/
26+
export function findSnapshotWorkspaceDir(projectId: string): string | null {
27+
const workspaceBase = getWorkspaceDir();
28+
const prefix = `cascade-${projectId}-`;
29+
try {
30+
const entries = readdirSync(workspaceBase);
31+
const match = entries.find((e) => {
32+
if (!e.startsWith(prefix)) return false;
33+
const suffix = e.slice(prefix.length);
34+
return /^\d+$/.test(suffix);
35+
});
36+
return match ? `${workspaceBase}/${match}` : null;
37+
} catch {
38+
return null;
39+
}
40+
}
41+
42+
/**
43+
* Refresh an existing snapshot workspace via git fetch + reset + optional branch checkout.
44+
*
45+
* This is the "warm-start" path: the Docker image already contains the cloned repo,
46+
* installed dependencies, and any setup artifacts from the previous run. We only
47+
* need to bring the working tree up to date with the remote.
48+
*/
49+
async function refreshSnapshotWorkspace(
50+
repoDir: string,
51+
project: ProjectConfig,
52+
log: AgentLogger,
53+
agentType: string,
54+
prBranch?: string,
55+
): Promise<void> {
56+
const branch = prBranch ?? project.baseBranch ?? 'main';
57+
58+
log.info('Refreshing snapshot workspace', { repoDir, branch, agentType });
59+
60+
// Fetch latest refs from origin (tolerates transient network errors)
61+
const fetchResult = await runCommand('git', ['fetch', 'origin'], repoDir);
62+
if (fetchResult.exitCode !== 0) {
63+
log.warn('git fetch exited with non-zero code (continuing)', {
64+
exitCode: fetchResult.exitCode,
65+
stderr: fetchResult.stderr.slice(-500),
66+
});
67+
}
68+
69+
// Reset to the remote tracking branch to discard any stale local changes
70+
const resetResult = await runCommand('git', ['reset', '--hard', `origin/${branch}`], repoDir);
71+
if (resetResult.exitCode !== 0) {
72+
log.warn('git reset --hard exited with non-zero code (continuing)', {
73+
exitCode: resetResult.exitCode,
74+
stderr: resetResult.stderr.slice(-500),
75+
});
76+
}
77+
78+
// Checkout the target branch (no-op when already on the correct branch)
79+
const checkoutResult = await runCommand('git', ['checkout', branch], repoDir);
80+
if (checkoutResult.exitCode !== 0) {
81+
log.warn('git checkout exited with non-zero code (continuing)', {
82+
exitCode: checkoutResult.exitCode,
83+
stderr: checkoutResult.stderr.slice(-500),
84+
});
85+
}
86+
87+
log.info('Snapshot workspace refreshed', { repoDir, branch });
88+
}
89+
90+
/**
91+
* Warm the TypeScript compiler cache for a repo directory, logging the result.
92+
* Extracted to keep setupRepository within the cognitive-complexity limit.
93+
*/
94+
async function maybeWarmTsCache(
95+
repoDir: string,
96+
warmTsCache: boolean | undefined,
97+
log: AgentLogger,
98+
): Promise<void> {
99+
if (!warmTsCache) return;
100+
log.info('Warming TypeScript cache', { repoDir });
101+
const tscResult = await warmTypeScriptCache(repoDir);
102+
if (tscResult) {
103+
log.info('TypeScript cache warmed', {
104+
durationMs: tscResult.durationMs,
105+
hadErrors: !!tscResult.error,
106+
});
107+
}
108+
}
109+
17110
export async function setupRepository(options: SetupRepositoryOptions): Promise<string> {
18111
const { project, log, agentType, prBranch, warmTsCache } = options;
19112

113+
// ── Snapshot-reuse path ────────────────────────────────────────────────────
114+
// When CASCADE_SNAPSHOT_REUSE=true the container image already contains the
115+
// repo, dependencies, and prior setup work. Locate the baked-in workspace
116+
// and refresh it with fetch/reset semantics instead of cloning from scratch.
117+
if (process.env.CASCADE_SNAPSHOT_REUSE === 'true' && project.repo) {
118+
const snapshotDir = findSnapshotWorkspaceDir(project.id);
119+
if (snapshotDir) {
120+
log.info('Snapshot reuse detected — skipping clone', {
121+
projectId: project.id,
122+
agentType,
123+
snapshotDir,
124+
});
125+
await refreshSnapshotWorkspace(snapshotDir, project, log, agentType, prBranch);
126+
await maybeWarmTsCache(snapshotDir, warmTsCache, log);
127+
return snapshotDir;
128+
}
129+
130+
// Snapshot directory not found — fall through to cold-start clone
131+
log.warn('Snapshot reuse requested but no workspace directory found — falling back to clone', {
132+
projectId: project.id,
133+
agentType,
134+
});
135+
}
136+
137+
// ── Cold-start path (clone) ────────────────────────────────────────────────
138+
20139
// Create temp directory for all agents
21140
const repoDir = createTempDir(project.id);
22141

@@ -52,17 +171,7 @@ export async function setupRepository(options: SetupRepositoryOptions): Promise<
52171
}
53172
}
54173

55-
// Warm TypeScript cache to avoid slow first-run compilation during agent execution
56-
if (warmTsCache) {
57-
log.info('Warming TypeScript cache', { repoDir });
58-
const tscResult = await warmTypeScriptCache(repoDir);
59-
if (tscResult) {
60-
log.info('TypeScript cache warmed', {
61-
durationMs: tscResult.durationMs,
62-
hadErrors: !!tscResult.error,
63-
});
64-
}
65-
}
174+
await maybeWarmTsCache(repoDir, warmTsCache, log);
66175

67176
return repoDir;
68177
}

src/router/container-manager.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,6 @@ export async function spawnWorker(job: Job<CascadeJob>): Promise<void> {
199199

200200
// Resolve projectId once — used for both credential env and work-item lock tracking
201201
const projectId = await extractProjectIdFromJob(job.data);
202-
const workerEnv = await buildWorkerEnvWithProjectId(job, projectId);
203-
const hasCredentials = workerEnv.some((e) => e.startsWith('CASCADE_CREDENTIAL_KEYS='));
204202

205203
// Extract agentType early so it can be included in container labels
206204
// (needed by orphan cleanup to narrow DB fallback queries to the right agent type)
@@ -214,6 +212,12 @@ export async function spawnWorker(job: Job<CascadeJob>): Promise<void> {
214212
jobId,
215213
);
216214

215+
// A snapshot is being reused when snapshotEnabled and the image differs from the base image.
216+
const snapshotReuse = snapshotEnabled && workerImage !== routerConfig.workerImage;
217+
218+
const workerEnv = await buildWorkerEnvWithProjectId(job, projectId, snapshotReuse);
219+
const hasCredentials = workerEnv.some((e) => e.startsWith('CASCADE_CREDENTIAL_KEYS='));
220+
217221
logger.info('[WorkerManager] Spawning worker:', {
218222
jobId,
219223
type: job.data.type,

src/router/worker-env.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,14 @@ function appendOptionalEnvVars(env: string[]): void {
7777
/**
7878
* Build environment variables for a worker container with a pre-resolved projectId.
7979
* @internal Used by container-manager.ts to avoid resolving projectId twice.
80+
*
81+
* @param snapshotReuse - When true, injects CASCADE_SNAPSHOT_REUSE=true so the
82+
* worker knows to refresh an existing workspace instead of cloning from scratch.
8083
*/
8184
export async function buildWorkerEnvWithProjectId(
8285
job: Job<CascadeJob>,
8386
projectId: string | null,
87+
snapshotReuse = false,
8488
): Promise<string[]> {
8589
const env: string[] = [
8690
`JOB_ID=${job.id}`,
@@ -97,6 +101,11 @@ export async function buildWorkerEnvWithProjectId(
97101
`LOG_LEVEL=${process.env.LOG_LEVEL || 'info'}`,
98102
];
99103

104+
// Signal snapshot reuse so the worker skips redundant setup (clone, install).
105+
if (snapshotReuse) {
106+
env.push('CASCADE_SNAPSHOT_REUSE=true');
107+
}
108+
100109
// Resolve project credentials in the router and set as individual env vars.
101110
// NOTE: CREDENTIAL_MASTER_KEY is intentionally NOT passed to workers.
102111
if (projectId) {

0 commit comments

Comments
 (0)