Skip to content

Commit 48cf553

Browse files
committed
fix(coordinator): cleanup failure retained — MCP_TaskCleanupFailed, Docker kill awaited (johannesjo#41)
1 parent c7f8a50 commit 48cf553

5 files changed

Lines changed: 201 additions & 20 deletions

File tree

electron/ipc/channels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,5 @@ export enum IPC {
160160
MCP_TaskHydrated = 'mcp_task_hydrated',
161161
MCP_StaleUrlWarning = 'mcp_stale_url_warning',
162162
MCP_CoordinatedTaskClosed = 'mcp_coordinated_task_closed',
163+
MCP_TaskCleanupFailed = 'mcp_task_cleanup_failed',
163164
}

electron/mcp/coordinator.test.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import os from 'os';
33
import { join, dirname } from 'path';
44

55
// --- fs / child_process mocks (must come before dynamic import) ---
6+
const mockExecFile = vi.fn(
7+
(
8+
_cmd: string,
9+
_args: string[],
10+
_opts: unknown,
11+
cb: (err: Error | null, stdout: string, stderr: string) => void,
12+
) => {
13+
cb(null, '', '');
14+
},
15+
);
16+
17+
vi.mock('child_process', () => ({
18+
execFile: mockExecFile,
19+
}));
20+
621
const mockWriteFileSync = vi.fn();
722
const mockReadFileSync = vi.fn(() => '# existing\n');
823
const mockExistsSync = vi.fn(() => false);
@@ -76,6 +91,7 @@ vi.mock('../ipc/channels.js', () => ({
7691
IPC: {
7792
MCP_TaskCreated: 'mcp_task_created',
7893
MCP_TaskClosed: 'mcp_task_closed',
94+
MCP_TaskCleanupFailed: 'mcp_task_cleanup_failed',
7995
MCP_TaskStateSync: 'mcp_task_state_sync',
8096
MCP_CoordinatorNotificationStaged: 'mcp_coordinator_notification_staged',
8197
MCP_CoordinatorNotificationCleared: 'mcp_coordinator_notification_cleared',
@@ -1796,16 +1812,37 @@ describe('Coordinator cleanupTask — failure resilience', () => {
17961812
coordinator.registerCoordinator('coord-1', 'proj-1');
17971813
});
17981814

1799-
it('deleteTask failure is swallowed and task is removed from map', async () => {
1815+
it('deleteTask failure retains task in map and emits MCP_TaskCleanupFailed, not MCP_TaskClosed', async () => {
18001816
const { deleteTask: mockDeleteTask } =
18011817
await vi.importMock<typeof import('../ipc/tasks.js')>('../ipc/tasks.js');
18021818
vi.mocked(mockDeleteTask).mockRejectedValueOnce(new Error('delete failed'));
18031819

18041820
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
18051821
await coordinator.closeTask('task-1');
18061822

1807-
expect(coordinator.getTask('task-1')).toBeUndefined();
1808-
expect(mockNotifyRenderer).toHaveBeenCalledWith('mcp_task_closed', { taskId: 'task-1' });
1823+
// Task must remain in backend map so retry is possible
1824+
expect(coordinator.getTask('task-1')).toBeDefined();
1825+
// MCP_TaskClosed must NOT be sent
1826+
expect(mockNotifyRenderer).not.toHaveBeenCalledWith('mcp_task_closed', expect.anything());
1827+
// Failure event must be sent with the error message
1828+
expect(mockNotifyRenderer).toHaveBeenCalledWith('mcp_task_cleanup_failed', {
1829+
taskId: 'task-1',
1830+
error: 'delete failed',
1831+
});
1832+
});
1833+
1834+
it('deleteTask failure preserves controlMap and blockedByHumanControl state', async () => {
1835+
const { deleteTask: mockDeleteTask } =
1836+
await vi.importMock<typeof import('../ipc/tasks.js')>('../ipc/tasks.js');
1837+
vi.mocked(mockDeleteTask).mockRejectedValueOnce(new Error('delete failed'));
1838+
1839+
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
1840+
// Simulate the task being under coordinator control
1841+
coordinator.setTaskControl('task-1', 'coordinator');
1842+
await coordinator.closeTask('task-1');
1843+
1844+
// Backend task still findable — retry via closeTask should work
1845+
expect(coordinator.getTask('task-1')).toBeDefined();
18091846
});
18101847

18111848
it('MCP config file deletion failure is swallowed and task is removed', async () => {
@@ -1838,6 +1875,87 @@ describe('Coordinator cleanupTask — failure resilience', () => {
18381875
});
18391876
});
18401877

1878+
// ─── Docker cleanup sequencing ────────────────────────────────────────────────
1879+
1880+
describe('Coordinator cleanupTask — Docker inner-process kill sequencing', () => {
1881+
let coordinator: InstanceType<typeof Coordinator>;
1882+
1883+
beforeEach(() => {
1884+
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+
);
1896+
mockExistsSync.mockReturnValue(false);
1897+
mockCreateBackendTask.mockResolvedValue({
1898+
id: 'task-1',
1899+
branch_name: 'task/test',
1900+
worktree_path: '/tmp/test',
1901+
});
1902+
coordinator = new Coordinator();
1903+
coordinator.setWindow(mockWin);
1904+
coordinator.setDefaultProject('proj-1', '/tmp/project');
1905+
coordinator.registerCoordinator('coord-1', 'proj-1');
1906+
coordinator.setDockerContainerName('coord-1', 'my-coord-container');
1907+
});
1908+
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);
1925+
1926+
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();
1932+
1933+
// Unblock docker kill — deleteTask should now be called
1934+
resolveDockerKill();
1935+
await closePromise;
1936+
expect(vi.mocked(mockDeleteTask)).toHaveBeenCalled();
1937+
});
1938+
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+
},
1949+
);
1950+
1951+
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
1952+
await coordinator.closeTask('task-1');
1953+
1954+
expect(coordinator.getTask('task-1')).toBeUndefined();
1955+
expect(mockNotifyRenderer).toHaveBeenCalledWith('mcp_task_closed', { taskId: 'task-1' });
1956+
});
1957+
});
1958+
18411959
// ─── Token rotation tests ──────────────────────────────────────────────────────
18421960

18431961
describe('Coordinator setMCPServerInfo — token rotation', () => {

electron/mcp/coordinator.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,25 +1087,27 @@ export class Coordinator {
10871087
// (each sub-task has a unique worktree path) rather than pkill -f claude,
10881088
// which would terminate sibling sub-tasks and the coordinator itself.
10891089
if (task.dockerContainerName) {
1090-
execFile(
1091-
'docker',
1092-
[
1093-
'exec',
1094-
task.dockerContainerName,
1095-
'sh',
1096-
'-c',
1097-
'for p in /proc/[0-9]*/cwd; do [ "$(readlink "$p" 2>/dev/null)" = "$1" ] && kill -TERM "$(basename "$(dirname "$p")")" 2>/dev/null; done',
1098-
'--',
1099-
task.worktreePath,
1100-
],
1101-
{ timeout: 5000 },
1102-
() => {
1103-
// Intentionally ignore errors: inner process may have already exited.
1104-
},
1105-
);
1090+
try {
1091+
await execAsync(
1092+
'docker',
1093+
[
1094+
'exec',
1095+
task.dockerContainerName,
1096+
'sh',
1097+
'-c',
1098+
'for p in /proc/[0-9]*/cwd; do [ "$(readlink "$p" 2>/dev/null)" = "$1" ] && kill -TERM "$(basename "$(dirname "$p")")" 2>/dev/null; done',
1099+
'--',
1100+
task.worktreePath,
1101+
],
1102+
{ timeout: 5000 },
1103+
);
1104+
} catch {
1105+
// Inner process may have already exited — failure is expected and harmless.
1106+
}
11061107
}
11071108

1108-
// Remove worktree
1109+
// Remove worktree. If this fails, keep all coordinator state so the caller
1110+
// can retry. Do NOT emit MCP_TaskClosed — the task still exists on disk.
11091111
try {
11101112
await deleteTask({
11111113
agentIds: [task.agentId],
@@ -1115,6 +1117,12 @@ export class Coordinator {
11151117
});
11161118
} catch (err) {
11171119
console.warn('Failed to delete coordinated task worktree:', err);
1120+
this.closingTaskIds.delete(taskId);
1121+
this.notifyRenderer(IPC.MCP_TaskCleanupFailed, {
1122+
taskId,
1123+
error: err instanceof Error ? err.message : String(err),
1124+
});
1125+
return;
11181126
}
11191127

11201128
// Clean up internal state — resolve idle and signal waiters before deleting

src/store/tasks.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,47 @@ describe('sendPrompt', () => {
483483
expect(writePayloads()).toEqual(['\x1b[I', '\x1b[200~line 1\nline 2\x1b[201~', '\r']);
484484
});
485485
});
486+
487+
// ─── MCP_TaskCleanupFailed IPC handler ───────────────────────────────────────
488+
489+
const cleanupFailedHandler = ipcHandlers.get(IPC.MCP_TaskCleanupFailed);
490+
if (!cleanupFailedHandler) throw new Error('mcp_task_cleanup_failed handler not registered');
491+
492+
describe('MCP_TaskCleanupFailed IPC handler', () => {
493+
beforeEach(() => {
494+
vi.clearAllMocks();
495+
mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args));
496+
mockTasks = {
497+
'task-1': {
498+
agentIds: ['agent-1'],
499+
shellAgentIds: [],
500+
closingStatus: 'closing',
501+
},
502+
};
503+
});
504+
505+
it('marks the task closingStatus as error', () => {
506+
cleanupFailedHandler({ taskId: 'task-1', error: 'git worktree delete failed' });
507+
508+
expect(mockTasks['task-1'].closingStatus).toBe('error');
509+
});
510+
511+
it('sets closingError to the error message', () => {
512+
cleanupFailedHandler({ taskId: 'task-1', error: 'git worktree delete failed' });
513+
514+
expect(mockTasks['task-1'].closingError).toBe('git worktree delete failed');
515+
});
516+
517+
it('does NOT remove the task from the store', () => {
518+
cleanupFailedHandler({ taskId: 'task-1', error: 'delete failed' });
519+
520+
expect(mockTasks['task-1']).toBeDefined();
521+
});
522+
523+
it('is a no-op if the task does not exist', () => {
524+
expect(() =>
525+
cleanupFailedHandler({ taskId: 'nonexistent', error: 'delete failed' }),
526+
).not.toThrow();
527+
expect(mockTasks['task-1'].closingStatus).toBe('closing');
528+
});
529+
});

src/store/tasks.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,16 @@ export function initMCPListeners(): () => void {
10251025
}),
10261026
);
10271027

1028+
cleanups.push(
1029+
window.electron.ipcRenderer.on(IPC.MCP_TaskCleanupFailed, (data: unknown) => {
1030+
const { taskId, error } = data as { taskId: string; error: string };
1031+
const task = store.tasks[taskId];
1032+
if (!task) return;
1033+
setStore('tasks', taskId, 'closingStatus', 'error');
1034+
setStore('tasks', taskId, 'closingError', error);
1035+
}),
1036+
);
1037+
10281038
cleanups.push(
10291039
window.electron.ipcRenderer.on(IPC.MCP_CoordinatorNotificationStaged, (data: unknown) => {
10301040
const evt = data as {

0 commit comments

Comments
 (0)