@@ -22,6 +22,7 @@ let mockTasks: Record<string, MockTask> = {};
2222let mockAgents : Record < string , unknown > = { } ;
2323let mockTaskOrder : string [ ] = [ ] ;
2424let mockCollapsedTaskOrder : string [ ] = [ ] ;
25+ let mockProjects : { id : string ; path : string } [ ] = [ ] ;
2526const ipcHandlers = new Map < string , ( data : unknown ) => void > ( ) ;
2627
2728function applySetStore ( ...args : unknown [ ] ) : void {
@@ -72,6 +73,7 @@ vi.mock('./core', () => ({
7273 if ( prop === 'taskOrder' ) return mockTaskOrder ;
7374 if ( prop === 'collapsedTaskOrder' ) return mockCollapsedTaskOrder ;
7475 if ( prop === 'availableAgents' ) return [ ] ;
76+ if ( prop === 'projects' ) return mockProjects ;
7577 return undefined ;
7678 } ,
7779 } ) ,
@@ -122,7 +124,16 @@ vi.stubGlobal('window', {
122124 } ,
123125} ) ;
124126
125- import { initMCPListeners , setTaskControl , collapseTask , sendPrompt } from './tasks' ;
127+ import {
128+ initMCPListeners ,
129+ setTaskControl ,
130+ collapseTask ,
131+ sendPrompt ,
132+ markTaskMcpPending ,
133+ markTaskMcpReady ,
134+ markTaskMcpError ,
135+ retryTaskMcpStartup ,
136+ } from './tasks' ;
126137
127138// ─── Coordinator listener setup ───────────────────────────────────────────────
128139
@@ -135,6 +146,7 @@ beforeEach(() => {
135146 mockAgents = { } ;
136147 mockTaskOrder = [ ] ;
137148 mockCollapsedTaskOrder = [ ] ;
149+ mockProjects = [ ] ;
138150 mockInvoke . mockResolvedValue ( undefined ) ;
139151} ) ;
140152
@@ -296,6 +308,139 @@ describe('hasActiveCoordinator condition — coordinator task removal', () => {
296308 } ) ;
297309} ) ;
298310
311+ // ─── MCP startup failure handling (TODO #43) ─────────────────────────────────
312+
313+ describe ( 'MCP startup status transitions' , ( ) => {
314+ beforeEach ( ( ) => {
315+ vi . clearAllMocks ( ) ;
316+ mockSetStore . mockImplementation ( ( ...args : unknown [ ] ) => applySetStore ( ...args ) ) ;
317+ mockInvoke . mockResolvedValue ( undefined ) ;
318+ mockProjects = [ { id : 'proj-1' , path : '/repo' } ] ;
319+ } ) ;
320+
321+ it ( 'markTaskMcpPending sets status to pending' , ( ) => {
322+ mockTasks [ 'task-1' ] = { agentIds : [ ] , shellAgentIds : [ ] } ;
323+ markTaskMcpPending ( 'task-1' ) ;
324+ expect ( mockTasks [ 'task-1' ] . mcpStartupStatus ) . toBe ( 'pending' ) ;
325+ } ) ;
326+
327+ it ( 'markTaskMcpReady sets status to ready' , ( ) => {
328+ mockTasks [ 'task-1' ] = { agentIds : [ ] , shellAgentIds : [ ] , mcpStartupStatus : 'pending' } ;
329+ markTaskMcpReady ( 'task-1' ) ;
330+ expect ( mockTasks [ 'task-1' ] . mcpStartupStatus ) . toBe ( 'ready' ) ;
331+ } ) ;
332+
333+ it ( 'markTaskMcpError sets status to error with control chars stripped' , ( ) => {
334+ mockTasks [ 'task-1' ] = { agentIds : [ ] , shellAgentIds : [ ] , mcpStartupStatus : 'pending' } ;
335+ // \x1b (ESC, 0x1b) is a control char and gets stripped; printable chars like '[31m' remain
336+ markTaskMcpError ( 'task-1' , 'Connection refused\x1b[31m injected\x1b[0m' ) ;
337+ expect ( mockTasks [ 'task-1' ] . mcpStartupStatus ) . toBe ( 'error' ) ;
338+ expect ( mockTasks [ 'task-1' ] . mcpStartupError ) . toBe ( 'Connection refused[31m injected[0m' ) ;
339+ } ) ;
340+
341+ it ( 'failed StartMCPServer marks coordinator task with error instead of staying pending' , async ( ) => {
342+ mockTasks [ 'coord-1' ] = {
343+ agentIds : [ 'agent-coord' ] ,
344+ shellAgentIds : [ ] ,
345+ coordinatorMode : true ,
346+ projectId : 'proj-1' ,
347+ gitIsolation : 'worktree' ,
348+ worktreePath : '/repo/.worktrees/coord' ,
349+ } ;
350+ mockAgents [ 'agent-coord' ] = { def : { command : 'claude' , args : [ ] } } ;
351+ mockInvoke . mockRejectedValueOnce ( new Error ( 'port in use' ) ) ;
352+
353+ markTaskMcpPending ( 'coord-1' ) ;
354+ await retryTaskMcpStartup ( 'coord-1' ) ;
355+
356+ expect ( mockTasks [ 'coord-1' ] . mcpStartupStatus ) . toBe ( 'error' ) ;
357+ expect ( String ( mockTasks [ 'coord-1' ] . mcpStartupError ) ) . toContain ( 'port in use' ) ;
358+ } ) ;
359+
360+ it ( 'successful StartMCPServer marks coordinator task as ready' , async ( ) => {
361+ mockTasks [ 'coord-1' ] = {
362+ agentIds : [ 'agent-coord' ] ,
363+ shellAgentIds : [ ] ,
364+ coordinatorMode : true ,
365+ projectId : 'proj-1' ,
366+ gitIsolation : 'worktree' ,
367+ worktreePath : '/repo/.worktrees/coord' ,
368+ } ;
369+ mockAgents [ 'agent-coord' ] = { def : { command : 'claude' , args : [ ] } } ;
370+ mockInvoke . mockResolvedValueOnce ( undefined ) ;
371+
372+ markTaskMcpPending ( 'coord-1' ) ;
373+ await retryTaskMcpStartup ( 'coord-1' ) ;
374+
375+ expect ( mockTasks [ 'coord-1' ] . mcpStartupStatus ) . toBe ( 'ready' ) ;
376+ } ) ;
377+
378+ it ( 'child hydration failure marks only that child as error, leaving sibling spawnable' , async ( ) => {
379+ mockTasks [ 'coord-1' ] = {
380+ agentIds : [ ] ,
381+ shellAgentIds : [ ] ,
382+ coordinatorMode : true ,
383+ projectId : 'proj-1' ,
384+ mcpStartupStatus : 'ready' ,
385+ } ;
386+ mockTasks [ 'child-a' ] = {
387+ agentIds : [ ] ,
388+ shellAgentIds : [ ] ,
389+ coordinatedBy : 'coord-1' ,
390+ projectId : 'proj-1' ,
391+ gitIsolation : 'worktree' ,
392+ worktreePath : '/repo/.worktrees/child-a' ,
393+ branchName : 'task/child-a' ,
394+ } ;
395+ mockTasks [ 'child-b' ] = {
396+ agentIds : [ ] ,
397+ shellAgentIds : [ ] ,
398+ coordinatedBy : 'coord-1' ,
399+ projectId : 'proj-1' ,
400+ gitIsolation : 'worktree' ,
401+ worktreePath : '/repo/.worktrees/child-b' ,
402+ branchName : 'task/child-b' ,
403+ } ;
404+
405+ // child-a fails, child-b succeeds
406+ mockInvoke . mockRejectedValueOnce ( new Error ( 'hydrate failed' ) ) . mockResolvedValueOnce ( undefined ) ;
407+
408+ markTaskMcpPending ( 'child-a' ) ;
409+ await retryTaskMcpStartup ( 'child-a' ) ;
410+ markTaskMcpPending ( 'child-b' ) ;
411+ await retryTaskMcpStartup ( 'child-b' ) ;
412+
413+ expect ( mockTasks [ 'child-a' ] . mcpStartupStatus ) . toBe ( 'error' ) ;
414+ expect ( mockTasks [ 'child-b' ] . mcpStartupStatus ) . toBe ( 'ready' ) ;
415+ } ) ;
416+
417+ it ( 'retry of child when coordinator is in error surfaces dependency message' , async ( ) => {
418+ mockTasks [ 'coord-1' ] = {
419+ agentIds : [ ] ,
420+ shellAgentIds : [ ] ,
421+ coordinatorMode : true ,
422+ projectId : 'proj-1' ,
423+ mcpStartupStatus : 'error' ,
424+ } ;
425+ mockTasks [ 'child-1' ] = {
426+ agentIds : [ ] ,
427+ shellAgentIds : [ ] ,
428+ coordinatedBy : 'coord-1' ,
429+ projectId : 'proj-1' ,
430+ gitIsolation : 'worktree' ,
431+ worktreePath : '/repo/.worktrees/child-1' ,
432+ branchName : 'task/child-1' ,
433+ mcpStartupStatus : 'error' ,
434+ } ;
435+
436+ await retryTaskMcpStartup ( 'child-1' ) ;
437+
438+ expect ( mockTasks [ 'child-1' ] . mcpStartupStatus ) . toBe ( 'error' ) ;
439+ expect ( String ( mockTasks [ 'child-1' ] . mcpStartupError ) ) . toContain ( 'coordinator' ) ;
440+ expect ( mockInvoke ) . not . toHaveBeenCalledWith ( IPC . MCP_HydrateCoordinatedTask , expect . anything ( ) ) ;
441+ } ) ;
442+ } ) ;
443+
299444// ─── sendPrompt tests ─────────────────────────────────────────────────────────
300445
301446function writePayloads ( ) : string [ ] {
0 commit comments