329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
/**
|
|
* In-process teammate spawning
|
|
*
|
|
* Creates and registers an in-process teammate task. Unlike process-based
|
|
* teammates (tmux/iTerm2), in-process teammates run in the same Node.js
|
|
* process using AsyncLocalStorage for context isolation.
|
|
*
|
|
* The actual agent execution loop is handled by InProcessTeammateTask
|
|
* component (Task #14). This module handles:
|
|
* 1. Creating TeammateContext
|
|
* 2. Creating linked AbortController
|
|
* 3. Registering InProcessTeammateTaskState in AppState
|
|
* 4. Returning spawn result for backend
|
|
*/
|
|
|
|
import sample from 'lodash-es/sample.js'
|
|
import { getSessionId } from '../../bootstrap/state.js'
|
|
import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js'
|
|
import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'
|
|
import type { AppState } from '../../state/AppState.js'
|
|
import { createTaskStateBase, generateTaskId } from '../../Task.js'
|
|
import type {
|
|
InProcessTeammateTaskState,
|
|
TeammateIdentity,
|
|
} from '../../tasks/InProcessTeammateTask/types.js'
|
|
import { createAbortController } from '../abortController.js'
|
|
import { formatAgentId } from '../agentId.js'
|
|
import { registerCleanup } from '../cleanupRegistry.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import { emitTaskTerminatedSdk } from '../sdkEventQueue.js'
|
|
import { evictTaskOutput } from '../task/diskOutput.js'
|
|
import {
|
|
evictTerminalTask,
|
|
registerTask,
|
|
STOPPED_DISPLAY_MS,
|
|
} from '../task/framework.js'
|
|
import { createTeammateContext } from '../teammateContext.js'
|
|
import {
|
|
isPerfettoTracingEnabled,
|
|
registerAgent as registerPerfettoAgent,
|
|
unregisterAgent as unregisterPerfettoAgent,
|
|
} from '../telemetry/perfettoTracing.js'
|
|
import { removeMemberByAgentId } from './teamHelpers.js'
|
|
|
|
type SetAppStateFn = (updater: (prev: AppState) => AppState) => void
|
|
|
|
/**
|
|
* Minimal context required for spawning an in-process teammate.
|
|
* This is a subset of ToolUseContext - only what spawnInProcessTeammate actually uses.
|
|
*/
|
|
export type SpawnContext = {
|
|
setAppState: SetAppStateFn
|
|
toolUseId?: string
|
|
}
|
|
|
|
/**
|
|
* Configuration for spawning an in-process teammate.
|
|
*/
|
|
export type InProcessSpawnConfig = {
|
|
/** Display name for the teammate, e.g., "researcher" */
|
|
name: string
|
|
/** Team this teammate belongs to */
|
|
teamName: string
|
|
/** Initial prompt/task for the teammate */
|
|
prompt: string
|
|
/** Optional UI color for the teammate */
|
|
color?: string
|
|
/** Whether teammate must enter plan mode before implementing */
|
|
planModeRequired: boolean
|
|
/** Optional model override for this teammate */
|
|
model?: string
|
|
}
|
|
|
|
/**
|
|
* Result from spawning an in-process teammate.
|
|
*/
|
|
export type InProcessSpawnOutput = {
|
|
/** Whether spawn was successful */
|
|
success: boolean
|
|
/** Full agent ID (format: "name@team") */
|
|
agentId: string
|
|
/** Task ID for tracking in AppState */
|
|
taskId?: string
|
|
/** AbortController for this teammate (linked to parent) */
|
|
abortController?: AbortController
|
|
/** Teammate context for AsyncLocalStorage */
|
|
teammateContext?: ReturnType<typeof createTeammateContext>
|
|
/** Error message if spawn failed */
|
|
error?: string
|
|
}
|
|
|
|
/**
|
|
* Spawns an in-process teammate.
|
|
*
|
|
* Creates the teammate's context, registers the task in AppState, and returns
|
|
* the spawn result. The actual agent execution is driven by the
|
|
* InProcessTeammateTask component which uses runWithTeammateContext() to
|
|
* execute the agent loop with proper identity isolation.
|
|
*
|
|
* @param config - Spawn configuration
|
|
* @param context - Context with setAppState for registering task
|
|
* @returns Spawn result with teammate info
|
|
*/
|
|
export async function spawnInProcessTeammate(
|
|
config: InProcessSpawnConfig,
|
|
context: SpawnContext,
|
|
): Promise<InProcessSpawnOutput> {
|
|
const { name, teamName, prompt, color, planModeRequired, model } = config
|
|
const { setAppState } = context
|
|
|
|
// Generate deterministic agent ID
|
|
const agentId = formatAgentId(name, teamName)
|
|
const taskId = generateTaskId('in_process_teammate')
|
|
|
|
logForDebugging(
|
|
`[spawnInProcessTeammate] Spawning ${agentId} (taskId: ${taskId})`,
|
|
)
|
|
|
|
try {
|
|
// Create independent AbortController for this teammate
|
|
// Teammates should not be aborted when the leader's query is interrupted
|
|
const abortController = createAbortController()
|
|
|
|
// Get parent session ID for transcript correlation
|
|
const parentSessionId = getSessionId()
|
|
|
|
// Create teammate identity (stored as plain data in AppState)
|
|
const identity: TeammateIdentity = {
|
|
agentId,
|
|
agentName: name,
|
|
teamName,
|
|
color,
|
|
planModeRequired,
|
|
parentSessionId,
|
|
}
|
|
|
|
// Create teammate context for AsyncLocalStorage
|
|
// This will be used by runWithTeammateContext() during agent execution
|
|
const teammateContext = createTeammateContext({
|
|
agentId,
|
|
agentName: name,
|
|
teamName,
|
|
color,
|
|
planModeRequired,
|
|
parentSessionId,
|
|
abortController,
|
|
})
|
|
|
|
// Register agent in Perfetto trace for hierarchy visualization
|
|
if (isPerfettoTracingEnabled()) {
|
|
registerPerfettoAgent(agentId, name, parentSessionId)
|
|
}
|
|
|
|
// Create task state
|
|
const description = `${name}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}`
|
|
|
|
const taskState: InProcessTeammateTaskState = {
|
|
...createTaskStateBase(
|
|
taskId,
|
|
'in_process_teammate',
|
|
description,
|
|
context.toolUseId,
|
|
),
|
|
type: 'in_process_teammate',
|
|
status: 'running',
|
|
identity,
|
|
prompt,
|
|
model,
|
|
abortController,
|
|
awaitingPlanApproval: false,
|
|
spinnerVerb: sample(getSpinnerVerbs()),
|
|
pastTenseVerb: sample(TURN_COMPLETION_VERBS),
|
|
permissionMode: planModeRequired ? 'plan' : 'default',
|
|
isIdle: false,
|
|
shutdownRequested: false,
|
|
lastReportedToolCount: 0,
|
|
lastReportedTokenCount: 0,
|
|
pendingUserMessages: [],
|
|
messages: [], // Initialize to empty array so getDisplayedMessages works immediately
|
|
}
|
|
|
|
// Register cleanup handler for graceful shutdown
|
|
const unregisterCleanup = registerCleanup(async () => {
|
|
logForDebugging(`[spawnInProcessTeammate] Cleanup called for ${agentId}`)
|
|
abortController.abort()
|
|
// Task state will be updated by the execution loop when it detects abort
|
|
})
|
|
taskState.unregisterCleanup = unregisterCleanup
|
|
|
|
// Register task in AppState
|
|
registerTask(taskState, setAppState)
|
|
|
|
logForDebugging(
|
|
`[spawnInProcessTeammate] Registered ${agentId} in AppState`,
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
agentId,
|
|
taskId,
|
|
abortController,
|
|
teammateContext,
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : 'Unknown error during spawn'
|
|
logForDebugging(
|
|
`[spawnInProcessTeammate] Failed to spawn ${agentId}: ${errorMessage}`,
|
|
)
|
|
return {
|
|
success: false,
|
|
agentId,
|
|
error: errorMessage,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kills an in-process teammate by aborting its controller.
|
|
*
|
|
* Note: This is the implementation called by InProcessBackend.kill().
|
|
*
|
|
* @param taskId - Task ID of the teammate to kill
|
|
* @param setAppState - AppState setter
|
|
* @returns true if killed successfully
|
|
*/
|
|
export function killInProcessTeammate(
|
|
taskId: string,
|
|
setAppState: SetAppStateFn,
|
|
): boolean {
|
|
let killed = false
|
|
let teamName: string | null = null
|
|
let agentId: string | null = null
|
|
let toolUseId: string | undefined
|
|
let description: string | undefined
|
|
|
|
setAppState((prev: AppState) => {
|
|
const task = prev.tasks[taskId]
|
|
if (!task || task.type !== 'in_process_teammate') {
|
|
return prev
|
|
}
|
|
|
|
const teammateTask = task as InProcessTeammateTaskState
|
|
|
|
if (teammateTask.status !== 'running') {
|
|
return prev
|
|
}
|
|
|
|
// Capture identity for cleanup after state update
|
|
teamName = teammateTask.identity.teamName
|
|
agentId = teammateTask.identity.agentId
|
|
toolUseId = teammateTask.toolUseId
|
|
description = teammateTask.description
|
|
|
|
// Abort the controller to stop execution
|
|
teammateTask.abortController?.abort()
|
|
|
|
// Call cleanup handler
|
|
teammateTask.unregisterCleanup?.()
|
|
|
|
// Update task state and remove from teamContext.teammates
|
|
killed = true
|
|
|
|
// Call pending idle callbacks to unblock any waiters (e.g., engine.waitForIdle)
|
|
teammateTask.onIdleCallbacks?.forEach(cb => cb())
|
|
|
|
// Remove from teamContext.teammates using the agentId
|
|
let updatedTeamContext = prev.teamContext
|
|
if (prev.teamContext && prev.teamContext.teammates && agentId) {
|
|
const { [agentId]: _, ...remainingTeammates } = prev.teamContext.teammates
|
|
updatedTeamContext = {
|
|
...prev.teamContext,
|
|
teammates: remainingTeammates,
|
|
}
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
teamContext: updatedTeamContext,
|
|
tasks: {
|
|
...prev.tasks,
|
|
[taskId]: {
|
|
...teammateTask,
|
|
status: 'killed' as const,
|
|
notified: true,
|
|
endTime: Date.now(),
|
|
onIdleCallbacks: [], // Clear callbacks to prevent stale references
|
|
messages: teammateTask.messages?.length
|
|
? [teammateTask.messages[teammateTask.messages.length - 1]!]
|
|
: undefined,
|
|
pendingUserMessages: [],
|
|
inProgressToolUseIDs: undefined,
|
|
abortController: undefined,
|
|
unregisterCleanup: undefined,
|
|
currentWorkAbortController: undefined,
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
// Remove from team file (outside state updater to avoid file I/O in callback)
|
|
if (teamName && agentId) {
|
|
removeMemberByAgentId(teamName, agentId)
|
|
}
|
|
|
|
if (killed) {
|
|
void evictTaskOutput(taskId)
|
|
// notified:true was pre-set so no XML notification fires; close the SDK
|
|
// task_started bookend directly. The in-process runner's own
|
|
// completion/failure emit guards on status==='running' so it won't
|
|
// double-emit after seeing status:killed.
|
|
emitTaskTerminatedSdk(taskId, 'stopped', {
|
|
toolUseId,
|
|
summary: description,
|
|
})
|
|
setTimeout(
|
|
evictTerminalTask.bind(null, taskId, setAppState),
|
|
STOPPED_DISPLAY_MS,
|
|
)
|
|
}
|
|
|
|
// Release perfetto agent registry entry
|
|
if (agentId) {
|
|
unregisterPerfettoAgent(agentId)
|
|
}
|
|
|
|
return killed
|
|
}
|