Skip to content

Commit cf18466

Browse files
authored
🤖 fix: persist runtimeConfig when forking local workspaces (#1863)
## Summary Fix local workspace fork creating corrupt worktree workspaces by persisting `runtimeConfig` in `config.addWorkspace()`, then simplify the codebase by removing redundant inline fallbacks. ## Background PR #1721 (commit 4238ff4) added fork support for local (project-dir) workspaces. The `LocalRuntime.forkWorkspace()` was correctly implemented to return the project path, and `workspaceService.fork()` correctly assembled the `FrontendWorkspaceMetadata` with `runtimeConfig: forkedRuntimeConfig`. However, when `config.addWorkspace()` stored the forked workspace entry, it **discarded the runtimeConfig field**: ```typescript const workspaceEntry: Workspace = { path: workspacePath, id: metadata.id, name: metadata.name, createdAt: metadata.createdAt, // runtimeConfig was missing! }; ``` On subsequent config loads, `getAllWorkspaceMetadata()` would see no `runtimeConfig` and apply `DEFAULT_RUNTIME_CONFIG` (type: "worktree" with srcBaseDir), causing the forked workspace to be treated as a worktree runtime. This created "corrupt worktree" errors because WorktreeRuntime operations would fail on directories that were never set up as git worktrees. ## Implementation ### Commit 1: Fix runtimeConfig persistence One-line fix: include `runtimeConfig: metadata.runtimeConfig` in the workspace entry when calling `addWorkspace()`. ### Commit 2: Remove redundant fallbacks The config layer already guarantees every `WorkspaceMetadata` has a `runtimeConfig` (via `DEFAULT_RUNTIME_CONFIG` applied in `getAllWorkspaceMetadata()`). This commit removes 13 inline `?? { type: "local", srcBaseDir }` fallbacks across 5 service files that were: - **Redundant**: The metadata type already includes runtimeConfig - **Inconsistent**: Inline fallbacks used `type: "local"` while the canonical default is `type: "worktree"` - **Bug-masking**: Silently substituting values instead of surfacing issues Files cleaned up: - `workspaceService.ts` (5 locations) - `agentSession.ts` (5 locations) - `aiService.ts`, `terminalService.ts`, `workspaceMcpOverridesService.ts` (1 each) ### Commit 3: Fix namedWorkspacePath persistence (Codex review feedback) `config.addWorkspace()` was hardcoding worktree-style paths (`~/.mux/src/...`) even for local workspaces. After app restart, this caused Open-in-Editor and path display to use the wrong directory. Fix: use `namedWorkspacePath` from the metadata if provided (which is computed runtime-aware by `Runtime.getWorkspacePath()`), falling back to worktree-style path only for legacy callers. ## Validation - Integration tests for fork persistence: - Creates local workspace → forks → verifies runtimeConfig survives reload - Verifies `namedWorkspacePath` is the project path (not `~/.mux/src/...`) after reload - `make typecheck` passes - `make static-check` passes - `TEST_INTEGRATION=1 bun x jest tests/ipc/forkWorkspace.test.ts` - all 9 tests pass --- _Generated with `mux` · Model: `anthropic:claude-sonnet-4-20250514`_ <!-- mux-attribution: model=anthropic:claude-sonnet-4-20250514 -->
1 parent 6a84858 commit cf18466

7 files changed

Lines changed: 101 additions & 73 deletions

File tree

src/node/config.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,10 @@ export class Config {
762762
* @param projectPath Absolute path to the project
763763
* @param metadata Workspace metadata to save
764764
*/
765-
async addWorkspace(projectPath: string, metadata: WorkspaceMetadata): Promise<void> {
765+
async addWorkspace(
766+
projectPath: string,
767+
metadata: WorkspaceMetadata & { namedWorkspacePath?: string }
768+
): Promise<void> {
766769
await this.editConfig((config) => {
767770
let project = config.projects.get(projectPath);
768771

@@ -774,15 +777,17 @@ export class Config {
774777
// Check if workspace already exists (by ID)
775778
const existingIndex = project.workspaces.findIndex((w) => w.id === metadata.id);
776779

777-
// Compute workspace path - this is only for legacy config migration
778-
// New code should use Runtime.getWorkspacePath() directly
780+
// Use provided namedWorkspacePath if available (runtime-aware),
781+
// otherwise fall back to worktree-style path for legacy compatibility
779782
const projectName = this.getProjectName(projectPath);
780-
const workspacePath = path.join(this.srcDir, projectName, metadata.name);
783+
const workspacePath =
784+
metadata.namedWorkspacePath ?? path.join(this.srcDir, projectName, metadata.name);
781785
const workspaceEntry: Workspace = {
782786
path: workspacePath,
783787
id: metadata.id,
784788
name: metadata.name,
785789
createdAt: metadata.createdAt,
790+
runtimeConfig: metadata.runtimeConfig,
786791
};
787792

788793
if (existingIndex >= 0) {

src/node/services/agentSession.ts

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,10 @@ export class AgentSession {
345345
const expectedPath = isInPlace
346346
? metadata.projectPath
347347
: (() => {
348-
const runtime = createRuntime(
349-
metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
350-
{ projectPath: metadata.projectPath, workspaceName: metadata.name }
351-
);
348+
const runtime = createRuntime(metadata.runtimeConfig, {
349+
projectPath: metadata.projectPath,
350+
workspaceName: metadata.name,
351+
});
352352
return runtime.getWorkspacePath(metadata.projectPath, metadata.name);
353353
})();
354354
assert(
@@ -1272,14 +1272,7 @@ export class AgentSession {
12721272

12731273
return attachments;
12741274
}
1275-
const runtime = createRuntimeForWorkspace({
1276-
runtimeConfig: metadataResult.data.runtimeConfig ?? {
1277-
type: "local",
1278-
srcBaseDir: this.config.srcDir,
1279-
},
1280-
projectPath: metadataResult.data.projectPath,
1281-
name: metadataResult.data.name,
1282-
});
1275+
const runtime = createRuntimeForWorkspace(metadataResult.data);
12831276

12841277
const attachments = await AttachmentService.generatePostCompactionAttachments(
12851278
metadataResult.data.name,
@@ -1343,14 +1336,7 @@ export class AgentSession {
13431336

13441337
return attachments;
13451338
}
1346-
const runtime = createRuntimeForWorkspace({
1347-
runtimeConfig: metadataResult.data.runtimeConfig ?? {
1348-
type: "local",
1349-
srcBaseDir: this.config.srcDir,
1350-
},
1351-
projectPath: metadataResult.data.projectPath,
1352-
name: metadataResult.data.name,
1353-
});
1339+
const runtime = createRuntimeForWorkspace(metadataResult.data);
13541340

13551341
const attachments = await AttachmentService.generatePostCompactionAttachments(
13561342
metadataResult.data.name,
@@ -1399,11 +1385,7 @@ export class AgentSession {
13991385
}
14001386

14011387
const metadata = metadataResult.data;
1402-
const runtime = createRuntimeForWorkspace({
1403-
runtimeConfig: metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
1404-
projectPath: metadata.projectPath,
1405-
name: metadata.name,
1406-
});
1388+
const runtime = createRuntimeForWorkspace(metadata);
14071389
const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name);
14081390

14091391
const materialized = await materializeFileAtMentions(messageText, {
@@ -1467,10 +1449,10 @@ export class AgentSession {
14671449
}
14681450

14691451
const metadata = metadataResult.data;
1470-
const runtime = createRuntime(
1471-
metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
1472-
{ projectPath: metadata.projectPath, workspaceName: metadata.name }
1473-
);
1452+
const runtime = createRuntime(metadata.runtimeConfig, {
1453+
projectPath: metadata.projectPath,
1454+
workspaceName: metadata.name,
1455+
});
14741456

14751457
// In-place workspaces (CLI/benchmarks) have projectPath === name.
14761458
// Use the path directly instead of reconstructing via getWorkspacePath.

src/node/services/aiService.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,10 +1159,10 @@ export class AIService extends EventEmitter {
11591159
}
11601160

11611161
// Get workspace path - handle both worktree and in-place modes
1162-
const runtime = createRuntime(
1163-
metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
1164-
{ projectPath: metadata.projectPath, workspaceName: metadata.name }
1165-
);
1162+
const runtime = createRuntime(metadata.runtimeConfig, {
1163+
projectPath: metadata.projectPath,
1164+
workspaceName: metadata.name,
1165+
});
11661166
// In-place workspaces (CLI/benchmarks) have projectPath === name
11671167
// Use path directly instead of reconstructing via getWorkspacePath
11681168
const isInPlace = metadata.projectPath === metadata.name;

src/node/services/terminalService.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ export class TerminalService {
8585
}
8686

8787
// 2. Create runtime (pass workspace info for Docker container name derivation)
88-
const runtime = createRuntime(
89-
workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
90-
{ projectPath: workspaceMetadata.projectPath, workspaceName: workspaceMetadata.name }
91-
);
88+
const runtime = createRuntime(workspaceMetadata.runtimeConfig, {
89+
projectPath: workspaceMetadata.projectPath,
90+
workspaceName: workspaceMetadata.name,
91+
});
9292

9393
// 3. Compute workspace path
9494
const workspacePath = runtime.getWorkspacePath(

src/node/services/workspaceMcpOverridesService.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,7 @@ export class WorkspaceMcpOverridesService {
166166
}> {
167167
const metadata = await this.getWorkspaceMetadata(workspaceId);
168168

169-
const runtime = createRuntimeForWorkspace({
170-
runtimeConfig: metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
171-
projectPath: metadata.projectPath,
172-
name: metadata.name,
173-
});
169+
const runtime = createRuntimeForWorkspace(metadata);
174170

175171
// In-place workspaces (CLI/benchmarks) store the workspace path directly by setting
176172
// metadata.projectPath === metadata.name.

src/node/services/workspaceService.ts

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -836,10 +836,10 @@ export class WorkspaceService extends EventEmitter {
836836
const metadata = metadataResult.data;
837837
const projectPath = metadata.projectPath;
838838

839-
const runtime = createRuntime(
840-
metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
841-
{ projectPath, workspaceName: metadata.name }
842-
);
839+
const runtime = createRuntime(metadata.runtimeConfig, {
840+
projectPath,
841+
workspaceName: metadata.name,
842+
});
843843

844844
// Delete workspace from runtime first - if this fails with force=false, we abort
845845
// and keep workspace in config so user can retry. This prevents orphaned directories.
@@ -1018,10 +1018,10 @@ export class WorkspaceService extends EventEmitter {
10181018
}
10191019
const { projectPath } = workspace;
10201020

1021-
const runtime = createRuntime(
1022-
oldMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
1023-
{ projectPath, workspaceName: oldName }
1024-
);
1021+
const runtime = createRuntime(oldMetadata.runtimeConfig, {
1022+
projectPath,
1023+
workspaceName: oldName,
1024+
});
10251025

10261026
const renameResult = await runtime.renameWorkspace(projectPath, oldName, newName);
10271027

@@ -1502,10 +1502,7 @@ export class WorkspaceService extends EventEmitter {
15021502
const foundProjectPath = sourceMetadata.projectPath;
15031503
const projectName = sourceMetadata.projectName;
15041504

1505-
const sourceRuntimeConfig = sourceMetadata.runtimeConfig ?? {
1506-
type: "local",
1507-
srcBaseDir: this.config.srcDir,
1508-
};
1505+
const sourceRuntimeConfig = sourceMetadata.runtimeConfig;
15091506

15101507
// Block fork for remote runtimes - creates broken workspaces
15111508
// Sub-agent task spawning uses a different code path (TaskService.create)
@@ -2348,16 +2345,7 @@ export class WorkspaceService extends EventEmitter {
23482345
return { paths: [] };
23492346
}
23502347

2351-
const runtimeConfig = metadata.runtimeConfig ?? {
2352-
type: "local" as const,
2353-
srcBaseDir: this.config.srcDir,
2354-
};
2355-
2356-
const runtime = createRuntimeForWorkspace({
2357-
runtimeConfig,
2358-
projectPath: metadata.projectPath,
2359-
name: metadata.name,
2360-
});
2348+
const runtime = createRuntimeForWorkspace(metadata);
23612349
const isInPlace = metadata.projectPath === metadata.name;
23622350
const workspacePath = isInPlace
23632351
? metadata.projectPath
@@ -2474,11 +2462,7 @@ export class WorkspaceService extends EventEmitter {
24742462
using tempDir = new DisposableTempDir("mux-ipc-bash");
24752463

24762464
// Create runtime and compute workspace path
2477-
const runtimeConfig = metadata.runtimeConfig ?? {
2478-
type: "local" as const,
2479-
srcBaseDir: this.config.srcDir,
2480-
};
2481-
const runtime = createRuntime(runtimeConfig, {
2465+
const runtime = createRuntime(metadata.runtimeConfig, {
24822466
projectPath: metadata.projectPath,
24832467
workspaceName: metadata.name,
24842468
});

tests/ipc/forkWorkspace.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,65 @@ describeIntegration("Workspace fork", () => {
502502
},
503503
15000
504504
);
505+
506+
test.concurrent(
507+
"should persist local runtimeConfig through config reload after fork",
508+
async () => {
509+
const env = await createTestEnvironment();
510+
const tempGitRepo = await createTempGitRepo();
511+
512+
try {
513+
// Create source workspace with LocalRuntime (project-dir mode)
514+
const localRuntimeConfig = { type: "local" as const };
515+
516+
const client = resolveOrpcClient(env);
517+
const createResult = await client.workspace.create({
518+
projectPath: tempGitRepo,
519+
branchName: "local-persist-test",
520+
trunkBranch: "main",
521+
runtimeConfig: localRuntimeConfig,
522+
});
523+
expect(createResult.success).toBe(true);
524+
if (!createResult.success) return;
525+
const sourceWorkspaceId = createResult.metadata.id;
526+
527+
// Fork the local workspace
528+
const forkResult = await client.workspace.fork({
529+
sourceWorkspaceId,
530+
newName: "local-persist-forked",
531+
});
532+
expect(forkResult.success).toBe(true);
533+
if (!forkResult.success) return;
534+
const forkedWorkspaceId = forkResult.metadata.id;
535+
536+
// Verify forked workspace has local runtimeConfig immediately
537+
expect(forkResult.metadata.runtimeConfig.type).toBe("local");
538+
expect("srcBaseDir" in forkResult.metadata.runtimeConfig).toBe(false);
539+
540+
// BUG REPRO: Reload config and verify runtimeConfig persisted correctly
541+
// This simulates what happens after app restart or when getAllWorkspaceMetadata is called
542+
const workspaces = await client.workspace.list();
543+
const forkedWorkspace = workspaces.find((w: { id: string }) => w.id === forkedWorkspaceId);
544+
545+
// This is the critical assertion that would fail before the fix:
546+
// After reload, the workspace should still have type: "local" without srcBaseDir
547+
expect(forkedWorkspace).toBeDefined();
548+
expect(forkedWorkspace!.runtimeConfig.type).toBe("local");
549+
expect("srcBaseDir" in forkedWorkspace!.runtimeConfig).toBe(false);
550+
551+
// Verify namedWorkspacePath is the project path (not ~/.mux/src/...) for local workspaces
552+
// This ensures Open-in-Editor and path display work correctly after reload
553+
expect(forkedWorkspace!.namedWorkspacePath).toBe(tempGitRepo);
554+
expect(forkResult.metadata.namedWorkspacePath).toBe(tempGitRepo);
555+
556+
// Cleanup
557+
await client.workspace.remove({ workspaceId: sourceWorkspaceId });
558+
await client.workspace.remove({ workspaceId: forkedWorkspaceId });
559+
} finally {
560+
await cleanupTestEnvironment(env);
561+
await cleanupTempGitRepo(tempGitRepo);
562+
}
563+
},
564+
15000
565+
);
505566
});

0 commit comments

Comments
 (0)