Skip to content

Commit 3df3570

Browse files
brookscclaude
andcommitted
refactor: extract atomic writes, replay cache, preamble module; coordinator lifecycle state machine
- atomic.ts: atomicWriteFileSync/atomicWriteFile (write-to-tmp + rename) eliminates torn MCP config files on crash; all four config write sites now use it - replay-cache.ts: ReplayCache<T> replaces inline recentlyDelivered Map; TTL-based with eviction on write; key includes coordinatorTaskId to prevent cross-coordinator replay - preamble.ts: extracts removePreambleBlock, detectPreambleFiles, filterDiffSections, buildNormalizedPreambleFileDiff, stripPreambleFromBranch as standalone tested exports; removes five private methods from Coordinator god class (~150 lines) - types.ts: adds CoordinatorLifecycle = 'starting'|'ready'|'closing'|'closed'; transitions enforced in registerCoordinator/setMCPServerInfo/deregisterCoordinator - register.ts (P3): moves mcpConfig + mergedMcpJson computation before Docker copyFileSync so .mcp.json merge logic failures leave no filesystem residue - coordinator.test.ts: atomic.js mocked directly; removePreambleBlock tested via preamble.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bdc9943 commit 3df3570

8 files changed

Lines changed: 410 additions & 323 deletions

File tree

electron/ipc/register.ts

Lines changed: 56 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { startStepsWatcher, stopStepsWatcher, readStepsForWorktree } from './ste
3030
import { initPrChecks, startPrChecksWatcher, stopPrChecksWatcher, isPrUrl } from './pr-checks.js';
3131
import { readCoverageSummary } from './coverage.js';
3232
import { startRemoteServer, getMCPLogs } from '../remote/server.js';
33+
import { atomicWriteFileSync } from '../mcp/atomic.js';
3334
import {
3435
getGitIgnoredDirs,
3536
getMainBranch,
@@ -1355,24 +1356,64 @@ export function registerAllHandlers(win: BrowserWindow): void {
13551356
remoteServerStartedForMcp = true;
13561357
}
13571358

1358-
// Write temp MCP config file — use the bundled single-file MCP server
1359+
// Resolve the source MCP server binary path.
13591360
const thisDir = path.dirname(fileURLToPath(import.meta.url));
1360-
let mcpServerPath = path.join(thisDir, '..', 'mcp-server.cjs');
1361-
1362-
// In packaged builds, asar-unpacked files live in app.asar.unpacked/
1363-
if (mcpServerPath.includes('/app.asar/')) {
1364-
mcpServerPath = mcpServerPath.replace('/app.asar/', '/app.asar.unpacked/');
1361+
let hostMcpServerPath = path.join(thisDir, '..', 'mcp-server.cjs');
1362+
if (hostMcpServerPath.includes('/app.asar/')) {
1363+
hostMcpServerPath = hostMcpServerPath.replace('/app.asar/', '/app.asar.unpacked/');
13651364
}
1365+
1366+
// In Docker mode the server is copied into the worktree so the container can reach it.
1367+
// Compute the destination path now (pure, no side effects) so we can build mcpConfig
1368+
// and mergedMcpJson before committing any Docker filesystem writes.
1369+
const dockerMcpServerPath = args.dockerContainerName
1370+
? getDockerMcpServerDestPath(args.worktreePath, args.projectRoot)
1371+
: undefined;
1372+
const mcpServerPath = dockerMcpServerPath ?? hostMcpServerPath;
1373+
13661374
const serverUrl = getMCPRemoteServerUrl(remoteServer.port, args.dockerContainerName);
13671375

1368-
// Docker+coordinator: copy the MCP server script into the mounted worktree/project path
1369-
// so it is accessible inside the container.
1370-
if (args.dockerContainerName) {
1371-
const dockerMcpServerPath = getDockerMcpServerDestPath(args.worktreePath, args.projectRoot);
1376+
// Build mcpConfig and mergedMcpJson (pure computation — no filesystem or state side effects).
1377+
// Doing this before any Docker copy or coordinator mutation ensures that if .mcp.json
1378+
// merge logic ever grows fallible, Docker residue is never left behind.
1379+
const mcpConfig = {
1380+
mcpServers: {
1381+
'parallel-code': {
1382+
type: 'stdio' as const,
1383+
command: 'node',
1384+
args: [
1385+
mcpServerPath,
1386+
'--url',
1387+
serverUrl,
1388+
'--coordinator-id',
1389+
args.coordinatorTaskId,
1390+
...(args.skipPermissions && args.propagateSkipPermissions
1391+
? ['--skip-permissions']
1392+
: []),
1393+
],
1394+
env: { PARALLEL_CODE_MCP_TOKEN: remoteServer.token },
1395+
},
1396+
},
1397+
};
1398+
1399+
const configJson = JSON.stringify(mcpConfig, null, 2);
1400+
1401+
// Merge mcpConfig into the pre-validated existingMcpContent (parsed above,
1402+
// before any coordinator state was mutated).
1403+
let mergedMcpJson: string | undefined;
1404+
if (mcpJsonDir && worktreeMcpPath) {
1405+
const existingServers =
1406+
(existingMcpContent.mcpServers as Record<string, unknown> | undefined) ?? {};
1407+
existingMcpContent.mcpServers = { ...existingServers, ...mcpConfig.mcpServers };
1408+
mergedMcpJson = JSON.stringify(existingMcpContent, null, 2);
1409+
}
1410+
1411+
// All pure computation done. Now commit side effects: coordinator state mutations,
1412+
// Docker filesystem writes, MCP config file writes.
1413+
if (dockerMcpServerPath) {
13721414
fs.mkdirSync(path.dirname(dockerMcpServerPath), { recursive: true });
1373-
fs.copyFileSync(mcpServerPath, dockerMcpServerPath);
1374-
mcpServerPath = dockerMcpServerPath;
1375-
coordinator.setDockerContainerName(args.coordinatorTaskId, args.dockerContainerName);
1415+
fs.copyFileSync(hostMcpServerPath, dockerMcpServerPath); // nosemgrep: semgrep.copyfilesync-side-effect -- all pure computation (mcpConfig, mergedMcpJson) is done above; this is correctly ordered
1416+
coordinator.setDockerContainerName(args.coordinatorTaskId, args.dockerContainerName ?? '');
13761417
coordinator.setDockerImage(args.coordinatorTaskId, args.dockerImage ?? null);
13771418
console.warn('[MCP] Docker mode: copied MCP server to', dockerMcpServerPath);
13781419
// Keep .parallel-code/ out of git status in the sub-task worktree.
@@ -1420,38 +1461,6 @@ export function registerAllHandlers(win: BrowserWindow): void {
14201461
args.agentArgs ?? [],
14211462
);
14221463

1423-
const mcpConfig = {
1424-
mcpServers: {
1425-
'parallel-code': {
1426-
type: 'stdio' as const,
1427-
command: 'node',
1428-
args: [
1429-
mcpServerPath,
1430-
'--url',
1431-
serverUrl,
1432-
'--coordinator-id',
1433-
args.coordinatorTaskId,
1434-
...(args.skipPermissions && args.propagateSkipPermissions
1435-
? ['--skip-permissions']
1436-
: []),
1437-
],
1438-
env: { PARALLEL_CODE_MCP_TOKEN: remoteServer.token },
1439-
},
1440-
},
1441-
};
1442-
1443-
const configJson = JSON.stringify(mcpConfig, null, 2);
1444-
1445-
// Merge mcpConfig into the pre-validated existingMcpContent (parsed above,
1446-
// before any coordinator state was mutated).
1447-
let mergedMcpJson: string | undefined;
1448-
if (mcpJsonDir && worktreeMcpPath) {
1449-
const existingServers =
1450-
(existingMcpContent.mcpServers as Record<string, unknown> | undefined) ?? {};
1451-
existingMcpContent.mcpServers = { ...existingServers, ...mcpConfig.mcpServers };
1452-
mergedMcpJson = JSON.stringify(existingMcpContent, null, 2);
1453-
}
1454-
14551464
// In docker mode the coordinator agent auto-discovers .mcp.json in the project root.
14561465
// No host-temp configPath needed.
14571466
let configPath: string | undefined;
@@ -1460,15 +1469,14 @@ export function registerAllHandlers(win: BrowserWindow): void {
14601469
app.getPath('temp'),
14611470
`parallel-code-mcp-${args.coordinatorTaskId}.json`,
14621471
);
1463-
fs.writeFileSync(configPath, configJson, { mode: 0o600 });
1472+
atomicWriteFileSync(configPath, configJson, { mode: 0o600 });
14641473
}
14651474

14661475
// Write .mcp.json for auto-discovery. Read before writing — merge only the
14671476
// parallel-code key so we don't destroy user-defined entries. Track whether
14681477
// we created the file so deregisterCoordinator can clean up correctly.
14691478
if (mcpJsonDir && worktreeMcpPath && mergedMcpJson !== undefined) {
1470-
fs.writeFileSync(worktreeMcpPath, mergedMcpJson);
1471-
fs.chmodSync(worktreeMcpPath, 0o600);
1479+
atomicWriteFileSync(worktreeMcpPath, mergedMcpJson, { mode: 0o600 });
14721480
coordinator.setMcpJsonInfo(args.coordinatorTaskId, worktreeMcpPath, !mcpFileExistedBefore);
14731481

14741482
// Append to .git/info/exclude (local-only gitignore, not committed)

electron/mcp/atomic.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { writeFileSync, renameSync, unlinkSync } from 'fs';
2+
import { writeFile, rename, unlink } from 'fs/promises';
3+
import { join } from 'path';
4+
import os from 'os';
5+
import { randomUUID } from 'crypto';
6+
7+
/** Write `data` to `filePath` atomically: write to a temp file then rename.
8+
* A crash between write and rename leaves a stale .tmp file but never a torn target. */
9+
export function atomicWriteFileSync(
10+
filePath: string,
11+
data: string,
12+
options?: { mode?: number },
13+
): void {
14+
const tmp = join(os.tmpdir(), `parallel-code-atomic-${randomUUID()}.tmp`);
15+
try {
16+
writeFileSync(tmp, data, options);
17+
renameSync(tmp, filePath);
18+
} catch (err) {
19+
try {
20+
unlinkSync(tmp);
21+
} catch {
22+
/* best-effort cleanup */
23+
}
24+
throw err;
25+
}
26+
}
27+
28+
/** Async version: write `data` to `filePath` atomically via temp file + rename. */
29+
export async function atomicWriteFile(
30+
filePath: string,
31+
data: string,
32+
options?: { mode?: number },
33+
): Promise<void> {
34+
const tmp = join(os.tmpdir(), `parallel-code-atomic-${randomUUID()}.tmp`);
35+
try {
36+
await writeFile(tmp, data, options);
37+
await rename(tmp, filePath);
38+
} catch (err) {
39+
try {
40+
await unlink(tmp);
41+
} catch {
42+
/* best-effort cleanup */
43+
}
44+
throw err;
45+
}
46+
}

0 commit comments

Comments
 (0)