@@ -3,6 +3,21 @@ import os from 'os';
33import { 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+
621const mockWriteFileSync = vi . fn ( ) ;
722const mockReadFileSync = vi . fn ( ( ) => '# existing\n' ) ;
823const 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
18431961describe ( 'Coordinator setMCPServerInfo — token rotation' , ( ) => {
0 commit comments