Skip to content

Commit f25dc86

Browse files
committed
feat(coordinator): Docker sub-tasks each run in their own container (johannesjo#31)
1 parent 0579d57 commit f25dc86

7 files changed

Lines changed: 305 additions & 185 deletions

File tree

electron/ipc/register.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ export function validateStartMCPServerArgs(args: Record<string, unknown>): void
178178
throw new Error('dockerContainerName contains invalid characters');
179179
}
180180
}
181+
if (args.dockerImage !== undefined) {
182+
assertString(args.dockerImage, 'dockerImage');
183+
if (!(args.dockerImage as string).trim()) {
184+
throw new Error('dockerImage must not be blank');
185+
}
186+
}
181187
}
182188

183189
/** Reject relative paths that attempt directory traversal or are absolute. */
@@ -1281,6 +1287,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
12811287
agentCommand?: string;
12821288
agentArgs?: string[];
12831289
dockerContainerName?: string;
1290+
dockerImage?: string;
12841291
},
12851292
) => {
12861293
validateStartMCPServerArgs(args as unknown as Record<string, unknown>);
@@ -1335,6 +1342,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
13351342
fs.copyFileSync(mcpServerPath, dockerMcpServerPath);
13361343
mcpServerPath = dockerMcpServerPath;
13371344
coordinator.setDockerContainerName(args.coordinatorTaskId, args.dockerContainerName);
1345+
coordinator.setDockerImage(args.coordinatorTaskId, args.dockerImage ?? null);
13381346
console.warn('[MCP] Docker mode: copied MCP server to', dockerMcpServerPath);
13391347
// Keep .parallel-code/ out of git status in the sub-task worktree.
13401348
// Use .git/info/exclude (local-only, never committed) to avoid dirtying

electron/mcp/coordinator.test.ts

Lines changed: 186 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -541,40 +541,45 @@ describe('Coordinator sub-agent spawn settings', () => {
541541
);
542542
});
543543

544-
it('uses docker exec with -w flag when dockerContainerName is set', async () => {
544+
it('uses docker run (dockerMode: true) when dockerContainerName is set — sub-task gets its own container', async () => {
545545
coordinator.setDockerContainerName('coord-1', 'my-container');
546546
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
547547
expect(mockSpawnAgent).toHaveBeenCalledWith(
548548
expect.anything(),
549549
expect.objectContaining({
550-
command: 'docker',
551-
args: expect.arrayContaining(['exec', '-it', '-w', '/tmp/test', 'my-container', 'claude']),
550+
// Sub-task uses docker run (dockerMode: true), not docker exec
551+
dockerMode: true,
552+
// Command is the agent command, not 'docker'
553+
command: 'claude',
554+
// Args are the agent args (not docker exec wrapper)
555+
args: expect.not.arrayContaining(['exec']),
552556
}),
553557
);
558+
// Coordinator container name is NOT in the args (sub-task has its own container)
559+
const spawnArgs = mockSpawnAgent.mock.calls[0][1].args as string[];
560+
expect(spawnArgs).not.toContain('my-container');
554561
});
555562

556-
it('does not use docker exec when dockerContainerName is null', async () => {
563+
it('does not use docker mode when dockerContainerName is null', async () => {
557564
coordinator.setDockerContainerName('coord-1', null);
558565
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
559566
expect(mockSpawnAgent).toHaveBeenCalledWith(
560567
expect.anything(),
561568
expect.objectContaining({ command: 'claude' }),
562569
);
563-
const spawnArgs = mockSpawnAgent.mock.calls[0][1].args as string[];
564-
expect(spawnArgs).not.toContain('docker');
570+
const spawnCall = mockSpawnAgent.mock.calls[0][1] as { dockerMode?: boolean; args: string[] };
571+
expect(spawnCall.dockerMode).toBeUndefined();
572+
expect(spawnCall.args).not.toContain('docker');
565573
});
566574

567-
it('docker exec -w uses the sub-task worktree path, not the coordinator projectRoot', async () => {
575+
it('docker run cwd is the sub-task worktree path, not the coordinator projectRoot', async () => {
568576
coordinator.setDockerContainerName('coord-1', 'my-container');
569577
// coordinator projectRoot is '/tmp/project', sub-task worktree is '/tmp/test'
570578
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
571-
const spawnArgs = mockSpawnAgent.mock.calls[0][1].args as string[];
572-
const wIdx = spawnArgs.indexOf('-w');
573-
expect(wIdx).toBeGreaterThan(0);
574-
const wValue = spawnArgs[wIdx + 1];
575-
// Must be the sub-task worktree path (/tmp/test), not the coordinator's projectRoot (/tmp/project)
576-
expect(wValue).toBe('/tmp/test');
577-
expect(wValue).not.toBe('/tmp/project');
579+
// cwd (passed to pty.ts) is the sub-task worktree, not the coordinator's projectRoot
580+
const spawnCall = mockSpawnAgent.mock.calls[0][1] as { cwd: string };
581+
expect(spawnCall.cwd).toBe('/tmp/test');
582+
expect(spawnCall.cwd).not.toBe('/tmp/project');
578583
});
579584
});
580585

@@ -1877,22 +1882,11 @@ describe('Coordinator cleanupTask — failure resilience', () => {
18771882

18781883
// ─── Docker cleanup sequencing ────────────────────────────────────────────────
18791884

1880-
describe('Coordinator cleanupTask — Docker inner-process kill sequencing', () => {
1885+
describe('Coordinator cleanupTask — Docker sub-task container stop', () => {
18811886
let coordinator: InstanceType<typeof Coordinator>;
18821887

18831888
beforeEach(() => {
18841889
vi.clearAllMocks();
1885-
// Default: execFile calls its callback synchronously (success)
1886-
mockExecFile.mockImplementation(
1887-
(
1888-
_cmd: string,
1889-
_args: string[],
1890-
_opts: unknown,
1891-
cb: (err: Error | null, stdout: string, stderr: string) => void,
1892-
) => {
1893-
cb(null, '', '');
1894-
},
1895-
);
18961890
mockExistsSync.mockReturnValue(false);
18971891
mockCreateBackendTask.mockResolvedValue({
18981892
id: 'task-1',
@@ -1904,53 +1898,45 @@ describe('Coordinator cleanupTask — Docker inner-process kill sequencing', ()
19041898
coordinator.setDefaultProject('proj-1', '/tmp/project');
19051899
coordinator.registerCoordinator('coord-1', 'proj-1');
19061900
coordinator.setDockerContainerName('coord-1', 'my-coord-container');
1901+
coordinator.setDockerImage('coord-1', 'parallel-code-agent:latest');
19071902
});
19081903

1909-
it('awaits docker inner-process kill before calling deleteTask', async () => {
1910-
let resolveDockerKill!: () => void;
1911-
mockExecFile.mockImplementation(
1912-
(
1913-
_cmd: string,
1914-
_args: string[],
1915-
_opts: unknown,
1916-
cb: (err: Error | null, stdout: string, stderr: string) => void,
1917-
) => {
1918-
resolveDockerKill = () => cb(null, '', '');
1919-
},
1920-
);
1921-
1922-
const { deleteTask: mockDeleteTask } =
1923-
await vi.importMock<typeof import('../ipc/tasks.js')>('../ipc/tasks.js');
1924-
vi.mocked(mockDeleteTask).mockResolvedValue(undefined);
1904+
it('closeTask calls killAgent (which stops the sub-task Docker container via pty.ts)', async () => {
1905+
const { killAgent } = await vi.importMock<typeof import('../ipc/pty.js')>('../ipc/pty.js');
19251906

19261907
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
1927-
const closePromise = coordinator.closeTask('task-1');
1928-
1929-
// Flush microtasks — docker kill is pending, deleteTask must not have been called yet
1930-
await Promise.resolve();
1931-
expect(vi.mocked(mockDeleteTask)).not.toHaveBeenCalled();
1908+
await coordinator.closeTask('task-1');
19321909

1933-
// Unblock docker kill — deleteTask should now be called
1934-
resolveDockerKill();
1935-
await closePromise;
1936-
expect(vi.mocked(mockDeleteTask)).toHaveBeenCalled();
1910+
// killAgent is responsible for stopping the Docker container (via stopDockerContainer in pty.ts)
1911+
expect(vi.mocked(killAgent)).toHaveBeenCalled();
19371912
});
19381913

1939-
it('docker kill failure does not prevent deleteTask from being called', async () => {
1940-
mockExecFile.mockImplementation(
1941-
(
1942-
_cmd: string,
1943-
_args: string[],
1944-
_opts: unknown,
1945-
cb: (err: Error | null, stdout: string, stderr: string) => void,
1946-
) => {
1947-
cb(new Error('container not found'), '', '');
1948-
},
1914+
it('closeTask does not call docker exec kill — sub-task has its own container', async () => {
1915+
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
1916+
vi.clearAllMocks(); // reset after createTask
1917+
await coordinator.closeTask('task-1');
1918+
1919+
// execFile is used for git commands only — never for 'docker exec kill'
1920+
const dockerExecKillCall = mockExecFile.mock.calls.find(
1921+
(c) =>
1922+
c[0] === 'docker' &&
1923+
Array.isArray(c[1]) &&
1924+
c[1][0] === 'exec' &&
1925+
c[1].includes('my-coord-container'),
19491926
);
1927+
expect(dockerExecKillCall).toBeUndefined();
1928+
});
1929+
1930+
it('deleteTask is called immediately after killAgent (no waiting for docker exec)', async () => {
1931+
const { deleteTask: mockDeleteTask } =
1932+
await vi.importMock<typeof import('../ipc/tasks.js')>('../ipc/tasks.js');
1933+
vi.mocked(mockDeleteTask).mockResolvedValue(undefined);
19501934

19511935
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
19521936
await coordinator.closeTask('task-1');
19531937

1938+
// deleteTask is called (task is cleaned up)
1939+
expect(vi.mocked(mockDeleteTask)).toHaveBeenCalled();
19541940
expect(coordinator.getTask('task-1')).toBeUndefined();
19551941
expect(mockNotifyRenderer).toHaveBeenCalledWith('mcp_task_closed', { taskId: 'task-1' });
19561942
});
@@ -2094,9 +2080,9 @@ describe('Multiple Docker coordinators — isolation', () => {
20942080
});
20952081
});
20962082

2097-
// ─── Sub-task closes coordinator container ────────────────────────────────────
2083+
// ─── Sub-task per-container spawn (#31) ──────────────────────────────────────
20982084

2099-
describe('Coordinator docker exec sub-task — container lifecycle', () => {
2085+
describe('Coordinator Docker sub-task — per-container spawn', () => {
21002086
let coordinator: InstanceType<typeof Coordinator>;
21012087

21022088
beforeEach(() => {
@@ -2112,27 +2098,31 @@ describe('Coordinator docker exec sub-task — container lifecycle', () => {
21122098
coordinator.setDefaultProject('proj-1', '/tmp/project');
21132099
coordinator.registerCoordinator('coord-1', 'proj-1');
21142100
coordinator.setDockerContainerName('coord-1', 'my-coord-container');
2101+
coordinator.setDockerImage('coord-1', 'parallel-code-agent:latest');
21152102
});
21162103

2117-
it('sub-task spawned via docker exec uses command=docker, not command=claude', async () => {
2104+
it('sub-task spawned with dockerMode: true (docker run), not docker exec', async () => {
21182105
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
21192106

2120-
expect(mockSpawnAgent).toHaveBeenCalledWith(
2121-
expect.anything(),
2122-
expect.objectContaining({ command: 'docker' }),
2123-
);
2124-
2125-
const spawnArgs = mockSpawnAgent.mock.calls[0][1].args as string[];
2126-
// Sub-task uses 'docker exec', not 'docker run' — must not start a new container
2127-
expect(spawnArgs).toContain('exec');
2128-
expect(spawnArgs).not.toContain('run');
2107+
const spawnCall = mockSpawnAgent.mock.calls[0][1] as {
2108+
command: string;
2109+
args: string[];
2110+
dockerMode?: boolean;
2111+
};
2112+
// Must use dockerMode: true so pty.ts builds `docker run`
2113+
expect(spawnCall.dockerMode).toBe(true);
2114+
// Command is the agent, not 'docker'
2115+
expect(spawnCall.command).toBe('claude');
2116+
// Args never contain 'exec'
2117+
expect(spawnCall.args).not.toContain('exec');
21292118
});
21302119

2131-
it('sub-task docker exec references the coordinator container name, not a new container', async () => {
2120+
it('sub-task does NOT reference the coordinator container name in spawn args', async () => {
21322121
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
21332122

2134-
const spawnArgs = mockSpawnAgent.mock.calls[0][1].args as string[];
2135-
expect(spawnArgs).toContain('my-coord-container');
2123+
const spawnCall = mockSpawnAgent.mock.calls[0][1] as { args: string[] };
2124+
// Each sub-task gets its own container — coordinator container name is never used as spawn target
2125+
expect(spawnCall.args).not.toContain('my-coord-container');
21362126
});
21372127
});
21382128

@@ -2806,6 +2796,126 @@ describe('Coordinator closeTask — per-task config isolation (two sub-tasks)',
28062796
});
28072797
});
28082798

2799+
// ─── Docker per-container sub-task tests (#31) ───────────────────────────────
2800+
2801+
describe('Coordinator Docker mode — per-container sub-tasks', () => {
2802+
let coordinator: InstanceType<typeof Coordinator>;
2803+
2804+
beforeEach(() => {
2805+
vi.clearAllMocks();
2806+
mockExistsSync.mockReturnValue(false);
2807+
mockCreateBackendTask.mockResolvedValue({
2808+
id: 'task-docker-1',
2809+
branch_name: 'task/docker-sub',
2810+
worktree_path: '/tmp/project/.worktrees/task/docker-sub',
2811+
});
2812+
coordinator = new Coordinator();
2813+
coordinator.setWindow(mockWin);
2814+
coordinator.setDefaultProject('proj-1', '/tmp/project');
2815+
2816+
// Register coordinator in Docker mode
2817+
coordinator.registerCoordinator('coord-docker', 'proj-1', {
2818+
worktreePath: '/tmp/project/.worktrees/task/coord',
2819+
});
2820+
coordinator.setDockerContainerName('coord-docker', 'parallel-code-abcdef123456');
2821+
coordinator.setDockerImage('coord-docker', 'parallel-code-agent:latest');
2822+
coordinator.setCoordinatorSpawnDefaults('coord-docker', 'claude', []);
2823+
});
2824+
2825+
it('createTask in Docker mode spawns via docker run (dockerMode: true), not docker exec', async () => {
2826+
await coordinator.createTask({
2827+
name: 'docker-sub-task',
2828+
coordinatorTaskId: 'coord-docker',
2829+
});
2830+
2831+
expect(mockSpawnAgent).toHaveBeenCalledOnce();
2832+
const spawnCall = mockSpawnAgent.mock.calls[0][1];
2833+
2834+
// Must use dockerMode: true — never build 'docker exec' args manually
2835+
expect(spawnCall.dockerMode).toBe(true);
2836+
// Command is the agent command (claude), not 'docker'
2837+
expect(spawnCall.command).toBe('claude');
2838+
// Args do not contain 'exec'
2839+
expect(spawnCall.args).not.toContain('exec');
2840+
// Args do not contain the coordinator container name
2841+
expect(spawnCall.args).not.toContain('parallel-code-abcdef123456');
2842+
});
2843+
2844+
it('createTask passes the coordinator Docker image to spawnAgent', async () => {
2845+
await coordinator.createTask({
2846+
name: 'docker-sub-task',
2847+
coordinatorTaskId: 'coord-docker',
2848+
});
2849+
2850+
const spawnCall = mockSpawnAgent.mock.calls[0][1];
2851+
expect(spawnCall.dockerImage).toBe('parallel-code-agent:latest');
2852+
});
2853+
2854+
it('createTask sets dockerMountWorktreeParent: true so coordinator .parallel-code/ is accessible', async () => {
2855+
await coordinator.createTask({
2856+
name: 'docker-sub-task',
2857+
coordinatorTaskId: 'coord-docker',
2858+
});
2859+
2860+
const spawnCall = mockSpawnAgent.mock.calls[0][1];
2861+
expect(spawnCall.dockerMountWorktreeParent).toBe(true);
2862+
});
2863+
2864+
it('createTask in non-Docker mode does not set dockerMode on spawnAgent', async () => {
2865+
coordinator.registerCoordinator('coord-host', 'proj-1');
2866+
coordinator.setCoordinatorSpawnDefaults('coord-host', 'claude', []);
2867+
// No setDockerContainerName call — host mode
2868+
2869+
mockCreateBackendTask.mockResolvedValueOnce({
2870+
id: 'task-host-1',
2871+
branch_name: 'task/host-sub',
2872+
worktree_path: '/tmp/project/.worktrees/task/host-sub',
2873+
});
2874+
2875+
await coordinator.createTask({
2876+
name: 'host-sub-task',
2877+
coordinatorTaskId: 'coord-host',
2878+
});
2879+
2880+
const spawnCall = mockSpawnAgent.mock.calls[0][1];
2881+
expect(spawnCall.dockerMode).toBeUndefined();
2882+
expect(spawnCall.dockerImage).toBeUndefined();
2883+
expect(spawnCall.command).toBe('claude');
2884+
});
2885+
2886+
it('setDockerImage stores the image on coordinator state', () => {
2887+
coordinator.setDockerImage('coord-docker', 'my-custom-image:v2');
2888+
// Verify through createTask spawn — indirectly tests the stored value
2889+
// (direct state access not available, but spawnAgent mock captures it)
2890+
});
2891+
2892+
it('closeTask for a Docker sub-task does not call docker exec kill', async () => {
2893+
const { killAgent } = await vi.importMock<typeof import('../ipc/pty.js')>('../ipc/pty.js');
2894+
2895+
await coordinator.createTask({
2896+
name: 'docker-sub-task',
2897+
coordinatorTaskId: 'coord-docker',
2898+
});
2899+
2900+
const task = coordinator.listTasks().find((t) => t.name === 'docker-sub-task');
2901+
if (!task) throw new Error('task not found');
2902+
2903+
vi.clearAllMocks();
2904+
await coordinator.closeTask(task.id);
2905+
2906+
// killAgent is called (which internally calls stopDockerContainer in pty.ts)
2907+
expect(killAgent).toHaveBeenCalledWith(expect.any(String));
2908+
2909+
// docker exec should NOT be called for killing the inner process
2910+
// (execFile is called only for git operations in cleanupTask, not docker exec kill)
2911+
const dockerExecKillCall = mockExecFile.mock.calls.find(
2912+
(c) =>
2913+
c[0] === 'docker' && Array.isArray(c[1]) && c[1][0] === 'exec' && c[1].includes('kill'),
2914+
);
2915+
expect(dockerExecKillCall).toBeUndefined();
2916+
});
2917+
});
2918+
28092919
// ─── preload allowlist regression test ───────────────────────────────────────
28102920

28112921
describe('preload.cjs MCP channel allowlist', () => {

0 commit comments

Comments
 (0)