Skip to content

Commit 97e7215

Browse files
committed
🤖 fix: reuse cached ssh layouts across runtime instances
1 parent 9cdda18 commit 97e7215

5 files changed

Lines changed: 72 additions & 16 deletions

File tree

src/node/runtime/SSHRuntime.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test";
44
import * as runtimeHelpers from "@/node/utils/runtime/helpers";
55
import * as disposableExec from "@/node/utils/disposableExec";
66
import * as submoduleSync from "./submoduleSync";
7-
import { SSHRuntime, computeBaseRepoPath } from "./SSHRuntime";
7+
import { SSHRuntime, clearSharedProjectLayoutCache, computeBaseRepoPath } from "./SSHRuntime";
88
import {
99
buildLegacyRemoteProjectLayout,
1010
buildRemoteProjectLayout,
@@ -35,6 +35,10 @@ function createMockExecResult(
3535
} as unknown as ReturnType<typeof disposableExec.execFileAsync>;
3636
}
3737

38+
afterEach(() => {
39+
clearSharedProjectLayoutCache();
40+
});
41+
3842
describe("SSHRuntime constructor", () => {
3943
it("should accept tilde in srcBaseDir", () => {
4044
// Tildes are now allowed - they will be resolved via resolvePath()
@@ -1205,6 +1209,23 @@ describe("SSHRuntime layout detection", () => {
12051209
expect(detectionCommand).toContain(`test -e "${legacyLayout.projectRoot}/${workspaceName}"`);
12061210
expect(detectionCommand).not.toContain(`test -d "${legacyLayout.projectRoot}"`);
12071211
});
1212+
it("reuses a cached legacy layout for fresh runtimes without workspacePath hints", () => {
1213+
const config = { host: "example.com", srcBaseDir: "/home/user/src" };
1214+
const projectPath = "/projects/cached-legacy-demo";
1215+
const workspaceName = "legacy-slot";
1216+
const legacyLayout = buildLegacyRemoteProjectLayout(config.srcBaseDir, projectPath);
1217+
const legacyWorkspacePath = getRemoteWorkspacePath(legacyLayout, workspaceName);
1218+
1219+
new SSHRuntime(config, createSSHTransport(config, false), {
1220+
projectPath,
1221+
workspaceName,
1222+
workspacePath: legacyWorkspacePath,
1223+
});
1224+
1225+
const freshRuntime = new SSHRuntime(config, createSSHTransport(config, false));
1226+
1227+
expect(freshRuntime.getWorkspacePath(projectPath, workspaceName)).toBe(legacyWorkspacePath);
1228+
});
12081229
});
12091230

12101231
describe("computeBaseRepoPath", () => {

src/node/runtime/SSHRuntime.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ const BUNDLE_REF_PREFIX = "refs/mux-bundle/";
6262
/** Small backoff for concurrent writers healing the same shared base repo config. */
6363
const BASE_REPO_CONFIG_LOCK_RETRY_DELAYS_MS = [50, 100, 200];
6464

65+
const sharedProjectLayouts = new Map<string, RemoteProjectLayout>();
66+
67+
function getProjectLayoutCacheKey(config: SSHRuntimeConfig, projectPath: string): string {
68+
return [
69+
config.host,
70+
config.port?.toString() ?? "22",
71+
config.identityFile ?? "default",
72+
config.srcBaseDir,
73+
projectPath,
74+
].join(":");
75+
}
76+
6577
function isGitConfigLockConflict(message: string): boolean {
6678
return /could not lock config file/i.test(message);
6779
}
@@ -166,6 +178,10 @@ export function computeBaseRepoPath(srcBaseDir: string, projectPath: string): st
166178
*
167179
* Extends RemoteRuntime for shared exec/file operations.
168180
*/
181+
export function clearSharedProjectLayoutCache(): void {
182+
sharedProjectLayouts.clear();
183+
}
184+
169185
export class SSHRuntime extends RemoteRuntime {
170186
private readonly config: SSHRuntimeConfig;
171187
private readonly transport: SSHTransport;
@@ -195,7 +211,7 @@ export class SSHRuntime extends RemoteRuntime {
195211
this.currentWorkspacePath = options?.workspacePath;
196212

197213
if (options?.projectPath && options.workspacePath) {
198-
this.projectLayouts.set(
214+
this.cacheProjectLayout(
199215
options.projectPath,
200216
buildRemoteProjectLayout(
201217
this.config.srcBaseDir,
@@ -250,15 +266,31 @@ export class SSHRuntime extends RemoteRuntime {
250266
return buildRemoteProjectLayout(this.config.srcBaseDir, projectPath);
251267
}
252268

269+
private getCachedProjectLayout(projectPath: string): RemoteProjectLayout | undefined {
270+
return (
271+
this.projectLayouts.get(projectPath) ??
272+
sharedProjectLayouts.get(getProjectLayoutCacheKey(this.config, projectPath))
273+
);
274+
}
275+
276+
private cacheProjectLayout(
277+
projectPath: string,
278+
layout: RemoteProjectLayout
279+
): RemoteProjectLayout {
280+
this.projectLayouts.set(projectPath, layout);
281+
sharedProjectLayouts.set(getProjectLayoutCacheKey(this.config, projectPath), layout);
282+
return layout;
283+
}
284+
253285
private getPreferredProjectLayout(projectPath: string): RemoteProjectLayout {
254-
return this.projectLayouts.get(projectPath) ?? this.getDefaultProjectLayout(projectPath);
286+
return this.getCachedProjectLayout(projectPath) ?? this.getDefaultProjectLayout(projectPath);
255287
}
256288

257289
private async resolveProjectLayout(
258290
projectPath: string,
259291
workspaceName?: string
260292
): Promise<RemoteProjectLayout> {
261-
const cached = this.projectLayouts.get(projectPath);
293+
const cached = this.getCachedProjectLayout(projectPath);
262294
if (cached) {
263295
return cached;
264296
}
@@ -286,11 +318,9 @@ export class SSHRuntime extends RemoteRuntime {
286318
timeout: 10,
287319
});
288320
const layout = detection.stdout.trim() === "legacy" ? legacyLayout : preferredLayout;
289-
this.projectLayouts.set(projectPath, layout);
290-
return layout;
321+
return this.cacheProjectLayout(projectPath, layout);
291322
} catch {
292-
this.projectLayouts.set(projectPath, preferredLayout);
293-
return preferredLayout;
323+
return this.cacheProjectLayout(projectPath, preferredLayout);
294324
}
295325
}
296326

@@ -435,12 +465,7 @@ export class SSHRuntime extends RemoteRuntime {
435465
return this.currentWorkspacePath;
436466
}
437467

438-
const cachedLayout = this.projectLayouts.get(projectPath);
439-
if (cachedLayout) {
440-
return getRemoteWorkspacePath(cachedLayout, workspaceName);
441-
}
442-
443-
return getRemoteWorkspacePath(this.getDefaultProjectLayout(projectPath), workspaceName);
468+
return getRemoteWorkspacePath(this.getPreferredProjectLayout(projectPath), workspaceName);
444469
}
445470

446471
/**

src/node/services/utils/forkOrchestrator.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ describe("orchestrateFork", () => {
153153
expect(createRuntimeMock).toHaveBeenCalledWith(DEFAULT_FORKED_RUNTIME_CONFIG, {
154154
projectPath: PROJECT_PATH,
155155
workspaceName: NEW_WORKSPACE_NAME,
156+
workspacePath: "/workspaces/forked",
156157
});
157158
});
158159

@@ -201,6 +202,7 @@ describe("orchestrateFork", () => {
201202
expect(createRuntimeMock).toHaveBeenCalledWith(DEFAULT_FORKED_RUNTIME_CONFIG, {
202203
projectPath: PROJECT_PATH,
203204
workspaceName: NEW_WORKSPACE_NAME,
205+
workspacePath: "/workspaces/created",
204206
});
205207
});
206208

@@ -409,6 +411,7 @@ describe("orchestrateFork", () => {
409411
expect(createRuntimeMock).toHaveBeenCalledWith(customForkedRuntimeConfig, {
410412
projectPath: PROJECT_PATH,
411413
workspaceName: NEW_WORKSPACE_NAME,
414+
workspacePath: "/workspaces/created-with-custom-runtime",
412415
});
413416
});
414417

@@ -451,10 +454,14 @@ describe("orchestrateFork", () => {
451454
expect.objectContaining({ containerName: "mux-demo-source-aaaaaa" })
452455
);
453456

454-
// createRuntime should also receive the normalized config
457+
// createRuntime should also receive the normalized config and the created workspace path.
455458
expect(createRuntimeMock).toHaveBeenCalledWith(
456459
expect.objectContaining({ containerName: expectedContainerName }),
457-
{ projectPath: PROJECT_PATH, workspaceName: NEW_WORKSPACE_NAME }
460+
{
461+
projectPath: PROJECT_PATH,
462+
workspaceName: NEW_WORKSPACE_NAME,
463+
workspacePath: "/workspaces/new",
464+
}
458465
);
459466
});
460467
it("returns Err when create fallback also fails", async () => {

src/node/services/utils/forkOrchestrator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ export async function orchestrateFork(
577577
const targetRuntime = createRuntime(normalizedForkedRuntimeConfig, {
578578
projectPath,
579579
workspaceName: newWorkspaceName,
580+
workspacePath,
580581
});
581582

582583
return Ok({

src/node/services/workspaceService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5168,9 +5168,11 @@ export class WorkspaceService extends EventEmitter {
51685168
// Copy plan file using explicit source/target runtimes for cross-runtime safety.
51695169
// Create a fresh source runtime handle because DockerRuntime.forkWorkspace() can
51705170
// mutate the original runtime's container identity to target the new workspace.
5171+
const sourceWorkspace = this.config.findWorkspace(sourceWorkspaceId);
51715172
const freshSourceRuntime = createRuntime(sourceRuntimeConfig, {
51725173
projectPath: foundProjectPath,
51735174
workspaceName: sourceMetadata.name,
5175+
workspacePath: sourceWorkspace?.workspacePath,
51745176
});
51755177
await copyPlanFileAcrossRuntimes(
51765178
freshSourceRuntime,

0 commit comments

Comments
 (0)