forked from sanbuphy/learn-coding-agent
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathspawnInProcess.ts
More file actions
328 lines (294 loc) · 10 KB
/
spawnInProcess.ts
File metadata and controls
328 lines (294 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
/**
* 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
}