/ utils / swarm / backends / InProcessBackend.ts
InProcessBackend.ts
  1  import type { ToolUseContext } from '../../../Tool.js'
  2  import {
  3    findTeammateTaskByAgentId,
  4    requestTeammateShutdown,
  5  } from '../../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
  6  import { parseAgentId } from '../../../utils/agentId.js'
  7  import { logForDebugging } from '../../../utils/debug.js'
  8  import { jsonStringify } from '../../../utils/slowOperations.js'
  9  import {
 10    createShutdownRequestMessage,
 11    writeToMailbox,
 12  } from '../../../utils/teammateMailbox.js'
 13  import { startInProcessTeammate } from '../inProcessRunner.js'
 14  import {
 15    killInProcessTeammate,
 16    spawnInProcessTeammate,
 17  } from '../spawnInProcess.js'
 18  import type {
 19    TeammateExecutor,
 20    TeammateMessage,
 21    TeammateSpawnConfig,
 22    TeammateSpawnResult,
 23  } from './types.js'
 24  
 25  /**
 26   * InProcessBackend implements TeammateExecutor for in-process teammates.
 27   *
 28   * Unlike pane-based backends (tmux/iTerm2), in-process teammates run in the
 29   * same Node.js process with isolated context via AsyncLocalStorage. They:
 30   * - Share resources (API client, MCP connections) with the leader
 31   * - Communicate via file-based mailbox (same as pane-based teammates)
 32   * - Are terminated via AbortController (not kill-pane)
 33   *
 34   * IMPORTANT: Before spawning, call setContext() to provide the ToolUseContext
 35   * needed for AppState access. This is intended for use via the TeammateExecutor
 36   * abstraction (getTeammateExecutor() in registry.ts).
 37   */
 38  export class InProcessBackend implements TeammateExecutor {
 39    readonly type = 'in-process' as const
 40  
 41    /**
 42     * Tool use context for AppState access.
 43     * Must be set via setContext() before spawn() is called.
 44     */
 45    private context: ToolUseContext | null = null
 46  
 47    /**
 48     * Sets the ToolUseContext for this backend.
 49     * Called by TeammateTool before spawning to provide AppState access.
 50     */
 51    setContext(context: ToolUseContext): void {
 52      this.context = context
 53    }
 54  
 55    /**
 56     * In-process backend is always available (no external dependencies).
 57     */
 58    async isAvailable(): Promise<boolean> {
 59      return true
 60    }
 61  
 62    /**
 63     * Spawns an in-process teammate.
 64     *
 65     * Uses spawnInProcessTeammate() to:
 66     * 1. Create TeammateContext via createTeammateContext()
 67     * 2. Create independent AbortController (not linked to parent)
 68     * 3. Register teammate in AppState.tasks
 69     * 4. Start agent execution via startInProcessTeammate()
 70     * 5. Return spawn result with agentId, taskId, abortController
 71     */
 72    async spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> {
 73      if (!this.context) {
 74        logForDebugging(
 75          `[InProcessBackend] spawn() called without context for ${config.name}`,
 76        )
 77        return {
 78          success: false,
 79          agentId: `${config.name}@${config.teamName}`,
 80          error:
 81            'InProcessBackend not initialized. Call setContext() before spawn().',
 82        }
 83      }
 84  
 85      logForDebugging(`[InProcessBackend] spawn() called for ${config.name}`)
 86  
 87      const result = await spawnInProcessTeammate(
 88        {
 89          name: config.name,
 90          teamName: config.teamName,
 91          prompt: config.prompt,
 92          color: config.color,
 93          planModeRequired: config.planModeRequired ?? false,
 94        },
 95        this.context,
 96      )
 97  
 98      // If spawn succeeded, start the agent execution loop
 99      if (
100        result.success &&
101        result.taskId &&
102        result.teammateContext &&
103        result.abortController
104      ) {
105        // Start the agent loop in the background (fire-and-forget)
106        // The prompt is passed through the task state and config
107        startInProcessTeammate({
108          identity: {
109            agentId: result.agentId,
110            agentName: config.name,
111            teamName: config.teamName,
112            color: config.color,
113            planModeRequired: config.planModeRequired ?? false,
114            parentSessionId: result.teammateContext.parentSessionId,
115          },
116          taskId: result.taskId,
117          prompt: config.prompt,
118          teammateContext: result.teammateContext,
119          // Strip messages: the teammate never reads toolUseContext.messages
120          // (runAgent overrides it via createSubagentContext). Passing the
121          // parent's conversation would pin it for the teammate's lifetime.
122          toolUseContext: { ...this.context, messages: [] },
123          abortController: result.abortController,
124          model: config.model,
125          systemPrompt: config.systemPrompt,
126          systemPromptMode: config.systemPromptMode,
127          allowedTools: config.permissions,
128          allowPermissionPrompts: config.allowPermissionPrompts,
129        })
130  
131        logForDebugging(
132          `[InProcessBackend] Started agent execution for ${result.agentId}`,
133        )
134      }
135  
136      return {
137        success: result.success,
138        agentId: result.agentId,
139        taskId: result.taskId,
140        abortController: result.abortController,
141        error: result.error,
142      }
143    }
144  
145    /**
146     * Sends a message to an in-process teammate.
147     *
148     * All teammates use file-based mailboxes for simplicity.
149     */
150    async sendMessage(agentId: string, message: TeammateMessage): Promise<void> {
151      logForDebugging(
152        `[InProcessBackend] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`,
153      )
154  
155      // Parse agentId to get agentName and teamName
156      // agentId format: "agentName@teamName" (e.g., "researcher@my-team")
157      const parsed = parseAgentId(agentId)
158      if (!parsed) {
159        logForDebugging(`[InProcessBackend] Invalid agentId format: ${agentId}`)
160        throw new Error(
161          `Invalid agentId format: ${agentId}. Expected format: agentName@teamName`,
162        )
163      }
164  
165      const { agentName, teamName } = parsed
166  
167      // Write to file-based mailbox
168      await writeToMailbox(
169        agentName,
170        {
171          text: message.text,
172          from: message.from,
173          color: message.color,
174          timestamp: message.timestamp ?? new Date().toISOString(),
175        },
176        teamName,
177      )
178  
179      logForDebugging(`[InProcessBackend] sendMessage() completed for ${agentId}`)
180    }
181  
182    /**
183     * Gracefully terminates an in-process teammate.
184     *
185     * Sends a shutdown request message to the teammate and sets the
186     * shutdownRequested flag. The teammate processes the request and
187     * either approves (exits) or rejects (continues working).
188     *
189     * Unlike pane-based teammates, in-process teammates handle their own
190     * exit via the shutdown flow - no external killPane() is needed.
191     */
192    async terminate(agentId: string, reason?: string): Promise<boolean> {
193      logForDebugging(
194        `[InProcessBackend] terminate() called for ${agentId}: ${reason}`,
195      )
196  
197      if (!this.context) {
198        logForDebugging(
199          `[InProcessBackend] terminate() failed: no context set for ${agentId}`,
200        )
201        return false
202      }
203  
204      // Get current AppState to find the task
205      const state = this.context.getAppState()
206      const task = findTeammateTaskByAgentId(agentId, state.tasks)
207  
208      if (!task) {
209        logForDebugging(
210          `[InProcessBackend] terminate() failed: task not found for ${agentId}`,
211        )
212        return false
213      }
214  
215      // Don't send another shutdown request if one is already pending
216      if (task.shutdownRequested) {
217        logForDebugging(
218          `[InProcessBackend] terminate(): shutdown already requested for ${agentId}`,
219        )
220        return true
221      }
222  
223      // Generate deterministic request ID
224      const requestId = `shutdown-${agentId}-${Date.now()}`
225  
226      // Create shutdown request message
227      const shutdownRequest = createShutdownRequestMessage({
228        requestId,
229        from: 'team-lead', // Terminate is always called by the leader
230        reason,
231      })
232  
233      // Send to teammate's mailbox
234      const teammateAgentName = task.identity.agentName
235      await writeToMailbox(
236        teammateAgentName,
237        {
238          from: 'team-lead',
239          text: jsonStringify(shutdownRequest),
240          timestamp: new Date().toISOString(),
241        },
242        task.identity.teamName,
243      )
244  
245      // Mark the task as shutdown requested
246      requestTeammateShutdown(task.id, this.context.setAppState)
247  
248      logForDebugging(
249        `[InProcessBackend] terminate() sent shutdown request to ${agentId}`,
250      )
251  
252      return true
253    }
254  
255    /**
256     * Force kills an in-process teammate immediately.
257     *
258     * Uses the teammate's AbortController to cancel all async operations
259     * and updates the task state to 'killed'.
260     */
261    async kill(agentId: string): Promise<boolean> {
262      logForDebugging(`[InProcessBackend] kill() called for ${agentId}`)
263  
264      if (!this.context) {
265        logForDebugging(
266          `[InProcessBackend] kill() failed: no context set for ${agentId}`,
267        )
268        return false
269      }
270  
271      // Get current AppState to find the task
272      const state = this.context.getAppState()
273      const task = findTeammateTaskByAgentId(agentId, state.tasks)
274  
275      if (!task) {
276        logForDebugging(
277          `[InProcessBackend] kill() failed: task not found for ${agentId}`,
278        )
279        return false
280      }
281  
282      // Kill the teammate via the existing helper function
283      const killed = killInProcessTeammate(task.id, this.context.setAppState)
284  
285      logForDebugging(
286        `[InProcessBackend] kill() ${killed ? 'succeeded' : 'failed'} for ${agentId}`,
287      )
288  
289      return killed
290    }
291  
292    /**
293     * Checks if an in-process teammate is still active.
294     *
295     * Returns true if the teammate exists, has status 'running',
296     * and its AbortController has not been aborted.
297     */
298    async isActive(agentId: string): Promise<boolean> {
299      logForDebugging(`[InProcessBackend] isActive() called for ${agentId}`)
300  
301      if (!this.context) {
302        logForDebugging(
303          `[InProcessBackend] isActive() failed: no context set for ${agentId}`,
304        )
305        return false
306      }
307  
308      // Get current AppState to find the task
309      const state = this.context.getAppState()
310      const task = findTeammateTaskByAgentId(agentId, state.tasks)
311  
312      if (!task) {
313        logForDebugging(
314          `[InProcessBackend] isActive(): task not found for ${agentId}`,
315        )
316        return false
317      }
318  
319      // Check if task is running and not aborted
320      const isRunning = task.status === 'running'
321      const isAborted = task.abortController?.signal.aborted ?? true
322  
323      const active = isRunning && !isAborted
324  
325      logForDebugging(
326        `[InProcessBackend] isActive() for ${agentId}: ${active} (running=${isRunning}, aborted=${isAborted})`,
327      )
328  
329      return active
330    }
331  }
332  
333  /**
334   * Factory function to create an InProcessBackend instance.
335   * Used by the registry (Task #8) to get backend instances.
336   */
337  export function createInProcessBackend(): InProcessBackend {
338    return new InProcessBackend()
339  }